Skip to content

Commit 5dd436c

Browse files
authored
feat(examples): add linting to all examples (coder#12595)
Fixes coder#12588
1 parent 410a7d5 commit 5dd436c

File tree

3 files changed

+157
-76
lines changed

3 files changed

+157
-76
lines changed

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ else
428428
endif
429429
.PHONY: fmt/shfmt
430430

431-
lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons
431+
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons
432432
.PHONY: lint
433433

434434
lint/site-icons:
@@ -447,6 +447,10 @@ lint/go:
447447
golangci-lint run
448448
.PHONY: lint/go
449449

450+
lint/examples:
451+
go run ./scripts/examplegen/main.go -lint
452+
.PHONY: lint/examples
453+
450454
# Use shfmt to determine the shell files, takes editorconfig into consideration.
451455
lint/shellcheck: $(SHELL_SRC_FILES)
452456
echo "--- shellcheck"

examples/templates/incus/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
display_name: Incus System Container with Docker
33
description: Develop in an Incus System Container with Docker using incus
4-
icon: /icon/lxc.svg
4+
icon: ../../../site/static/icon/lxc.svg
55
maintainer_github: coder
66
verified: true
77
tags: [local, incus, lxc, lxd]

scripts/examplegen/main.go

Lines changed: 151 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package main
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
7+
"flag"
68
"fmt"
79
"go/parser"
810
"go/token"
11+
"io"
912
"io/fs"
1013
"os"
1114
"path"
@@ -24,122 +27,196 @@ const (
2427
)
2528

2629
func main() {
27-
if err := run(); err != nil {
28-
panic(err)
30+
lint := flag.Bool("lint", false, "Lint **all** the examples instead of generating the examples.gen.json file")
31+
flag.Parse()
32+
33+
if err := run(*lint); err != nil {
34+
_, _ = fmt.Fprintf(os.Stderr, "error: %+v\n", err)
35+
os.Exit(1)
2936
}
3037
}
3138

32-
func run() error {
39+
//nolint:revive // This is a script, not a library.
40+
func run(lint bool) error {
3341
fset := token.NewFileSet()
3442
src, err := parser.ParseFile(fset, filepath.Join(examplesDir, examplesSrc), nil, parser.ParseComments)
3543
if err != nil {
3644
return err
3745
}
3846

47+
projectFS := os.DirFS(".")
48+
examplesFS := os.DirFS(examplesDir)
49+
3950
var paths []string
40-
for _, comment := range src.Comments {
41-
for _, line := range comment.List {
42-
if s, ok := parseEmbedTag(line.Text); ok && !strings.HasSuffix(s, ".json") {
43-
paths = append(paths, s)
51+
if lint {
52+
files, err := fs.ReadDir(examplesFS, "templates")
53+
if err != nil {
54+
return err
55+
}
56+
57+
for _, f := range files {
58+
if !f.IsDir() {
59+
continue
60+
}
61+
paths = append(paths, filepath.Join("templates", f.Name()))
62+
}
63+
} else {
64+
for _, comment := range src.Comments {
65+
for _, line := range comment.List {
66+
if s, ok := parseEmbedTag(line.Text); ok && !strings.HasSuffix(s, ".json") {
67+
paths = append(paths, s)
68+
}
4469
}
4570
}
4671
}
4772

4873
var examples []codersdk.TemplateExample
49-
files := os.DirFS(examplesDir)
74+
var errs []error
5075
for _, name := range paths {
51-
dir, err := fs.Stat(files, name)
76+
te, err := parseTemplateExample(projectFS, examplesFS, name)
5277
if err != nil {
53-
return err
54-
}
55-
if !dir.IsDir() {
78+
errs = append(errs, err)
5679
continue
5780
}
58-
exampleID := dir.Name()
59-
// Each one of these is a example!
60-
readme, err := fs.ReadFile(files, path.Join(name, "README.md"))
61-
if err != nil {
62-
return xerrors.Errorf("example %q does not contain README.md", exampleID)
81+
if te != nil {
82+
examples = append(examples, *te)
6383
}
84+
}
6485

65-
frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme))
86+
if len(errs) > 0 {
87+
return xerrors.Errorf("parse failed: %w", errors.Join(errs...))
88+
}
89+
90+
var w io.Writer = os.Stdout
91+
if lint {
92+
w = io.Discard
93+
}
94+
95+
_, err = fmt.Fprint(w, "// Code generated by examplegen. DO NOT EDIT.\n")
96+
if err != nil {
97+
return err
98+
}
99+
100+
enc := json.NewEncoder(w)
101+
enc.SetIndent("", " ")
102+
return enc.Encode(examples)
103+
}
104+
105+
func parseTemplateExample(projectFS, examplesFS fs.FS, name string) (te *codersdk.TemplateExample, err error) {
106+
var errs []error
107+
defer func() {
66108
if err != nil {
67-
return xerrors.Errorf("parse example %q front matter: %w", exampleID, err)
109+
errs = append([]error{err}, errs...)
68110
}
69-
70-
nameRaw, exists := frontMatter.FrontMatter["display_name"]
71-
if !exists {
72-
return xerrors.Errorf("example %q front matter does not contain name", exampleID)
111+
if len(errs) > 0 {
112+
err = xerrors.Errorf("example %q has errors", name)
113+
for _, e := range errs {
114+
err = errors.Join(err, e)
115+
}
73116
}
117+
}()
74118

75-
name, valid := nameRaw.(string)
76-
if !valid {
77-
return xerrors.Errorf("example %q name isn't a string", exampleID)
78-
}
119+
dir, err := fs.Stat(examplesFS, name)
120+
if err != nil {
121+
return nil, err
122+
}
123+
if !dir.IsDir() {
124+
//nolint:nilnil // This is a script, not a library.
125+
return nil, nil
126+
}
79127

80-
descriptionRaw, exists := frontMatter.FrontMatter["description"]
81-
if !exists {
82-
return xerrors.Errorf("example %q front matter does not contain name", exampleID)
83-
}
128+
exampleID := dir.Name()
129+
// Each one of these is a example!
130+
readme, err := fs.ReadFile(examplesFS, path.Join(name, "README.md"))
131+
if err != nil {
132+
return nil, xerrors.New("missing README.md")
133+
}
84134

85-
description, valid := descriptionRaw.(string)
86-
if !valid {
87-
return xerrors.Errorf("example %q description isn't a string", exampleID)
88-
}
135+
frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme))
136+
if err != nil {
137+
return nil, xerrors.Errorf("parse front matter: %w", err)
138+
}
89139

90-
tags := []string{}
91-
tagsRaw, exists := frontMatter.FrontMatter["tags"]
92-
if exists {
93-
tagsI, valid := tagsRaw.([]interface{})
94-
if !valid {
95-
return xerrors.Errorf("example %q tags isn't a slice: type %T", exampleID, tagsRaw)
96-
}
140+
// Make sure validation here is in sync with requirements for
141+
// coder/registry.
142+
displayName, err := getString(frontMatter.FrontMatter, "display_name")
143+
if err != nil {
144+
errs = append(errs, err)
145+
}
146+
147+
description, err := getString(frontMatter.FrontMatter, "description")
148+
if err != nil {
149+
errs = append(errs, err)
150+
}
151+
152+
_, err = getString(frontMatter.FrontMatter, "maintainer_github")
153+
if err != nil {
154+
errs = append(errs, err)
155+
}
156+
157+
tags := []string{}
158+
tagsRaw, exists := frontMatter.FrontMatter["tags"]
159+
if exists {
160+
tagsI, valid := tagsRaw.([]interface{})
161+
if !valid {
162+
errs = append(errs, xerrors.Errorf("tags isn't a slice: type %T", tagsRaw))
163+
} else {
97164
for _, tagI := range tagsI {
98165
tag, valid := tagI.(string)
99166
if !valid {
100-
return xerrors.Errorf("example %q tag isn't a string: type %T", exampleID, tagI)
167+
errs = append(errs, xerrors.Errorf("tag isn't a string: type %T", tagI))
168+
continue
101169
}
102170
tags = append(tags, tag)
103171
}
104172
}
173+
}
105174

106-
var icon string
107-
iconRaw, exists := frontMatter.FrontMatter["icon"]
108-
if exists {
109-
icon, valid = iconRaw.(string)
110-
if !valid {
111-
return xerrors.Errorf("example %q icon isn't a string", exampleID)
112-
}
113-
icon, err = filepath.Rel("../site/static/", filepath.Join(examplesDir, name, icon))
114-
if err != nil {
115-
return xerrors.Errorf("example %q icon is not in site/static: %w", exampleID, err)
116-
}
117-
// The FE needs a static path!
118-
icon = "/" + icon
175+
var icon string
176+
icon, err = getString(frontMatter.FrontMatter, "icon")
177+
if err != nil {
178+
errs = append(errs, err)
179+
} else {
180+
cleanPath := filepath.Clean(filepath.Join(examplesDir, name, icon))
181+
_, err := fs.Stat(projectFS, cleanPath)
182+
if err != nil {
183+
errs = append(errs, xerrors.Errorf("icon does not exist: %w", err))
119184
}
185+
if !strings.HasPrefix(cleanPath, filepath.Join("site", "static")) {
186+
errs = append(errs, xerrors.Errorf("icon is not in site/static/: %q", icon))
187+
}
188+
icon, err = filepath.Rel(filepath.Join("site", "static"), cleanPath)
189+
if err != nil {
190+
errs = append(errs, xerrors.Errorf("cannot make icon relative to site/static: %w", err))
191+
}
192+
}
120193

121-
examples = append(examples, codersdk.TemplateExample{
122-
ID: exampleID,
123-
Name: name,
124-
Description: description,
125-
Icon: icon,
126-
Tags: tags,
127-
Markdown: string(frontMatter.Content),
128-
129-
// URL is set by examples/examples.go.
130-
})
194+
if len(errs) > 0 {
195+
return nil, xerrors.New("front matter validation failed")
131196
}
132197

133-
w := os.Stdout
198+
return &codersdk.TemplateExample{
199+
ID: exampleID,
200+
Name: displayName,
201+
Description: description,
202+
Icon: "/" + icon, // The FE needs a static path!
203+
Tags: tags,
204+
Markdown: string(frontMatter.Content),
134205

135-
_, err = fmt.Fprint(w, "// Code generated by examplegen. DO NOT EDIT.\n")
136-
if err != nil {
137-
return err
138-
}
206+
// URL is set by examples/examples.go.
207+
}, nil
208+
}
139209

140-
enc := json.NewEncoder(os.Stdout)
141-
enc.SetIndent("", " ")
142-
return enc.Encode(examples)
210+
func getString(m map[string]any, key string) (string, error) {
211+
v, ok := m[key]
212+
if !ok {
213+
return "", xerrors.Errorf("front matter does not contain %q", key)
214+
}
215+
vv, ok := v.(string)
216+
if !ok {
217+
return "", xerrors.Errorf("%q isn't a string", key)
218+
}
219+
return vv, nil
143220
}
144221

145222
func parseEmbedTag(s string) (string, bool) {

0 commit comments

Comments
 (0)