feat(11.1-02): add the documentation theme, chroma highlighting and docs:serve
- WinterCMS-style shell: header with search and theme toggle, grouped sidebar, on-page TOC, pager, page actions, callouts, heading permalinks, footer and a 404 page - fenced code highlighted at build time by chroma/v2 into tok-* classes, with a copy button; no inline script, style or handler - vendored DM Sans/DM Mono fonts and Lucide icons with their licences - client-side search over search-index.json built with textContent only - summer docs:serve builds into a temp dir, serves on loopback by default, returns 404.html with status 404 and rebuilds on change
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -33,11 +34,16 @@ func newMarkdown() goldmark.Markdown {
|
||||
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.
|
||||
goldmark.WithRendererOptions(renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 100))),
|
||||
// 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),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,49 +71,123 @@ func (fenceAnnotator) Transform(doc *ast.Document, reader text.Reader, pc parser
|
||||
})
|
||||
}
|
||||
|
||||
// codeRenderer renders every fenced code block inside <figure class="code">;
|
||||
// a src= fence gets a <figcaption> naming its source, linked to source_url.
|
||||
type codeRenderer struct{}
|
||||
// headingRenderer renders headings with their slug ID and, on H2 and H3,
|
||||
// a permalink after the text.
|
||||
type headingRenderer struct{}
|
||||
|
||||
func (codeRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
|
||||
r.Register(ast.KindFencedCodeBlock, renderFence)
|
||||
func (headingRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
|
||||
r.Register(ast.KindHeading, renderHeading)
|
||||
}
|
||||
|
||||
func attrString(n ast.Node, name string) string {
|
||||
v, ok := n.AttributeString(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
b, _ := v.([]byte)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func renderFence(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
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
|
||||
}
|
||||
n := node.(*ast.FencedCodeBlock)
|
||||
_, _ = w.WriteString(`<figure class="code">`)
|
||||
if ref := attrString(n, "data-src"); ref != "" {
|
||||
_, _ = w.WriteString("<figcaption>")
|
||||
if href := attrString(n, "data-href"); href != "" {
|
||||
_, _ = w.WriteString(`<a href="` + html.EscapeString(href) + `">` + html.EscapeString(ref) + "</a>")
|
||||
} else {
|
||||
_, _ = w.WriteString(html.EscapeString(ref))
|
||||
if id != "" && (n.Level == 2 || n.Level == 3) {
|
||||
_, _ = w.WriteString(`<a class="heading-anchor" href="#` + html.EscapeString(id) +
|
||||
`" aria-label="Link to section: ` + html.EscapeString(plainText(n, src)) + `">#</a>`)
|
||||
}
|
||||
_, _ = w.WriteString("</" + tag + ">\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)
|
||||
}
|
||||
_, _ = w.WriteString("</figcaption>")
|
||||
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)
|
||||
}
|
||||
_, _ = w.WriteString("<pre><code")
|
||||
if lang := n.Language(src); lang != nil {
|
||||
_, _ = w.WriteString(` class="language-` + html.EscapeString(string(lang)) + `"`)
|
||||
}
|
||||
|
||||
// calloutLine matches the marker line of a callout.
|
||||
var calloutLine = regexp.MustCompile(`^\[!([A-Za-z]+)\]$`)
|
||||
|
||||
// calloutRenderer renders a callout as <aside class="callout callout-…">
|
||||
// with its icon and title.
|
||||
type calloutRenderer struct{}
|
||||
|
||||
func (calloutRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
|
||||
r.Register(kindCallout, renderCallout)
|
||||
}
|
||||
|
||||
var calloutMeta = map[string]struct{ class, icon, title string }{
|
||||
"NOTE": {"callout-note", "info", "Note"},
|
||||
"TIP": {"callout-tip", "lightbulb", "Tip"},
|
||||
"WARNING": {"callout-warning", "triangle-alert", "Warning"},
|
||||
}
|
||||
|
||||
func renderCallout(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
meta := calloutMeta[node.(*calloutNode).CalloutType]
|
||||
if !entering {
|
||||
_, _ = w.WriteString("</div></aside>\n")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
_ = w.WriteByte('>')
|
||||
for i := 0; i < n.Lines().Len(); i++ {
|
||||
line := n.Lines().At(i)
|
||||
_, _ = w.WriteString(html.EscapeString(string(line.Value(src))))
|
||||
}
|
||||
_, _ = w.WriteString("</code></pre></figure>\n")
|
||||
return ast.WalkSkipChildren, nil
|
||||
_, _ = w.WriteString(`<aside class="callout ` + meta.class + `" role="note"><p class="callout-title">`)
|
||||
writeIcon(w, meta.icon)
|
||||
_, _ = w.WriteString("<span>" + meta.title + `</span></p><div class="callout-body">`)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// h1Stripper removes the page's leading "# Title" heading; the template
|
||||
|
||||
Reference in New Issue
Block a user