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:
110
internal/docsite/fences.go
Normal file
110
internal/docsite/fences.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user