package content import ( "bytes" "fmt" stdhtml "html" "io" "path" "regexp" "strings" "github.com/alecthomas/chroma/v2" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage" ) // RendererStyle is the chroma style. Dark by default, matching the rest of // Sam's surfaces. const RendererStyle = "github-dark" // Renderer turns Markdown into server-side HTML. Nothing here runs in a // browser: goldmark for structure and tables, chroma for code, at build time. type Renderer struct { md goldmark.Markdown set *Set } // NewRenderer builds a renderer bound to a document set (needed to rewrite // relative .md links into site URLs). func NewRenderer(s *Set) *Renderer { r := &Renderer{set: s} r.md = goldmark.New( goldmark.WithExtensions( extension.GFM, extension.Footnote, extension.Typographer, highlighting.NewHighlighting( highlighting.WithStyle(RendererStyle), highlighting.WithFormatOptions(chromahtml.WithClasses(true)), ), ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), parser.WithASTTransformers(util.Prioritized(r, 100)), ), goldmark.WithRendererOptions(html.WithUnsafe()), // trusted, in-repo Markdown ) return r } // Transform rewrites document-relative links into site URLs so the // pre-rendered pages work as static files. The current document is carried in // the parser context by Render and Highlight-free callers. func (r *Renderer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { current, ok := pc.Get(docKey).(*Document) if !ok || current == nil { return } _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } switch node := n.(type) { case *ast.Link: node.Destination = r.rewrite(current, string(node.Destination), false) case *ast.Image: node.Destination = r.rewrite(current, string(node.Destination), true) } return ast.WalkContinue, nil }) } func (r *Renderer) rewrite(from *Document, dest string, image bool) []byte { if dest == "" || strings.Contains(dest, "://") || strings.HasPrefix(dest, "/") || strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "mailto:") { return []byte(dest) } target := dest suffix := "" if i := strings.IndexAny(target, "#?"); i >= 0 { suffix = target[i:] target = target[:i] } rel := target if strings.HasPrefix(rel, "content/") { rel = strings.TrimPrefix(rel, "content/") } else { rel = path.Join(path.Dir(from.Path), rel) } rel = path.Clean(rel) if d, ok := r.set.byRel[rel]; ok { return []byte(d.URL + suffix) } if image || KindForFile(rel) != stage.KindLinks { return []byte(AssetHref(rel) + suffix) } return []byte(dest) } // docKey carries the document being rendered through the parser context so // the link rewriter knows what "relative" means. var docKey = parser.NewContextKey() // Render fills HTML and Plain on every document in the set. func (r *Renderer) Render() error { for _, d := range r.set.Docs { var buf bytes.Buffer ctx := parser.NewContext() ctx.Set(docKey, d) if err := r.md.Convert([]byte(d.BodyMarkdown()), &buf, parser.WithContext(ctx)); err != nil { return fmt.Errorf("%s: render Markdown: %w", d.Path, err) } d.HTML = buf.String() d.Plain = PlainText(d.HTML) } return nil } // Highlight renders a standalone code file with chroma. Used by L3 code // artefacts and by the make-target that keeps the code exhibits in sync. func (r *Renderer) Highlight(w io.Writer, lang string, src []byte) error { lexer := lexers.Get(lang) if lexer == nil { lexer = lexers.Analyse(string(src)) } if lexer == nil { _, err := fmt.Fprintf(w, "
%s", stdhtml.EscapeString(string(src)))
return err
}
lexer = chroma.Coalesce(lexer)
it, err := lexer.Tokenise(nil, string(src))
if err != nil {
return err
}
style := styles.Get(RendererStyle)
if style == nil {
return fmt.Errorf("unknown chroma style %q", RendererStyle)
}
formatter := chromahtml.New(chromahtml.WithClasses(true), chromahtml.WithLineNumbers(false))
return formatter.Format(w, style, it)
}
// ChromaCSS returns the stylesheet for the highlighted classes, so the built
// site makes no colour decisions inline.
func (r *Renderer) ChromaCSS() (string, error) {
style := styles.Get(RendererStyle)
if style == nil {
return "", fmt.Errorf("unknown chroma style %q", RendererStyle)
}
var buf bytes.Buffer
if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, style); err != nil {
return "", err
}
return buf.String(), nil
}
// AssetHref maps a content-relative asset path onto its published URL.
func AssetHref(rel string) string {
return "/assets/" + strings.TrimPrefix(path.Clean(rel), "assets/")
}
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
// PlainText reduces rendered HTML to indexable text.
func PlainText(h string) string {
h = tagRe.ReplaceAllString(h, " ")
h = stdhtml.UnescapeString(h)
return strings.Join(strings.Fields(h), " ")
}