a73x

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, " ,;:.") + "…"
}