From 73c72af244118fc2a9e5602d6dc15ee8a3f3179a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 1 Oct 2026 08:30:19 +0200 Subject: [PATCH] fix(11.1-07): check go fences, README src= and shell fences from the AST - goLang follows the highlighter's chroma lookup, so golang and main.go need src= - a src= fence in a module README is refused and never captioned - shell fences inside callouts and lists are command-checked - a fence with four or more leading spaces is an indented code block --- README.md | 2 +- docs/console/utilities.md | 2 +- internal/docsite/check_commands.go | 18 ++++---- internal/docsite/check_policy.go | 72 +++++++++++++++++------------- internal/docsite/docsite.go | 6 ++- internal/docsite/highlight.go | 9 ++++ internal/docsite/render.go | 4 +- internal/docsite/snippet.go | 24 ++++++++-- 8 files changed, 88 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 958e81e..d3341b7 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ 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. +`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, and `src=` works on top-level code blocks of `docs/` pages only; after changing that source, run `summer docs:sync` to refresh the copies. `summer docs:serve` builds the same site into a temporary directory and previews it at `http://127.0.0.1:8088` (`--addr` to change it), rebuilding when the docs, a module or a `src=` source changes; a failed rebuild prints its problems and keeps serving the last good build. It listens only on a loopback address unless you pass `--allow-remote`. Search needs this server: browsers block the search index over `file://`. diff --git a/docs/console/utilities.md b/docs/console/utilities.md index 263f8d3..8123305 100644 --- a/docs/console/utilities.md +++ b/docs/console/utilities.md @@ -42,6 +42,6 @@ summer docs:sync summer docs:serve ``` -`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. After you change code that a page shows, run `summer docs:sync` to refresh the copies. +`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. After you change code that a page shows, run `summer docs:sync` to refresh the copies. `docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk. diff --git a/internal/docsite/check_commands.go b/internal/docsite/check_commands.go index b6b91a3..4060ab7 100644 --- a/internal/docsite/check_commands.go +++ b/internal/docsite/check_commands.go @@ -86,18 +86,16 @@ func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) { for _, span := range codeSpans(d.doc, d.body, d.line) { check(d, span.line, span.text) } - lines := strings.Split(string(d.body), "\n") - for _, f := range scanFences(lines) { - lang, _, _ := strings.Cut(f.info, " ") - if !slices.Contains(shellLangs, lang) { + fences, err := collectFences(d.doc, d.body) + if err != nil { + return nil, err + } + for _, f := range fences { + if !slices.Contains(shellLangs, f.lang) { continue } - end := f.close - if end < 0 { - end = len(lines) - } - for i := f.open + 1; i < end; i++ { - check(d, d.line+i, lines[i]) + for _, line := range f.code { + check(d, d.line+line.line, line.text) } } } diff --git a/internal/docsite/check_policy.go b/internal/docsite/check_policy.go index 8e24325..dc65b83 100644 --- a/internal/docsite/check_policy.go +++ b/internal/docsite/check_policy.go @@ -2,7 +2,6 @@ package docsite import ( "fmt" - "regexp" "slices" "strings" @@ -12,52 +11,63 @@ import ( // calloutTypes are the only `> [!TYPE]` callouts the theme renders. var calloutTypes = []string{"NOTE", "TIP", "WARNING"} -// calloutMarker matches the first line of a callout blockquote. -var calloutMarker = regexp.MustCompile(`^\s{0,3}>\s?\[!([A-Za-z]+)\]\s*$`) - // checkPolicy enforces the page content rules: -// - every go fence in a docs/ page carries src= (module READMEs are -// rendered as written, D-18); +// - every Go-lexer fence in a docs/ page carries src=, at any depth +// (module READMEs are rendered as written, D-18); // - callouts are NOTE, TIP or WARNING; // - docs/ headings are plain ASCII text without links or code spans. -func (s *site) checkPolicy(docs []parsedDoc) []Problem { +func (s *site) checkPolicy(docs []parsedDoc) ([]Problem, error) { var problems []Problem for _, d := range docs { if d.page == nil { continue } guide := d.page.Module == "" - lines := strings.Split(string(d.body), "\n") - fences := scanFences(lines) - inFence := make([]bool, len(lines)) - for _, f := range fences { - end := f.close - if end < 0 { - end = len(lines) - 1 + if guide { + fences, err := collectFences(d.doc, d.body) + if err != nil { + return nil, err } - for i := f.open; i <= end; i++ { - inFence[i] = true - } - fields := strings.Fields(f.info) - if guide && len(fields) > 0 && fields[0] == "go" && - !slices.ContainsFunc(fields[1:], func(f string) bool { return strings.HasPrefix(f, "src=") }) { - problems = append(problems, Problem{File: d.file, Line: d.line + f.open, Rule: "snippet", - Message: "go code block has no src= reference"}) - } - } - for i, line := range lines { - if inFence[i] { - continue - } - if m := calloutMarker.FindStringSubmatch(line); m != nil && !slices.Contains(calloutTypes, m[1]) { - problems = append(problems, Problem{File: d.file, Line: d.line + i, Rule: "callout", - Message: fmt.Sprintf("unknown type %s (use NOTE, TIP or WARNING)", m[1])}) + for _, f := range fences { + if _, hasSrc := ParseSrc(f.info); goLang(f.lang) && !hasSrc { + problems = append(problems, Problem{File: d.file, Line: d.line + f.line, Rule: "snippet", + Message: f.lang + " code block has no src= reference"}) + } } } + problems = append(problems, calloutProblems(d)...) if guide { problems = append(problems, headingProblems(d)...) } } + return problems, nil +} + +// calloutProblems reports blockquotes whose marker is not NOTE, TIP or +// WARNING. calloutTransformer has already turned the known types into +// callout nodes, so a remaining blockquote with a marker is unknown. +// The marker is read from the AST, so a copy of it inside a code fence +// is not a callout. +func calloutProblems(d parsedDoc) []Problem { + var problems []Problem + _ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { + bq, ok := n.(*gast.Blockquote) + if !entering || !ok { + return gast.WalkContinue, nil + } + para, ok := bq.FirstChild().(*gast.Paragraph) + if !ok || para.Lines().Len() == 0 { + return gast.WalkContinue, nil + } + first := para.Lines().At(0) + m := calloutLine.FindStringSubmatch(strings.TrimSpace(string(first.Value(d.body)))) + if m == nil || slices.Contains(calloutTypes, m[1]) { + return gast.WalkContinue, nil + } + problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, first.Start, d.line), Rule: "callout", + Message: fmt.Sprintf("unknown type %s (use NOTE, TIP or WARNING)", m[1])}) + return gast.WalkContinue, nil + }) return problems } diff --git a/internal/docsite/docsite.go b/internal/docsite/docsite.go index 873b15f..fb44771 100644 --- a/internal/docsite/docsite.go +++ b/internal/docsite/docsite.go @@ -262,7 +262,11 @@ func (s *site) checkContent() ([]Problem, error) { return nil, err } problems = append(problems, fp...) - problems = append(problems, s.checkPolicy(docs)...) + pp, err := s.checkPolicy(docs) + if err != nil { + return nil, err + } + problems = append(problems, pp...) return problems, nil } diff --git a/internal/docsite/highlight.go b/internal/docsite/highlight.go index e52dab5..18b7733 100644 --- a/internal/docsite/highlight.go +++ b/internal/docsite/highlight.go @@ -84,6 +84,15 @@ func highlight(lang, code string) string { return b.String() } +// goLang reports whether lang is the Go lexer the highlighter uses. +// chroma's registry resolves names, aliases, case and file names, so +// go, Go, GO, golang, Golang and main.go are Go. go-html-template, +// go-text-template, text and an empty language are not. +func goLang(lang string) bool { + lexer := lexerFor(lang) + return lexer != nil && lexer.Config().Name == "Go" +} + // lexerFor returns the chroma lexer for a fence language, or nil for // plain text and unknown languages. func lexerFor(lang string) chroma.Lexer { diff --git a/internal/docsite/render.go b/internal/docsite/render.go index 61ed2b9..40962bc 100644 --- a/internal/docsite/render.go +++ b/internal/docsite/render.go @@ -469,7 +469,9 @@ func scanFences(lines []string) []fence { func openFence(line string) (fence, bool) { trimmed := strings.TrimLeft(line, " ") - if len(trimmed) < 3 || (trimmed[0] != '`' && trimmed[0] != '~') { + // CommonMark: a fence has at most three leading spaces. Four or more + // is an indented code block, which is what goldmark parses. + if len(line)-len(trimmed) > 3 || len(trimmed) < 3 || (trimmed[0] != '`' && trimmed[0] != '~') { return fence{}, false } c := trimmed[0] diff --git a/internal/docsite/snippet.go b/internal/docsite/snippet.go index 0c2aad3..92d5b97 100644 --- a/internal/docsite/snippet.go +++ b/internal/docsite/snippet.go @@ -452,6 +452,11 @@ type drift struct { // its container marker, so the fence is refused instead of extracted. const nestedSrcMessage = "src= code block must be a top-level block of the page, not inside a callout, blockquote or list item" +// readmeSrcMessage is the problem for a src= fence in a module README. +// Those fences are rendered as written and never compared, so a src= +// there would claim a check that does not run. +const readmeSrcMessage = "module README code blocks are rendered as written; src= is verified only in docs/ pages" + // checkFences verifies every src= fence in fences. lines are the body // lines the fences index into. lineBase is the 1-based file line of // lines[0]; file is the display path for problems. @@ -513,19 +518,30 @@ func unindent(l string, n int) string { return l[i:] } -// checkSnippets verifies the src= fences of every docs page. Module -// READMEs are skipped here; a later check refuses src= on those pages -// because their fences are rendered as written. +// checkSnippets verifies the src= fences of every page. A docs/ page is +// compared with its source. A module README is rendered as written, so +// any src= fence there is refused instead of checked. func (s *site) checkSnippets(docs []parsedDoc) ([]Problem, error) { var problems []Problem for _, d := range docs { - if d.page == nil || d.page.Module != "" { + if d.page == nil { continue } fences, err := collectFences(d.doc, d.body) if err != nil { return nil, err } + if d.page.Module != "" { + for _, f := range fences { + ref, ok := ParseSrc(f.info) + if !ok { + continue + } + problems = append(problems, Problem{File: d.file, Line: d.line + f.line, Rule: "snippet", + Message: ref.String() + ": " + readmeSrcMessage}) + } + continue + } _, ps, err := checkFences(s.opts.Root, d.file, strings.Split(string(d.body), "\n"), d.line, fences) if err != nil { return nil, err