Files
summercms/internal/docsite/fences.go
Jakub Zych 5b7e37fb69 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).
2026-10-01 08:27:42 +02:00

111 lines
3.4 KiB
Go

package docsite
import (
"bytes"
"fmt"
"strings"
gast "github.com/yuin/goldmark/ast"
)
// fenceLine is one source line of a fenced code block. line is the
// 0-based index in the Markdown body. text is the line with the
// trailing newline removed; goldmark has already dropped container
// markers such as a blockquote's "> ".
type fenceLine struct {
line int
text string
}
// mdFence is one fenced code block taken from the goldmark AST the
// renderer uses, so a fence goldmark renders is visible to the checkers.
type mdFence struct {
info string // info string; empty when the fence has none
lang string // first field of info
line int // 0-based body line of the opening fence; -1 when unknown
topLevel bool // parent is the document
code []fenceLine
// top is the line-scanned fence, set only for a top-level fence
// that has an info string. Sync rewrites those; nested fences are
// refused instead.
top fence
}
// collectFences walks every fenced code block in doc. doc must be a
// document parsed with newMarkdown, so callouts, blockquotes and lists
// are the same nodes the renderer sees. A top-level fence whose opening
// line openFence rejects is an error: the AST and the line view disagree
// and that must not pass silently.
func collectFences(doc gast.Node, body []byte) ([]mdFence, error) {
rawLines := strings.Split(string(body), "\n")
var out []mdFence
err := gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
fc, ok := n.(*gast.FencedCodeBlock)
if !entering || !ok {
return gast.WalkContinue, nil
}
mf, err := mdFenceFrom(fc, body, rawLines)
if err != nil {
return gast.WalkStop, err
}
out = append(out, mf)
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
return out, nil
}
// mdFenceFrom builds one mdFence. The opening line is the info segment's
// line, or the line before the first body line when the fence has no info.
func mdFenceFrom(fc *gast.FencedCodeBlock, body []byte, rawLines []string) (mdFence, error) {
var mf mdFence
if fc.Info != nil {
mf.info = string(fc.Info.Segment.Value(body))
mf.line = bytes.Count(body[:fc.Info.Segment.Start], []byte("\n"))
} else if fc.Lines().Len() > 0 {
mf.line = bytes.Count(body[:fc.Lines().At(0).Start], []byte("\n")) - 1
} else {
mf.line = -1
}
if field, _, ok := strings.Cut(mf.info, " "); ok {
mf.lang = field
} else {
mf.lang = mf.info
}
parent := fc.Parent()
mf.topLevel = parent != nil && parent.Kind() == gast.KindDocument
lines := fc.Lines()
for i := 0; i < lines.Len(); i++ {
seg := lines.At(i)
text := bytes.TrimRight(seg.Value(body), "\n")
mf.code = append(mf.code, fenceLine{
line: bytes.Count(body[:seg.Start], []byte("\n")),
text: string(text),
})
}
if !mf.topLevel || mf.info == "" {
return mf, nil
}
if mf.line < 0 || mf.line >= len(rawLines) {
return mdFence{}, fmt.Errorf("docsite: fence at body line %d: opening line is outside the source", mf.line+1)
}
opened, ok := openFence(rawLines[mf.line])
if !ok {
return mdFence{}, fmt.Errorf("docsite: fence at body line %d: AST and source line disagree", mf.line+1)
}
opened.open = mf.line
closeLine := mf.line + 1
if n := len(mf.code); n > 0 {
closeLine = mf.code[n-1].line + 1
}
if closeLine >= 0 && closeLine < len(rawLines) && closesFence(rawLines[closeLine], opened) {
opened.close = closeLine
} else {
opened.close = -1
}
mf.top = opened
return mf, nil
}