- 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).
111 lines
3.4 KiB
Go
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
|
|
}
|