package content import ( "bytes" "fmt" stdhtml "html" "image" _ "image/jpeg" _ "image/png" "io" "os" "path" "path/filepath" "regexp" "strconv" "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) r.sizeImage(current, node) } 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) } // sizeImage records an image's intrinsic size and loading hints on the node, so // the rendered page reserves the space before the file arrives. Without this the // text jumps as each image lands, which is the most visible performance defect a // page of diagrams can have. // // Only the four attributes in goldmark's image filter are used, and only when a // real size is known: an unknown asset keeps the browser's own behaviour. func (r *Renderer) sizeImage(from *Document, node *ast.Image) { if r.set == nil || from == nil { return } dest := string(node.Destination) if strings.Contains(dest, "://") || strings.HasPrefix(dest, "data:") { return } rel := dest if strings.HasPrefix(rel, "/assets/") { rel = "assets/" + strings.TrimPrefix(rel, "/assets/") } else if i := strings.IndexAny(rel, "#?"); i >= 0 { rel = rel[:i] } w, h, ok := imageSize(filepath.Join(r.set.Root, filepath.FromSlash(path.Clean(rel)))) if !ok { return } node.SetAttributeString("width", []byte(strconv.Itoa(w))) node.SetAttributeString("height", []byte(strconv.Itoa(h))) node.SetAttributeString("loading", []byte("lazy")) node.SetAttributeString("decoding", []byte("async")) } // svgSize reads width and height from the root element of an SVG. The diagrams // in content/assets are authored in this repository with explicit dimensions, // so a viewBox alone is not expected to occur. var svgSizeRe = regexp.MustCompile(`]*\swidth="(\d+(?:\.\d+)?)(?:px)?"[^>]*\sheight="(\d+(?:\.\d+)?)(?:px)?"`) // imageSize returns the intrinsic pixel size of a content asset. func imageSize(abs string) (int, int, bool) { f, err := os.Open(abs) if err != nil { return 0, 0, false } defer f.Close() if strings.HasSuffix(strings.ToLower(abs), ".svg") { head := make([]byte, 4096) n, _ := f.Read(head) m := svgSizeRe.FindSubmatch(head[:n]) if m == nil { return 0, 0, false } w, err1 := strconv.ParseFloat(string(m[1]), 64) h, err2 := strconv.ParseFloat(string(m[2]), 64) if err1 != nil || err2 != nil || w <= 0 || h <= 0 { return 0, 0, false } return int(w + 0.5), int(h + 0.5), true } cfg, _, err := image.DecodeConfig(f) if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { return 0, 0, false } return cfg.Width, cfg.Height, true } // 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), " ") }