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 }