From 5dd436c19b0187e9952c5cb77e9aed49596d2ca8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 14 Mar 2024 16:49:44 +0200 Subject: [PATCH] feat(examples): add linting to all examples (#12595) Fixes #12588 --- Makefile | 6 +- examples/templates/incus/README.md | 2 +- scripts/examplegen/main.go | 249 +++++++++++++++++++---------- 3 files changed, 169 insertions(+), 88 deletions(-) diff --git a/Makefile b/Makefile index 7f6cf4f6c4..970e1ec051 100644 --- a/Makefile +++ b/Makefile @@ -428,7 +428,7 @@ else endif .PHONY: fmt/shfmt -lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons +lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons .PHONY: lint lint/site-icons: @@ -447,6 +447,10 @@ lint/go: golangci-lint run .PHONY: lint/go +lint/examples: + go run ./scripts/examplegen/main.go -lint +.PHONY: lint/examples + # Use shfmt to determine the shell files, takes editorconfig into consideration. lint/shellcheck: $(SHELL_SRC_FILES) echo "--- shellcheck" diff --git a/examples/templates/incus/README.md b/examples/templates/incus/README.md index 3c055b8bdc..2300e6573f 100644 --- a/examples/templates/incus/README.md +++ b/examples/templates/incus/README.md @@ -1,7 +1,7 @@ --- display_name: Incus System Container with Docker description: Develop in an Incus System Container with Docker using incus -icon: /icon/lxc.svg +icon: ../../../site/static/icon/lxc.svg maintainer_github: coder verified: true tags: [local, incus, lxc, lxd] diff --git a/scripts/examplegen/main.go b/scripts/examplegen/main.go index 1b59632662..742d27b05a 100644 --- a/scripts/examplegen/main.go +++ b/scripts/examplegen/main.go @@ -3,9 +3,12 @@ package main import ( "bytes" "encoding/json" + "errors" + "flag" "fmt" "go/parser" "go/token" + "io" "io/fs" "os" "path" @@ -24,124 +27,198 @@ const ( ) func main() { - if err := run(); err != nil { - panic(err) + lint := flag.Bool("lint", false, "Lint **all** the examples instead of generating the examples.gen.json file") + flag.Parse() + + if err := run(*lint); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %+v\n", err) + os.Exit(1) } } -func run() error { +//nolint:revive // This is a script, not a library. +func run(lint bool) error { fset := token.NewFileSet() src, err := parser.ParseFile(fset, filepath.Join(examplesDir, examplesSrc), nil, parser.ParseComments) if err != nil { return err } + projectFS := os.DirFS(".") + examplesFS := os.DirFS(examplesDir) + var paths []string - for _, comment := range src.Comments { - for _, line := range comment.List { - if s, ok := parseEmbedTag(line.Text); ok && !strings.HasSuffix(s, ".json") { - paths = append(paths, s) + if lint { + files, err := fs.ReadDir(examplesFS, "templates") + if err != nil { + return err + } + + for _, f := range files { + if !f.IsDir() { + continue + } + paths = append(paths, filepath.Join("templates", f.Name())) + } + } else { + for _, comment := range src.Comments { + for _, line := range comment.List { + if s, ok := parseEmbedTag(line.Text); ok && !strings.HasSuffix(s, ".json") { + paths = append(paths, s) + } } } } var examples []codersdk.TemplateExample - files := os.DirFS(examplesDir) + var errs []error for _, name := range paths { - dir, err := fs.Stat(files, name) + te, err := parseTemplateExample(projectFS, examplesFS, name) if err != nil { - return err - } - if !dir.IsDir() { + errs = append(errs, err) continue } - exampleID := dir.Name() - // Each one of these is a example! - readme, err := fs.ReadFile(files, path.Join(name, "README.md")) - if err != nil { - return xerrors.Errorf("example %q does not contain README.md", exampleID) + if te != nil { + examples = append(examples, *te) } - - frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme)) - if err != nil { - return xerrors.Errorf("parse example %q front matter: %w", exampleID, err) - } - - nameRaw, exists := frontMatter.FrontMatter["display_name"] - if !exists { - return xerrors.Errorf("example %q front matter does not contain name", exampleID) - } - - name, valid := nameRaw.(string) - if !valid { - return xerrors.Errorf("example %q name isn't a string", exampleID) - } - - descriptionRaw, exists := frontMatter.FrontMatter["description"] - if !exists { - return xerrors.Errorf("example %q front matter does not contain name", exampleID) - } - - description, valid := descriptionRaw.(string) - if !valid { - return xerrors.Errorf("example %q description isn't a string", exampleID) - } - - tags := []string{} - tagsRaw, exists := frontMatter.FrontMatter["tags"] - if exists { - tagsI, valid := tagsRaw.([]interface{}) - if !valid { - return xerrors.Errorf("example %q tags isn't a slice: type %T", exampleID, tagsRaw) - } - for _, tagI := range tagsI { - tag, valid := tagI.(string) - if !valid { - return xerrors.Errorf("example %q tag isn't a string: type %T", exampleID, tagI) - } - tags = append(tags, tag) - } - } - - var icon string - iconRaw, exists := frontMatter.FrontMatter["icon"] - if exists { - icon, valid = iconRaw.(string) - if !valid { - return xerrors.Errorf("example %q icon isn't a string", exampleID) - } - icon, err = filepath.Rel("../site/static/", filepath.Join(examplesDir, name, icon)) - if err != nil { - return xerrors.Errorf("example %q icon is not in site/static: %w", exampleID, err) - } - // The FE needs a static path! - icon = "/" + icon - } - - examples = append(examples, codersdk.TemplateExample{ - ID: exampleID, - Name: name, - Description: description, - Icon: icon, - Tags: tags, - Markdown: string(frontMatter.Content), - - // URL is set by examples/examples.go. - }) } - w := os.Stdout + if len(errs) > 0 { + return xerrors.Errorf("parse failed: %w", errors.Join(errs...)) + } + + var w io.Writer = os.Stdout + if lint { + w = io.Discard + } _, err = fmt.Fprint(w, "// Code generated by examplegen. DO NOT EDIT.\n") if err != nil { return err } - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(examples) } +func parseTemplateExample(projectFS, examplesFS fs.FS, name string) (te *codersdk.TemplateExample, err error) { + var errs []error + defer func() { + if err != nil { + errs = append([]error{err}, errs...) + } + if len(errs) > 0 { + err = xerrors.Errorf("example %q has errors", name) + for _, e := range errs { + err = errors.Join(err, e) + } + } + }() + + dir, err := fs.Stat(examplesFS, name) + if err != nil { + return nil, err + } + if !dir.IsDir() { + //nolint:nilnil // This is a script, not a library. + return nil, nil + } + + exampleID := dir.Name() + // Each one of these is a example! + readme, err := fs.ReadFile(examplesFS, path.Join(name, "README.md")) + if err != nil { + return nil, xerrors.New("missing README.md") + } + + frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme)) + if err != nil { + return nil, xerrors.Errorf("parse front matter: %w", err) + } + + // Make sure validation here is in sync with requirements for + // coder/registry. + displayName, err := getString(frontMatter.FrontMatter, "display_name") + if err != nil { + errs = append(errs, err) + } + + description, err := getString(frontMatter.FrontMatter, "description") + if err != nil { + errs = append(errs, err) + } + + _, err = getString(frontMatter.FrontMatter, "maintainer_github") + if err != nil { + errs = append(errs, err) + } + + tags := []string{} + tagsRaw, exists := frontMatter.FrontMatter["tags"] + if exists { + tagsI, valid := tagsRaw.([]interface{}) + if !valid { + errs = append(errs, xerrors.Errorf("tags isn't a slice: type %T", tagsRaw)) + } else { + for _, tagI := range tagsI { + tag, valid := tagI.(string) + if !valid { + errs = append(errs, xerrors.Errorf("tag isn't a string: type %T", tagI)) + continue + } + tags = append(tags, tag) + } + } + } + + var icon string + icon, err = getString(frontMatter.FrontMatter, "icon") + if err != nil { + errs = append(errs, err) + } else { + cleanPath := filepath.Clean(filepath.Join(examplesDir, name, icon)) + _, err := fs.Stat(projectFS, cleanPath) + if err != nil { + errs = append(errs, xerrors.Errorf("icon does not exist: %w", err)) + } + if !strings.HasPrefix(cleanPath, filepath.Join("site", "static")) { + errs = append(errs, xerrors.Errorf("icon is not in site/static/: %q", icon)) + } + icon, err = filepath.Rel(filepath.Join("site", "static"), cleanPath) + if err != nil { + errs = append(errs, xerrors.Errorf("cannot make icon relative to site/static: %w", err)) + } + } + + if len(errs) > 0 { + return nil, xerrors.New("front matter validation failed") + } + + return &codersdk.TemplateExample{ + ID: exampleID, + Name: displayName, + Description: description, + Icon: "/" + icon, // The FE needs a static path! + Tags: tags, + Markdown: string(frontMatter.Content), + + // URL is set by examples/examples.go. + }, nil +} + +func getString(m map[string]any, key string) (string, error) { + v, ok := m[key] + if !ok { + return "", xerrors.Errorf("front matter does not contain %q", key) + } + vv, ok := v.(string) + if !ok { + return "", xerrors.Errorf("%q isn't a string", key) + } + return vv, nil +} + func parseEmbedTag(s string) (string, bool) { if !strings.HasPrefix(s, "//go:embed") { return "", false