feat(11.1-01): verify src= code blocks and add summer docs:sync
- src= fences name a file, a Go declaration or Example body, or a docs:start region - confinement: relative clean paths inside the root, no dotfiles or .env, no nested go.mod modules, Examples need // Output:, test regions must run - a drifted or missing snippet is a problem, so docs:build writes nothing - docs:sync rewrites drifted fence bodies in place - fences render in figure.code with a source caption; .md fences keep only the language - bonfire ExampleCall is the first verified example, shown in setup/installation
This commit is contained in:
@@ -73,6 +73,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
|------|----------|
|
||||
| `admin/` | The Vue 3 and TypeScript admin SPA; its build output is embedded by boardwalk. |
|
||||
| `cmd/` | The `summer` CLI (`cmd/summer`). |
|
||||
| `docs/` | Documentation source; `summer docs:build` renders it into a static site. |
|
||||
| `examples/` | Example applications; `examples/hello` is the reference application and workspace. |
|
||||
| `internal/` | CLI internals: the build and scaffolding generator, the watch loop and an OpenAPI conversion tool. |
|
||||
| `modules/` | The framework modules, one Go package each, listed below. |
|
||||
@@ -145,6 +146,8 @@ npm --prefix admin run gen:api # regenerate TypeScript types from admin/open
|
||||
|
||||
`summer dev` watches an application's sources and rebuilds its binary on change.
|
||||
|
||||
`summer docs:build` renders `docs/` and every module README into a static site under `site/` (use `--out` for another directory, `--check` to validate without writing). Code blocks with a `src=` reference are copies of real source; after changing that source, run `summer docs:sync` to refresh the copies.
|
||||
|
||||
## Design notes
|
||||
|
||||
Decisions and background live in [`.planning/notes/`](.planning/notes/), including:
|
||||
|
||||
@@ -45,6 +45,32 @@ func docsBuildCommand() bonfire.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func docsSyncCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:sync",
|
||||
Description: "Rewrite src= code blocks from their sources",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
result, problems, err := docsite.Sync(docsOptions(in))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return reportDocsProblems(out, "docs:sync", problems)
|
||||
}
|
||||
if result.Snippets == 0 {
|
||||
out.Printf("docs:sync: all snippets up to date\n")
|
||||
return nil
|
||||
}
|
||||
out.Printf("docs:sync: updated %d snippets in %d files\n", result.Snippets, result.Files)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func docsOptions(in bonfire.Input) docsite.Options {
|
||||
var opts docsite.Options
|
||||
opts.Root, _ = in.Flag("root")
|
||||
|
||||
@@ -47,6 +47,7 @@ func toolCommands() []bonfire.Command {
|
||||
delegateScheduleRunCommand(),
|
||||
delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"),
|
||||
docsBuildCommand(),
|
||||
docsSyncCommand(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
for _, c := range toolCommands() {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build"} {
|
||||
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build", "docs:sync"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("missing %s in %v", want, names)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
"schedule:run": {"--once"},
|
||||
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
|
||||
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
|
||||
"docs:sync": {"--src", "--root"},
|
||||
}
|
||||
for cmd, wants := range helpWants {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -33,3 +33,27 @@ go test -short ./...
|
||||
```
|
||||
|
||||
`go test -short` skips the tests that need Docker. Run `go test ./...` without `-short` when Docker is available.
|
||||
|
||||
## Check your install
|
||||
|
||||
Console commands in SummerCMS are plain `bonfire.Command` values: a name in `namespace:verb` form, its arguments and flags, and a run function that reads input and writes output. The `summer` tool and every application binary are built from such values, and `bonfire.Call` runs one in-process, which is how tests call commands. This example comes from the framework's own tests, so it compiles and runs whenever you run `go test ./...`:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleCall
|
||||
commands := []bonfire.Command{{
|
||||
Name: "acme:greet",
|
||||
Description: "Greet someone by name",
|
||||
Args: []bonfire.Arg{{Name: "name", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name, _ := in.Argument("name")
|
||||
out.Printf("Hello, %s\n", name)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
// Output: Hello, blog
|
||||
```
|
||||
|
||||
If the tests above pass, this example ran and printed `Hello, blog`. See the [bonfire](../../modules/bonfire/README.md) reference for flags, prompts and styled output.
|
||||
|
||||
@@ -147,3 +147,232 @@ func TestReadmeIngestion(t *testing.T) {
|
||||
t.Errorf("search index missing the alpha Usage entry: %s", search)
|
||||
}
|
||||
}
|
||||
|
||||
const snippetSite = `title: Acme
|
||||
description: Acme docs.
|
||||
sections:
|
||||
- name: setup
|
||||
title: Setup
|
||||
`
|
||||
|
||||
const libGo = `package lib
|
||||
|
||||
// Greeting returns a greeting.
|
||||
func Greeting(name string) string {
|
||||
return "Hello, " + name
|
||||
}
|
||||
|
||||
const (
|
||||
// A is the first.
|
||||
A = 1
|
||||
B = 2
|
||||
)
|
||||
`
|
||||
|
||||
const libTestGo = `package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGreeting(t *testing.T) {
|
||||
// docs:start call
|
||||
got := Greeting("x")
|
||||
// docs:end call
|
||||
if got == "" || check() == "" {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
}
|
||||
|
||||
func check() string {
|
||||
// docs:start called
|
||||
return Greeting("y")
|
||||
// docs:end called
|
||||
}
|
||||
|
||||
func helper() string {
|
||||
// docs:start orphan
|
||||
return "x"
|
||||
// docs:end orphan
|
||||
}
|
||||
|
||||
func ExampleGreeting() {
|
||||
fmt.Println(Greeting("blog"))
|
||||
// Output: Hello, blog
|
||||
}
|
||||
|
||||
func ExampleB() {
|
||||
_ = Greeting("x")
|
||||
}
|
||||
`
|
||||
|
||||
// snippetTree is a repository fixture with Go sources, tests and YAML that
|
||||
// src= references can name.
|
||||
func snippetTree(t *testing.T, extra map[string]string) string {
|
||||
t.Helper()
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.com/acme\n\ngo 1.27\n",
|
||||
"docs/site.yaml": snippetSite,
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
|
||||
"pkg/lib.go": libGo,
|
||||
"pkg/lib_test.go": libTestGo,
|
||||
"config/app.yaml": "app:\n # docs:start db\n db:\n host: localhost\n # docs:end db\n",
|
||||
"nested/go.mod": "module example.com/nested\n",
|
||||
"nested/x.go": "package nested\n\n// X is x.\nconst X = 1\n",
|
||||
"nested/x_test.go": "package nested\n",
|
||||
"notests/a.go": "package notests\n\n// A is a.\nconst A = 1\n",
|
||||
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
||||
}
|
||||
for k, v := range extra {
|
||||
files[k] = v
|
||||
}
|
||||
return writeTree(t, files)
|
||||
}
|
||||
|
||||
func TestSnippetForms(t *testing.T) {
|
||||
root := snippetTree(t, nil)
|
||||
for _, tc := range []struct {
|
||||
ref Ref
|
||||
want string
|
||||
}{
|
||||
{Ref{Path: "pkg/lib.go"}, libGo},
|
||||
{Ref{Path: "pkg/lib.go", Fragment: "Greeting"}, "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}"},
|
||||
{Ref{Path: "pkg/lib.go", Fragment: "A"}, "// A is the first.\n\tA = 1"},
|
||||
{Ref{Path: "pkg/lib.go", Fragment: "B"}, "B = 2"},
|
||||
{Ref{Path: "pkg/lib_test.go", Fragment: "ExampleGreeting"}, "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog"},
|
||||
{Ref{Path: "pkg/lib_test.go", Fragment: "call"}, "got := Greeting(\"x\")"},
|
||||
{Ref{Path: "pkg/lib_test.go", Fragment: "called"}, "return Greeting(\"y\")"},
|
||||
{Ref{Path: "pkg/lib_test.go", Fragment: "check"}, "func check() string {\n\t// docs:start called\n\treturn Greeting(\"y\")\n\t// docs:end called\n}"},
|
||||
{Ref{Path: "config/app.yaml", Fragment: "db"}, "db:\n host: localhost"},
|
||||
} {
|
||||
got, err := Extract(root, tc.ref)
|
||||
if err != nil {
|
||||
t.Errorf("Extract(%s): %v", tc.ref, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Extract(%s) = %q, want %q", tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, ref := range []Ref{{Path: "pkg/lib.go", Fragment: "Missing"}, {Path: "pkg/nope.go"}, {Path: "config/app.yaml", Fragment: "nope"}} {
|
||||
if _, err := Extract(root, ref); err != errSnippetNotFound {
|
||||
t.Errorf("Extract(%s) err = %v, want not found", ref, err)
|
||||
}
|
||||
}
|
||||
if ref, ok := ParseSrc("go src=pkg/lib.go#Greeting title=x"); !ok || ref != (Ref{Path: "pkg/lib.go", Fragment: "Greeting"}) {
|
||||
t.Errorf("ParseSrc = %+v %v", ref, ok)
|
||||
}
|
||||
if _, ok := ParseSrc("go"); ok {
|
||||
t.Error("ParseSrc found src= in a bare info string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetConfinement(t *testing.T) {
|
||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct{ src, want string }{
|
||||
{"/etc/passwd", "path must be relative"},
|
||||
{"../x.go", "must not leave the repository root"},
|
||||
{".env", "dotfile or .env file"},
|
||||
{"config/prod.env", "dotfile or .env file"},
|
||||
{"nested/x.go#X", "nested module nested/go.mod"},
|
||||
{"escape.txt", "outside the repository root"},
|
||||
{"notests/a.go#A", "no _test.go files"},
|
||||
{"pkg/lib_test.go#ExampleB", "has no // Output: comment"},
|
||||
{"pkg/lib_test.go#orphan", "not inside a Test or Example function"},
|
||||
{"pkg/lib_test.go#helper", "not inside a Test or Example function"},
|
||||
{"pkg/nope.go", "pkg/nope.go not found"},
|
||||
}
|
||||
var body strings.Builder
|
||||
lines := map[string]int{}
|
||||
line := 8 // frontmatter (6 lines), "# Snippets", blank
|
||||
for _, tc := range cases {
|
||||
body.WriteString("```go src=" + tc.src + "\n```\n\n")
|
||||
line++
|
||||
lines[tc.src] = line
|
||||
line += 2
|
||||
}
|
||||
root := snippetTree(t, map[string]string{
|
||||
"docs/setup/snippets.md": page("Snippets", "setup", 20, body.String()),
|
||||
"config/prod.env": "SECRET=1\n",
|
||||
})
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
problems, err := Check(Options{Root: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := problemLines(problems)
|
||||
if len(got) != len(cases) {
|
||||
t.Errorf("got %d problems, want %d:\n%s", len(got), len(cases), strings.Join(got, "\n"))
|
||||
}
|
||||
for _, tc := range cases {
|
||||
prefix := "docs/setup/snippets.md:" + strconv.Itoa(lines[tc.src]) + ": snippet: "
|
||||
if !slices.ContainsFunc(got, func(g string) bool { return strings.HasPrefix(g, prefix) && strings.Contains(g, tc.want) }) {
|
||||
t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n"))
|
||||
}
|
||||
}
|
||||
if _, _, err := Build(Options{Root: root, Out: filepath.Join(t.TempDir(), "site")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRewritesDrift(t *testing.T) {
|
||||
doc := page("Start", "setup", 10, "Intro.\n\n- In a list:\n\n ```go src=pkg/lib.go#Greeting\n func stale() {}\n ```\n\n"+
|
||||
"```yaml src=config/app.yaml#db\ndb:\n host: localhost\n```\n\nOutro.\n")
|
||||
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
||||
path := filepath.Join(root, "docs/setup/start.md")
|
||||
|
||||
problems, err := Check(Options{Root: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "docs/setup/start.md:13: snippet: body differs from pkg/lib.go#Greeting (run: summer docs:sync)"
|
||||
if got := problemLines(problems); !slices.Equal(got, []string{want}) {
|
||||
t.Fatalf("problems = %q, want [%q]", got, want)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "site")
|
||||
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) != 1 {
|
||||
t.Fatalf("Build with drift: %v %v", err, problems)
|
||||
}
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
t.Fatal("Build wrote output despite a drifted snippet")
|
||||
}
|
||||
|
||||
res, problems, err := Sync(Options{Root: root})
|
||||
if err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Sync: %v %v", err, problems)
|
||||
}
|
||||
if res != (SyncResult{Snippets: 1, Files: 1}) {
|
||||
t.Fatalf("Sync = %+v, want 1 snippet in 1 file", res)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDoc := strings.Replace(doc, " func stale() {}\n",
|
||||
" // Greeting returns a greeting.\n func Greeting(name string) string {\n \treturn \"Hello, \" + name\n }\n", 1)
|
||||
if string(got) != wantDoc {
|
||||
t.Fatalf("synced file =\n%s\nwant\n%s", got, wantDoc)
|
||||
}
|
||||
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
||||
t.Fatalf("second Sync = %+v, %v; want up to date", res, err)
|
||||
}
|
||||
if problems, err := Check(Options{Root: root}); err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Check after Sync: %v %q", err, problemLines(problems))
|
||||
}
|
||||
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Build after Sync: %v %v", err, problems)
|
||||
}
|
||||
md, err := os.ReadFile(filepath.Join(out, "setup/start.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(md), "src=") || !strings.Contains(string(md), " ```go\n") || !strings.Contains(string(md), "```yaml\n") {
|
||||
t.Errorf("start.md fences not reduced to the language word:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,11 @@ func assemble(opts Options) (*site, []Problem, error) {
|
||||
if err != nil || s == nil {
|
||||
return nil, problems, err
|
||||
}
|
||||
sp, err := s.checkSnippets()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
problems = append(problems, sp...)
|
||||
if len(problems) == 0 {
|
||||
rp, err := s.render()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"fmt"
|
||||
"html"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
@@ -30,11 +32,84 @@ func newMarkdown() goldmark.Markdown {
|
||||
parser.WithASTTransformers(
|
||||
util.Prioritized(h1Stripper{}, 100),
|
||||
util.Prioritized(linkRewriter{}, 200),
|
||||
util.Prioritized(fenceAnnotator{}, 300),
|
||||
),
|
||||
),
|
||||
// goldmark registers lower priority values last, so 100 overrides the
|
||||
// default html renderer (1000) for fenced code blocks.
|
||||
goldmark.WithRendererOptions(renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 100))),
|
||||
)
|
||||
}
|
||||
|
||||
// fenceAnnotator marks fences that carry src= with the reference and its
|
||||
// source_url link, for codeRenderer's caption.
|
||||
type fenceAnnotator struct{}
|
||||
|
||||
func (fenceAnnotator) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
pctx, _ := pc.Get(pageKey).(*pageContext)
|
||||
src := reader.Source()
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
fc, ok := n.(*ast.FencedCodeBlock)
|
||||
if !entering || !ok || fc.Info == nil {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
ref, ok := ParseSrc(string(fc.Info.Segment.Value(src)))
|
||||
if !ok {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
fc.SetAttributeString("data-src", []byte(ref.String()))
|
||||
if pctx != nil && pctx.site.cfg.SourceURL != "" {
|
||||
fc.SetAttributeString("data-href", []byte(strings.ReplaceAll(pctx.site.cfg.SourceURL, "{path}", ref.Path)))
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
})
|
||||
}
|
||||
|
||||
// codeRenderer renders every fenced code block inside <figure class="code">;
|
||||
// a src= fence gets a <figcaption> naming its source, linked to source_url.
|
||||
type codeRenderer struct{}
|
||||
|
||||
func (codeRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
|
||||
r.Register(ast.KindFencedCodeBlock, renderFence)
|
||||
}
|
||||
|
||||
func attrString(n ast.Node, name string) string {
|
||||
v, ok := n.AttributeString(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
b, _ := v.([]byte)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func renderFence(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.FencedCodeBlock)
|
||||
_, _ = w.WriteString(`<figure class="code">`)
|
||||
if ref := attrString(n, "data-src"); ref != "" {
|
||||
_, _ = w.WriteString("<figcaption>")
|
||||
if href := attrString(n, "data-href"); href != "" {
|
||||
_, _ = w.WriteString(`<a href="` + html.EscapeString(href) + `">` + html.EscapeString(ref) + "</a>")
|
||||
} else {
|
||||
_, _ = w.WriteString(html.EscapeString(ref))
|
||||
}
|
||||
_, _ = w.WriteString("</figcaption>")
|
||||
}
|
||||
_, _ = w.WriteString("<pre><code")
|
||||
if lang := n.Language(src); lang != nil {
|
||||
_, _ = w.WriteString(` class="language-` + html.EscapeString(string(lang)) + `"`)
|
||||
}
|
||||
_ = w.WriteByte('>')
|
||||
for i := 0; i < n.Lines().Len(); i++ {
|
||||
line := n.Lines().At(i)
|
||||
_, _ = w.WriteString(html.EscapeString(string(line.Value(src))))
|
||||
}
|
||||
_, _ = w.WriteString("</code></pre></figure>\n")
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// h1Stripper removes the page's leading "# Title" heading; the template
|
||||
// renders the title once.
|
||||
type h1Stripper struct{}
|
||||
@@ -327,7 +402,8 @@ func closesFence(line string, f fence) bool {
|
||||
}
|
||||
|
||||
// rewriteMarkdown applies the raw-output transforms to a Markdown body:
|
||||
// link destinations outside code fences are replaced through links.
|
||||
// fence info strings are reduced to their language word (dropping src=),
|
||||
// and link destinations outside code fences are replaced through links.
|
||||
func rewriteMarkdown(body string, links map[string]string) string {
|
||||
lines := strings.Split(body, "\n")
|
||||
inFence := make([]bool, len(lines))
|
||||
@@ -345,6 +421,11 @@ func rewriteMarkdown(body string, links map[string]string) string {
|
||||
origs = append(origs, o)
|
||||
}
|
||||
slices.SortFunc(origs, func(a, b string) int { return cmp.Or(cmp.Compare(len(b), len(a)), strings.Compare(a, b)) })
|
||||
for _, f := range scanFences(lines) {
|
||||
if lang, _, _ := strings.Cut(f.info, " "); lang != f.info {
|
||||
lines[f.open] = strings.Repeat(" ", f.indent) + strings.Repeat(string(f.char), f.count) + lang
|
||||
}
|
||||
}
|
||||
for i, line := range lines {
|
||||
if inFence[i] || !strings.Contains(line, "](") {
|
||||
continue
|
||||
|
||||
588
internal/docsite/snippet.go
Normal file
588
internal/docsite/snippet.go
Normal file
@@ -0,0 +1,588 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/doc"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Ref is a src= reference in a code fence info string: a
|
||||
// repository-relative path and an optional fragment (a top-level Go
|
||||
// identifier or a docs:start/docs:end region name).
|
||||
type Ref struct {
|
||||
Path string
|
||||
Fragment string
|
||||
}
|
||||
|
||||
// String returns "path" or "path#fragment".
|
||||
func (r Ref) String() string {
|
||||
if r.Fragment == "" {
|
||||
return r.Path
|
||||
}
|
||||
return r.Path + "#" + r.Fragment
|
||||
}
|
||||
|
||||
// ParseSrc finds the src= field in a fence info string, such as
|
||||
// "go src=modules/bonfire/example_test.go#ExampleCall".
|
||||
func ParseSrc(info string) (Ref, bool) {
|
||||
for _, f := range strings.Fields(info) {
|
||||
if v, ok := strings.CutPrefix(f, "src="); ok {
|
||||
p, frag, _ := strings.Cut(v, "#")
|
||||
return Ref{Path: p, Fragment: frag}, true
|
||||
}
|
||||
}
|
||||
return Ref{}, false
|
||||
}
|
||||
|
||||
// errSnippetNotFound marks a missing file, identifier or region.
|
||||
var errSnippetNotFound = errors.New("not found")
|
||||
|
||||
// snippetError is a src= reference that breaks a confinement or run rule.
|
||||
type snippetError string
|
||||
|
||||
func (e snippetError) Error() string { return string(e) }
|
||||
|
||||
// Extract returns the text a src= reference names, after checking that
|
||||
// the path is confined to the repository root and that Go sources are code
|
||||
// go test compiles and runs:
|
||||
//
|
||||
// - no fragment: the whole file;
|
||||
// - #region: the lines between "// docs:start region" and
|
||||
// "// docs:end region" ("# docs:..." in YAML), dedented;
|
||||
// - #Ident: a top-level Go declaration with its doc comment, verbatim;
|
||||
// for an Example function, its body dedented with the // Output: line.
|
||||
func Extract(root string, ref Ref) (string, error) {
|
||||
if err := checkRefPath(ref.Path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docsite: %w", err)
|
||||
}
|
||||
rootReal, err := filepath.EvalSymlinks(rootAbs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docsite: %w", err)
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(filepath.Join(rootAbs, filepath.FromSlash(ref.Path)))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", errSnippetNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docsite: %w", err)
|
||||
}
|
||||
if !within(real, rootReal) {
|
||||
return "", snippetError("path resolves outside the repository root")
|
||||
}
|
||||
if rel, err := filepath.Rel(rootReal, real); err == nil {
|
||||
if err := checkRefPath(filepath.ToSlash(rel)); err != nil {
|
||||
return "", snippetError("path resolves to a dotfile or .env file")
|
||||
}
|
||||
}
|
||||
st, err := os.Stat(real)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docsite: %w", err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return "", snippetError("path is a directory")
|
||||
}
|
||||
raw, err := os.ReadFile(real)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docsite: %w", err)
|
||||
}
|
||||
|
||||
isGo := strings.HasSuffix(real, ".go")
|
||||
isTest := strings.HasSuffix(real, "_test.go")
|
||||
if isGo {
|
||||
if err := checkRootModule(rootReal, filepath.Dir(real)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isTest {
|
||||
tests, _ := filepath.Glob(filepath.Join(filepath.Dir(real), "*_test.go"))
|
||||
if len(tests) == 0 {
|
||||
return "", snippetError("package has no _test.go files, so go test does not cover it")
|
||||
}
|
||||
}
|
||||
}
|
||||
if ref.Fragment == "" {
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
if text, start, end, found, err := extractRegion(lines, ref.Fragment); err != nil {
|
||||
return "", err
|
||||
} else if found {
|
||||
if isTest {
|
||||
if err := checkTestRegion(real, start, end); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
if !isGo {
|
||||
return "", errSnippetNotFound
|
||||
}
|
||||
return extractIdent(real, raw, ref.Fragment, isTest)
|
||||
}
|
||||
|
||||
// checkRefPath enforces the form of a src= path: relative, clean, inside
|
||||
// the root, and never a dotfile or .env file.
|
||||
func checkRefPath(p string) error {
|
||||
if p == "" {
|
||||
return snippetError("src= has no path")
|
||||
}
|
||||
if filepath.IsAbs(p) || strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) || filepath.VolumeName(p) != "" ||
|
||||
(len(p) > 1 && p[1] == ':') {
|
||||
return snippetError("path must be relative to the repository root")
|
||||
}
|
||||
if strings.Contains(p, `\`) {
|
||||
return snippetError("path must use forward slashes")
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == ".." {
|
||||
return snippetError("path must not leave the repository root")
|
||||
}
|
||||
if strings.HasPrefix(seg, ".") || strings.HasSuffix(seg, ".env") {
|
||||
return snippetError("path names a dotfile or .env file")
|
||||
}
|
||||
}
|
||||
if path.Clean(p) != p {
|
||||
return snippetError("path must be clean (no ./, // or trailing /)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRootModule refuses a Go source that sits in a nested module (a
|
||||
// directory between it and the root holds a go.mod): root go test does not
|
||||
// run it.
|
||||
func checkRootModule(rootReal, dir string) error {
|
||||
for d := dir; d != rootReal && within(d, rootReal); d = filepath.Dir(d) {
|
||||
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
|
||||
rel, _ := filepath.Rel(rootReal, d)
|
||||
return snippetError(fmt.Sprintf("path is inside the nested module %s/go.mod, which root go test does not run", filepath.ToSlash(rel)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractRegion returns the dedented lines between the docs:start and
|
||||
// docs:end markers of a region, and the 1-based marker lines.
|
||||
func extractRegion(lines []string, name string) (text string, start, end int, found bool, err error) {
|
||||
isMarker := func(line, kind string) bool {
|
||||
t := strings.TrimSpace(line)
|
||||
return t == "// docs:"+kind+" "+name || t == "# docs:"+kind+" "+name
|
||||
}
|
||||
start = -1
|
||||
for i, line := range lines {
|
||||
if start < 0 && isMarker(line, "start") {
|
||||
start = i
|
||||
continue
|
||||
}
|
||||
if start >= 0 && isMarker(line, "end") {
|
||||
return strings.Join(dedent(lines[start+1:i]), "\n"), start + 1, i + 1, true, nil
|
||||
}
|
||||
}
|
||||
if start >= 0 {
|
||||
return "", 0, 0, false, snippetError(fmt.Sprintf("region %q has no docs:end marker", name))
|
||||
}
|
||||
return "", 0, 0, false, nil
|
||||
}
|
||||
|
||||
// dedent removes the longest common leading whitespace of the non-blank
|
||||
// lines and trims leading and trailing blank lines.
|
||||
func dedent(lines []string) []string {
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
prefix, set := "", false
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
continue
|
||||
}
|
||||
lead := l[:len(l)-len(strings.TrimLeft(l, " \t"))]
|
||||
if !set {
|
||||
prefix, set = lead, true
|
||||
continue
|
||||
}
|
||||
for !strings.HasPrefix(lead, prefix) {
|
||||
prefix = prefix[:len(prefix)-1]
|
||||
}
|
||||
}
|
||||
out := make([]string, len(lines))
|
||||
for i, l := range lines {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
out[i] = ""
|
||||
continue
|
||||
}
|
||||
out[i] = strings.TrimPrefix(l, prefix)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractIdent returns a top-level declaration by name, or an Example
|
||||
// function's body.
|
||||
func extractIdent(file string, raw []byte, name string, isTest bool) (string, error) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, file, raw, parser.ParseComments)
|
||||
if err != nil {
|
||||
return "", snippetError("cannot parse Go source: " + firstLine(err.Error()))
|
||||
}
|
||||
offset := func(p token.Pos) int { return fset.Position(p).Offset }
|
||||
for _, decl := range f.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if funcKey(d) != name {
|
||||
continue
|
||||
}
|
||||
if isTest {
|
||||
if err := checkTestIdent(file, name, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if d.Recv == nil && strings.HasPrefix(name, "Example") && isTest {
|
||||
if !exampleHasOutput(f, name) {
|
||||
return "", snippetError(name + " has no // Output: comment, so go test compiles it but never runs it")
|
||||
}
|
||||
body := string(raw[offset(d.Body.Lbrace)+1 : offset(d.Body.Rbrace)])
|
||||
return strings.Join(dedent(strings.Split(body, "\n")), "\n"), nil
|
||||
}
|
||||
start := d.Pos()
|
||||
if d.Doc != nil {
|
||||
start = d.Doc.Pos()
|
||||
}
|
||||
return string(raw[offset(start):offset(d.End())]), nil
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range d.Specs {
|
||||
var names []*ast.Ident
|
||||
var specDoc *ast.CommentGroup
|
||||
switch sp := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
names, specDoc = []*ast.Ident{sp.Name}, sp.Doc
|
||||
case *ast.ValueSpec:
|
||||
names, specDoc = sp.Names, sp.Doc
|
||||
}
|
||||
if !slices.ContainsFunc(names, func(id *ast.Ident) bool { return id.Name == name }) {
|
||||
continue
|
||||
}
|
||||
if isTest {
|
||||
if err := checkTestIdent(file, name, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
start, end := d.Pos(), d.End()
|
||||
doc := d.Doc
|
||||
if d.Lparen.IsValid() {
|
||||
start, end, doc = spec.Pos(), spec.End(), specDoc
|
||||
}
|
||||
if doc != nil {
|
||||
start = doc.Pos()
|
||||
}
|
||||
return string(raw[offset(start):offset(end)]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errSnippetNotFound
|
||||
}
|
||||
|
||||
// funcKey is "Name" for a function and "Type.Name" for a method.
|
||||
func funcKey(d *ast.FuncDecl) string {
|
||||
if d.Recv == nil || len(d.Recv.List) == 0 {
|
||||
return d.Name.Name
|
||||
}
|
||||
t := d.Recv.List[0].Type
|
||||
for {
|
||||
switch x := t.(type) {
|
||||
case *ast.StarExpr:
|
||||
t = x.X
|
||||
continue
|
||||
case *ast.IndexExpr:
|
||||
t = x.X
|
||||
continue
|
||||
case *ast.IndexListExpr:
|
||||
t = x.X
|
||||
continue
|
||||
case *ast.Ident:
|
||||
return x.Name + "." + d.Name.Name
|
||||
}
|
||||
return d.Name.Name
|
||||
}
|
||||
}
|
||||
|
||||
func exampleHasOutput(f *ast.File, name string) bool {
|
||||
for _, ex := range doc.Examples(f) {
|
||||
if "Example"+ex.Name == name {
|
||||
return ex.Output != "" || ex.EmptyOutput
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// testGraph is the reachability of the functions in one directory's test
|
||||
// files from its Test and Example functions.
|
||||
type testGraph struct {
|
||||
fset *token.FileSet
|
||||
funcs map[string][]*ast.FuncDecl // by key, per file order
|
||||
reachable map[string]bool // func keys
|
||||
referenced map[string]bool // identifier names used by reachable funcs
|
||||
files map[string]*ast.File // by path
|
||||
}
|
||||
|
||||
func isRoot(d *ast.FuncDecl) bool {
|
||||
return d.Recv == nil && (strings.HasPrefix(d.Name.Name, "Test") || strings.HasPrefix(d.Name.Name, "Example"))
|
||||
}
|
||||
|
||||
func loadTestGraph(dir string) (*testGraph, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*_test.go"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docsite: %w", err)
|
||||
}
|
||||
g := &testGraph{fset: token.NewFileSet(), funcs: map[string][]*ast.FuncDecl{},
|
||||
reachable: map[string]bool{}, referenced: map[string]bool{}, files: map[string]*ast.File{}}
|
||||
byName := map[string][]string{} // plain or method name -> keys
|
||||
var queue []string
|
||||
for _, p := range paths {
|
||||
f, err := parser.ParseFile(g.fset, p, nil, 0)
|
||||
if err != nil {
|
||||
return nil, snippetError("cannot parse " + filepath.Base(p) + ": " + firstLine(err.Error()))
|
||||
}
|
||||
g.files[p] = f
|
||||
for _, decl := range f.Decls {
|
||||
d, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := funcKey(d)
|
||||
g.funcs[key] = append(g.funcs[key], d)
|
||||
byName[d.Name.Name] = append(byName[d.Name.Name], key)
|
||||
if isRoot(d) && !g.reachable[key] {
|
||||
g.reachable[key] = true
|
||||
queue = append(queue, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for len(queue) > 0 {
|
||||
key := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, d := range g.funcs[key] {
|
||||
if d.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(d.Body, func(n ast.Node) bool {
|
||||
id, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
g.referenced[id.Name] = true
|
||||
for _, k := range byName[id.Name] {
|
||||
if !g.reachable[k] {
|
||||
g.reachable[k] = true
|
||||
queue = append(queue, k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
const notRunMessage = "fragment is not inside a Test or Example function, or a function one of them calls, so go test does not run it"
|
||||
|
||||
// checkTestIdent requires a _test.go declaration to be run by go test: a
|
||||
// function reachable from a Test or Example, or a type, var or const that
|
||||
// such a function uses.
|
||||
func checkTestIdent(file, name string, isFunc bool) error {
|
||||
g, err := loadTestGraph(filepath.Dir(file))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (isFunc && g.reachable[name]) || (!isFunc && g.referenced[name]) {
|
||||
return nil
|
||||
}
|
||||
return snippetError(notRunMessage)
|
||||
}
|
||||
|
||||
// checkTestRegion requires the region's marker lines to sit inside one
|
||||
// function of the file that a Test or Example function reaches.
|
||||
func checkTestRegion(file string, start, end int) error {
|
||||
g, err := loadTestGraph(filepath.Dir(file))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := g.files[file]
|
||||
if f == nil {
|
||||
return snippetError(notRunMessage)
|
||||
}
|
||||
for _, decl := range f.Decls {
|
||||
d, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from, to := g.fset.Position(d.Pos()).Line, g.fset.Position(d.End()).Line
|
||||
if from < start && end < to && g.reachable[funcKey(d)] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return snippetError(notRunMessage)
|
||||
}
|
||||
|
||||
// drift is a src= fence whose body differs from its source.
|
||||
type drift struct {
|
||||
f fence
|
||||
want string
|
||||
}
|
||||
|
||||
// checkFences verifies every src= fence in lines. lineBase is the 1-based
|
||||
// file line of lines[0]; file is the display path for problems.
|
||||
func checkFences(root, file string, lines []string, lineBase int) ([]drift, []Problem, error) {
|
||||
var drifts []drift
|
||||
var problems []Problem
|
||||
for _, f := range scanFences(lines) {
|
||||
ref, ok := ParseSrc(f.info)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
line := lineBase + f.open
|
||||
fail := func(msg string) {
|
||||
problems = append(problems, Problem{File: file, Line: line, Rule: "snippet", Message: msg})
|
||||
}
|
||||
if f.close < 0 {
|
||||
fail("code block has no closing fence")
|
||||
continue
|
||||
}
|
||||
want, err := Extract(root, ref)
|
||||
var se snippetError
|
||||
switch {
|
||||
case errors.Is(err, errSnippetNotFound):
|
||||
fail(ref.String() + " not found")
|
||||
continue
|
||||
case errors.As(err, &se):
|
||||
fail(ref.String() + ": " + se.Error())
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, nil, err
|
||||
}
|
||||
if fenceBody(lines, f) != strings.TrimRight(want, "\n") {
|
||||
fail(fmt.Sprintf("body differs from %s (run: summer docs:sync)", ref))
|
||||
drifts = append(drifts, drift{f: f, want: want})
|
||||
}
|
||||
}
|
||||
return drifts, problems, nil
|
||||
}
|
||||
|
||||
// fenceBody returns a fence's body with the fence indentation removed and
|
||||
// trailing newlines trimmed.
|
||||
func fenceBody(lines []string, f fence) string {
|
||||
body := make([]string, 0, f.close-f.open)
|
||||
for _, l := range lines[f.open+1 : f.close] {
|
||||
body = append(body, unindent(l, f.indent))
|
||||
}
|
||||
return strings.TrimRight(strings.Join(body, "\n"), "\n")
|
||||
}
|
||||
|
||||
func unindent(l string, n int) string {
|
||||
i := 0
|
||||
for i < n && i < len(l) && l[i] == ' ' {
|
||||
i++
|
||||
}
|
||||
return l[i:]
|
||||
}
|
||||
|
||||
// checkSnippets verifies the src= fences of every docs page.
|
||||
func (s *site) checkSnippets() ([]Problem, error) {
|
||||
var problems []Problem
|
||||
for _, p := range s.pages {
|
||||
if p.Module != "" {
|
||||
continue
|
||||
}
|
||||
_, ps, err := checkFences(s.opts.Root, p.Source, strings.Split(string(p.Body), "\n"), p.BodyLine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
problems = append(problems, ps...)
|
||||
}
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
// Sync rewrites every drifted src= fence body under Src from its source,
|
||||
// preserving everything else byte for byte. When a reference is missing or
|
||||
// breaks a rule, it returns the problems and writes nothing.
|
||||
func Sync(opts Options) (SyncResult, []Problem, error) {
|
||||
opts, err := opts.normalize()
|
||||
if err != nil {
|
||||
return SyncResult{}, nil, err
|
||||
}
|
||||
s := &site{opts: opts}
|
||||
files, err := walkPages(opts.Src)
|
||||
if err != nil {
|
||||
return SyncResult{}, nil, err
|
||||
}
|
||||
type rewrite struct {
|
||||
path string
|
||||
mode fs.FileMode
|
||||
data []byte
|
||||
}
|
||||
var rewrites []rewrite
|
||||
var problems []Problem
|
||||
var result SyncResult
|
||||
for _, abs := range files {
|
||||
raw, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return SyncResult{}, nil, fmt.Errorf("docs:sync: read %s: %w", abs, err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
drifts, ps, err := checkFences(opts.Root, s.rel(abs), lines, 1)
|
||||
if err != nil {
|
||||
return SyncResult{}, nil, err
|
||||
}
|
||||
for _, p := range ps {
|
||||
if !strings.HasPrefix(p.Message, "body differs from ") {
|
||||
problems = append(problems, p)
|
||||
}
|
||||
}
|
||||
if len(drifts) == 0 {
|
||||
continue
|
||||
}
|
||||
for i := len(drifts) - 1; i >= 0; i-- {
|
||||
d := drifts[i]
|
||||
pad := strings.Repeat(" ", d.f.indent)
|
||||
var body []string
|
||||
for _, l := range strings.Split(strings.TrimRight(d.want, "\n"), "\n") {
|
||||
if l == "" {
|
||||
body = append(body, "")
|
||||
} else {
|
||||
body = append(body, pad+l)
|
||||
}
|
||||
}
|
||||
lines = slices.Concat(lines[:d.f.open+1], body, lines[d.f.close:])
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return SyncResult{}, nil, fmt.Errorf("docs:sync: %w", err)
|
||||
}
|
||||
rewrites = append(rewrites, rewrite{path: abs, mode: st.Mode().Perm(), data: []byte(strings.Join(lines, "\n"))})
|
||||
result.Snippets += len(drifts)
|
||||
result.Files++
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sortProblems(problems)
|
||||
return SyncResult{}, problems, nil
|
||||
}
|
||||
for _, r := range rewrites {
|
||||
if err := os.WriteFile(r.path, r.data, r.mode); err != nil {
|
||||
return SyncResult{}, nil, fmt.Errorf("docs:sync: write %s: %w", r.path, err)
|
||||
}
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
27
modules/bonfire/example_test.go
Normal file
27
modules/bonfire/example_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package bonfire_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
func ExampleCall() {
|
||||
commands := []bonfire.Command{{
|
||||
Name: "acme:greet",
|
||||
Description: "Greet someone by name",
|
||||
Args: []bonfire.Arg{{Name: "name", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name, _ := in.Argument("name")
|
||||
out.Printf("Hello, %s\n", name)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
// Output: Hello, blog
|
||||
}
|
||||
Reference in New Issue
Block a user