fix(11.1-07): find src= fences in the goldmark AST and refuse nested ones

- collectFences walks every fenced code block the renderer parses
- a nested src= fence is refused and is not extracted, synced or captioned
- checkSnippets runs on the same parse as the other checkers
- Sync rewrites drifted top-level fences and keeps the frontmatter

Deviation: TestSyncRewritesDrift's drifted src= fence sat inside a list item, which is now refused. The drift case is a two-space top-level fence so Sync still rewrites it (problem line 11).
This commit is contained in:
Jakub Zych
2026-10-01 08:27:42 +02:00
parent d9f187ae39
commit 5b7e37fb69
6 changed files with 182 additions and 32 deletions

View File

@@ -1,6 +1,7 @@
package docsite
import (
"bytes"
"errors"
"fmt"
"go/ast"
@@ -446,21 +447,31 @@ type drift struct {
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) {
// nestedSrcMessage is the problem for a src= fence that is not a
// top-level block. docs:sync cannot rewrite a line that still carries
// 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"
// 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.
func checkFences(root, file string, lines []string, lineBase int, fences []mdFence) ([]drift, []Problem, error) {
var drifts []drift
var problems []Problem
for _, f := range scanFences(lines) {
for _, f := range fences {
ref, ok := ParseSrc(f.info)
if !ok {
continue
}
line := lineBase + f.open
line := lineBase + f.line
fail := func(msg string) {
problems = append(problems, Problem{File: file, Line: line, Rule: "snippet", Message: msg})
}
if f.close < 0 {
if !f.topLevel {
fail(ref.String() + ": " + nestedSrcMessage)
continue
}
if f.top.close < 0 {
fail("code block has no closing fence")
continue
}
@@ -476,9 +487,9 @@ func checkFences(root, file string, lines []string, lineBase int) ([]drift, []Pr
case err != nil:
return nil, nil, err
}
if fenceBody(lines, f) != strings.TrimRight(want, "\n") {
if fenceBody(lines, f.top) != strings.TrimRight(want, "\n") {
fail(fmt.Sprintf("body differs from %s (run: summer docs:sync)", ref))
drifts = append(drifts, drift{f: f, want: want})
drifts = append(drifts, drift{f: f.top, want: want})
}
}
return drifts, problems, nil
@@ -502,14 +513,20 @@ func unindent(l string, n int) string {
return l[i:]
}
// checkSnippets verifies the src= fences of every docs page.
func (s *site) checkSnippets() ([]Problem, error) {
// 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.
func (s *site) checkSnippets(docs []parsedDoc) ([]Problem, error) {
var problems []Problem
for _, p := range s.pages {
if p.Module != "" {
for _, d := range docs {
if d.page == nil || d.page.Module != "" {
continue
}
_, ps, err := checkFences(s.opts.Root, p.Source, strings.Split(string(p.Body), "\n"), p.BodyLine)
fences, err := collectFences(d.doc, d.body)
if err != nil {
return nil, err
}
_, ps, err := checkFences(s.opts.Root, d.file, strings.Split(string(d.body), "\n"), d.line, fences)
if err != nil {
return nil, err
}
@@ -544,8 +561,20 @@ func Sync(opts Options) (SyncResult, []Problem, error) {
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)
body, bodyLine := raw, 1
if _, b, bl, ok := splitFrontmatter(raw); ok {
body, bodyLine = b, bl
}
if !bytes.HasSuffix(raw, body) {
return SyncResult{}, nil, fmt.Errorf("docs:sync: %s: body is not a suffix of the file", abs)
}
doc := s.parseRaw(newMarkdown(), body)
fences, err := collectFences(doc, body)
if err != nil {
return SyncResult{}, nil, err
}
lines := strings.Split(string(body), "\n")
drifts, ps, err := checkFences(opts.Root, s.rel(abs), lines, bodyLine, fences)
if err != nil {
return SyncResult{}, nil, err
}
@@ -560,21 +589,22 @@ func Sync(opts Options) (SyncResult, []Problem, error) {
for i := len(drifts) - 1; i >= 0; i-- {
d := drifts[i]
pad := strings.Repeat(" ", d.f.indent)
var body []string
var rewritten []string
for _, l := range strings.Split(strings.TrimRight(d.want, "\n"), "\n") {
if l == "" {
body = append(body, "")
rewritten = append(rewritten, "")
} else {
body = append(body, pad+l)
rewritten = append(rewritten, pad+l)
}
}
lines = slices.Concat(lines[:d.f.open+1], body, lines[d.f.close:])
lines = slices.Concat(lines[:d.f.open+1], rewritten, 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"))})
rewritten := append(append([]byte{}, raw[:len(raw)-len(body)]...), []byte(strings.Join(lines, "\n"))...)
rewrites = append(rewrites, rewrite{path: abs, mode: st.Mode().Perm(), data: rewritten})
result.Snippets += len(drifts)
result.Files++
}