package docsite import ( "bytes" "cmp" "fmt" "html" "path" "regexp" "slices" "strings" "unicode" "unicode/utf8" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) // newMarkdown returns the one goldmark pipeline every page goes through: // CommonMark plus GFM, heading IDs, and the page transformers. The html // renderer keeps its default safe mode, so raw HTML in Markdown is never // passed through. func newMarkdown() goldmark.Markdown { return goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithParserOptions( parser.WithAutoHeadingID(), parser.WithASTTransformers( util.Prioritized(h1Stripper{}, 100), util.Prioritized(linkRewriter{}, 200), util.Prioritized(fenceAnnotator{}, 300), util.Prioritized(calloutTransformer{}, 400), ), ), // goldmark registers lower priority values last, so 100 overrides the // default html renderer (1000) for fenced code blocks and headings. goldmark.WithRendererOptions(renderer.WithNodeRenderers( util.Prioritized(codeRenderer{}, 100), util.Prioritized(headingRenderer{}, 100), util.Prioritized(calloutRenderer{}, 100), )), ) } // fenceAnnotator marks fences that carry src= with the reference and its // source_url link, for codeRenderer's caption. type fenceAnnotator struct{} func (fenceAnnotator) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { pctx, _ := pc.Get(pageKey).(*pageContext) src := reader.Source() _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { fc, ok := n.(*ast.FencedCodeBlock) if !entering || !ok || fc.Info == nil { return ast.WalkContinue, nil } // Captions only on the fences checkSnippets compares: top-level // src= fences of docs/ pages. A nested or module README fence is // not verified, so it must not claim a source. verified := pctx != nil && pctx.page != nil && pctx.page.Module == "" && fc.Parent() != nil && fc.Parent().Kind() == ast.KindDocument if !verified { return ast.WalkContinue, nil } ref, ok := ParseSrc(string(fc.Info.Segment.Value(src))) if !ok { return ast.WalkContinue, nil } fc.SetAttributeString("data-src", []byte(ref.String())) if pctx.site.cfg.SourceURL != "" { fc.SetAttributeString("data-href", []byte(strings.ReplaceAll(pctx.site.cfg.SourceURL, "{path}", ref.Path))) } return ast.WalkSkipChildren, nil }) } // headingRenderer renders headings with their slug ID and, on H2 and H3, // a permalink after the text. type headingRenderer struct{} func (headingRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { r.Register(ast.KindHeading, renderHeading) } func renderHeading(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { n := node.(*ast.Heading) tag := fmt.Sprintf("h%d", n.Level) id := attrString(n, "id") if entering { _, _ = w.WriteString("<" + tag) if id != "" { _, _ = w.WriteString(` id="` + html.EscapeString(id) + `"`) } _ = w.WriteByte('>') return ast.WalkContinue, nil } if id != "" && (n.Level == 2 || n.Level == 3) { _, _ = w.WriteString(`#`) } _, _ = w.WriteString("\n") return ast.WalkContinue, nil } // kindCallout is the AST node kind of a > [!NOTE], [!TIP] or [!WARNING] // blockquote. var kindCallout = ast.NewNodeKind("Callout") // calloutNode holds the blocks of a callout; CalloutType is NOTE, TIP or WARNING. type calloutNode struct { ast.BaseBlock CalloutType string } func (n *calloutNode) Kind() ast.NodeKind { return kindCallout } func (n *calloutNode) Dump(src []byte, level int) { ast.DumpHelper(n, src, level, map[string]string{"CalloutType": n.CalloutType}, nil) } // calloutTransformer turns a blockquote whose first line is exactly // [!NOTE], [!TIP] or [!WARNING] into a callout without that line. Other // types stay blockquotes; checkPolicy reports them. type calloutTransformer struct{} func (calloutTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { src := reader.Source() var quotes []*ast.Blockquote _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if bq, ok := n.(*ast.Blockquote); ok && entering { quotes = append(quotes, bq) } return ast.WalkContinue, nil }) for _, bq := range quotes { para, ok := bq.FirstChild().(*ast.Paragraph) if !ok || para.Lines().Len() == 0 { continue } first := para.Lines().At(0) m := calloutLine.FindStringSubmatch(strings.TrimSpace(string(first.Value(src)))) if m == nil || !slices.Contains(calloutTypes, m[1]) { continue } for c := para.FirstChild(); c != nil; { next := c.NextSibling() t, ok := c.(*ast.Text) if !ok || t.Segment.Start >= first.Stop { break } para.RemoveChild(para, c) c = next } if para.ChildCount() == 0 { bq.RemoveChild(bq, para) } callout := &calloutNode{CalloutType: m[1]} for c := bq.FirstChild(); c != nil; { next := c.NextSibling() callout.AppendChild(callout, c) c = next } bq.Parent().ReplaceChild(bq.Parent(), bq, callout) } } // calloutLine matches the marker line of a callout. var calloutLine = regexp.MustCompile(`^\[!([A-Za-z]+)\]$`) // calloutRenderer renders a callout as \n") return ast.WalkContinue, nil } _, _ = w.WriteString(`