internal/site/render.go
Ref: Size: 6.3 KiB History
// Package site generates the eitri.sh static site: docs/*.md and a markdown
// landing page rendered through one HTML template, plus the downloads page
// and the agent-upgrade release manifest. Inter-doc links are rewritten to
// site paths; a link to a page that does not exist fails the build, which is
// the drift gate between repo docs and the published site.
package site
import (
"bytes"
"errors"
"fmt"
"html/template"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
ghtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// linkRewriter rewrites relative markdown link destinations to their site
// paths via targets, collecting an error per destination that maps to no
// known page. External (scheme), site-absolute (/...), and fragment-only
// links pass through untouched.
type linkRewriter struct {
targets map[string]string
errs []error
}
func (r *linkRewriter) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
var dest *[]byte
switch v := n.(type) {
case *ast.Link:
dest = &v.Destination
case *ast.Image:
dest = &v.Destination
default:
return ast.WalkContinue, nil
}
d := string(*dest)
if d == "" || strings.Contains(d, "://") || strings.HasPrefix(d, "mailto:") ||
strings.HasPrefix(d, "/") || strings.HasPrefix(d, "#") {
return ast.WalkContinue, nil
}
path, frag, _ := strings.Cut(d, "#")
path = strings.TrimPrefix(path, "./")
u, ok := r.targets[path]
if !ok {
r.errs = append(r.errs, fmt.Errorf("link to unknown page %q", d))
return ast.WalkContinue, nil
}
if frag != "" {
u += "#" + frag
}
*dest = []byte(u)
return ast.WalkContinue, nil
})
}
// section is one top-level heading of a page: what a table of contents lists,
// and the anchor it links to.
type section struct{ ID, Title string }
// render converts markdown to HTML, rewriting internal links via targets, and
// reports the page's top-level sections. Any link to an unknown internal page
// is an error.
func render(src []byte, targets map[string]string) ([]byte, []section, error) {
rw := &linkRewriter{targets: targets}
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
parser.WithASTTransformers(util.Prioritized(rw, 100)),
// Headings need stable ids before anything can link to them.
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(ghtml.WithUnsafe()),
)
doc := md.Parser().Parse(text.NewReader(src))
if len(rw.errs) > 0 {
return nil, nil, errors.Join(rw.errs...)
}
var buf bytes.Buffer
if err := md.Renderer().Render(&buf, src, doc); err != nil {
return nil, nil, err
}
return buf.Bytes(), sections(doc, src), nil
}
// sections collects a page's level-2 headings in document order.
func sections(doc ast.Node, src []byte) []section {
var out []section
for n := doc.FirstChild(); n != nil; n = n.NextSibling() {
h, ok := n.(*ast.Heading)
if !ok || h.Level != 2 {
continue
}
id, ok := h.AttributeString("id")
if !ok {
continue
}
var title strings.Builder
_ = ast.Walk(h, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if t, ok := n.(*ast.Text); ok && entering {
title.Write(t.Segment.Value(src))
}
return ast.WalkContinue, nil
})
out = append(out, section{ID: string(id.([]byte)), Title: title.String()})
}
return out
}
// A table of contents earns its place on a page long enough to scroll and
// sectioned enough to skip around: a short page's contents list is longer than
// the reading it saves.
const (
tocMinSections = 4
tocMinBytes = 6000
)
// withTOC puts a table of contents in front of a page's first section, when
// the page is worth navigating. The list goes inside the content rather than
// around it because the page's own title is the first thing in there.
func withTOC(html []byte, secs []section) []byte {
if len(secs) < tocMinSections || len(html) < tocMinBytes {
return html
}
at := bytes.Index(html, []byte("<h2"))
if at < 0 {
return html
}
var toc bytes.Buffer
toc.WriteString(`<nav class="toc">` + "\n")
for _, s := range secs {
fmt.Fprintf(&toc, "<a href=\"#%s\">%s</a>\n", s.ID, template.HTMLEscapeString(s.Title))
}
toc.WriteString("</nav>\n")
out := make([]byte, 0, len(html)+toc.Len())
out = append(out, html[:at]...)
out = append(out, toc.Bytes()...)
return append(out, html[at:]...)
}
// summaryLimit is how many runes of a page summary survive: enough for the
// crawlers that show ~160 characters, short enough that none of them cut a
// word off mid-preview.
const summaryLimit = 180
// summary is a page's own opening paragraph, flattened to plain text: the
// description a search result or a link preview shows. Headings, lists and
// code blocks are skipped — a page describes itself in prose or not at all.
func summary(src []byte) string {
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(src))
var para *ast.Paragraph
for n := doc.FirstChild(); n != nil; n = n.NextSibling() {
if p, ok := n.(*ast.Paragraph); ok {
para = p
break
}
}
if para == nil {
return ""
}
var b strings.Builder
_ = ast.Walk(para, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch v := n.(type) {
case *ast.Text:
b.Write(v.Segment.Value(src))
if v.SoftLineBreak() || v.HardLineBreak() {
b.WriteByte(' ')
}
case *ast.CodeSpan, *ast.RawHTML:
// Rendered inline: take the words, drop the markup.
for c := v.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
b.Write(t.Segment.Value(src))
}
}
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})
return clamp(strings.Join(strings.Fields(b.String()), " "), summaryLimit)
}
// clamp cuts s to at most limit runes, at the last word boundary, marking the
// cut with an ellipsis.
func clamp(s string, limit int) string {
r := []rune(s)
if len(r) <= limit {
return s
}
cut := string(r[:limit])
if i := strings.LastIndexByte(cut, ' '); i > 0 {
cut = cut[:i]
}
return strings.TrimRight(cut, " ,;:.") + "…"
}