From c0c7686926f5f997ef38c817600523c934d05c44 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 16:40:49 -0600 Subject: Add recursive Markdown rendering templates --- README.md | 71 +++++++ go.mod | 5 +- go.sum | 10 + main.go | 612 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- main_test.go | 328 +++++++++++++++++++++++++++++++- 5 files changed, 1008 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fb0e97f..477a95b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,77 @@ walking toward the site root before using the fallback. For example, `notes/recipes`, `notes`, then `page`. Set a different fallback with `-fallback template`; it defaults to `page`. +### Markdown element templates + +Templates named with the `markdown/` prefix can replace the HTML wrapper for +individual Markdown elements. Their `.Content` is the recursively rendered +content, so nested overrides continue to apply. Undefined element templates use +Goldmark's default HTML. A build fails if it finds an unsupported +`markdown/` template name. + +| Template | Fields | +| --- | --- | +| `markdown/heading1` through `markdown/heading6` | `Content template.HTML`, `ID string` | +| `markdown/paragraph` | `Content template.HTML` | +| `markdown/blockquote` | `Content template.HTML` | +| `markdown/code_block` | `Content template.HTML` | +| `markdown/fenced_code_block` | `Content template.HTML`, `HighlightedContent template.HTML`, `Language string` | +| `markdown/unordered_list` | `Content template.HTML`, `Tight bool` | +| `markdown/ordered_list` | `Content template.HTML`, `Start int`, `Tight bool` | +| `markdown/list_item` | `Content template.HTML` | +| `markdown/thematic_break` | no fields | +| `markdown/emphasis` | `Content template.HTML` | +| `markdown/strong` | `Content template.HTML` | +| `markdown/code_span` | `Content template.HTML` | +| `markdown/link` | `Content template.HTML`, `Destination string`, `Title string` | +| `markdown/autolink` | `Content template.HTML`, `Destination string` | +| `markdown/email_autolink` | `Content template.HTML`, `Destination string` | +| `markdown/image` | `Alt string`, `Destination string`, `Title string` | +| `markdown/hard_break` | no fields | +| `markdown/strikethrough` | `Content template.HTML` | +| `markdown/task_checkbox` | `Checked bool` | +| `markdown/table` | `Content template.HTML` | +| `markdown/table_header` | `Content template.HTML` | +| `markdown/table_body` | `Content template.HTML` | +| `markdown/table_row` | `Content template.HTML` | +| `markdown/table_header_cell` | `Content template.HTML`, `Alignment string` | +| `markdown/table_cell` | `Content template.HTML`, `Alignment string` | + +`.Content` is trusted, already-rendered HTML. For code spans and code blocks it +is escaped source text instead. Images flatten their description to plain +`.Alt`; elements without child content omit `.Content`. Destinations, titles, +language names, IDs, and alt text remain plain strings and are contextually +escaped by `html/template`. Raw Markdown HTML stays disabled and has no +override. + +`Tight` follows CommonMark: tight list items contain unwrapped inline content, +while loose-list paragraphs pass through `markdown/paragraph`. `Start` is the +first ordered-list marker, including `1`; later markers do not change item +numbers. Table cell `.Alignment` is `left`, `right`, `center`, or `none`. +`markdown/table_body` represents the generated `` and is omitted when a +table has no body rows. + +Defining `markdown/fenced_code_block` also enables build-time highlighting. +`.Language` is the first word of the fence info string. For a known exact +Chroma language, `.HighlightedContent` contains escaped, class-based token +markup with the fixed `chroma-` CSS-class prefix and no `
` or ``
+wrapper. It is empty for blank or unknown languages and on highlighting errors;
+`.Content` always provides escaped source as a safe fallback. Weft does not
+generate CSS or client-side JavaScript.
+
+```gotemplate
+{{define "markdown/paragraph"}}

{{.Content}}

{{end}} +{{define "markdown/ordered_list"}} +
    {{.Content}}
+{{end}} +{{define "markdown/list_item"}}
  • {{.Content}}
  • {{end}} +{{define "markdown/fenced_code_block"}} +
    {{if .HighlightedContent}}{{.HighlightedContent}}{{else}}{{.Content}}{{end}}
    +{{end}} +{{define "markdown/table_header"}}{{.Content}}{{end}} +{{define "markdown/table_body"}}{{.Content}}{{end}} +``` + ## Template data Every template receives this root value: diff --git a/go.mod b/go.mod index 9eff62a..7f659f7 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,12 @@ module weft -go 1.24 +go 1.25 require ( + github.com/alecthomas/chroma/v3 v3.0.0-alpha.5 github.com/yuin/goldmark v1.7.13 golang.org/x/net v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) + +require github.com/dlclark/regexp2/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index 302552a..11d74e6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v3 v3.0.0-alpha.5 h1:Ins5lDliEp34swfd+nh2eESjBA4MlrHrmi6Uyxsl/9E= +github.com/alecthomas/chroma/v3 v3.0.0-alpha.5/go.mod h1:CjTPYmVdv0LiMgid2gCJeE30AgchDKbdsW7R3xs9mHI= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0= +github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= diff --git a/main.go b/main.go index 96c1115..150cdad 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,14 @@ package main import ( + "bufio" "bytes" "encoding/json" "encoding/xml" "errors" "flag" "fmt" + stdhtml "html" "html/template" "io" "io/fs" @@ -20,11 +22,18 @@ import ( "syscall" "time" + chromahtml "github.com/alecthomas/chroma/v3/formatters/html" + "github.com/alecthomas/chroma/v3/lexers" + "github.com/alecthomas/chroma/v3/styles" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + gmhtml "github.com/yuin/goldmark/renderer/html" gmtext "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" "golang.org/x/net/html" "gopkg.in/yaml.v3" ) @@ -45,6 +54,9 @@ type Page struct { Meta map[string]any ModTime time.Time Sections []template.HTML + + markdownSource []byte + markdownAST ast.Node } type templateData struct { @@ -67,6 +79,7 @@ type site struct { templates *template.Template fallback string warnings []string + markdown goldmark.Markdown } type manifest struct { @@ -109,13 +122,19 @@ func buildWithFallback(root, canonicalRoot, fallback string, manageGitignore boo return fmt.Errorf("site root is not a directory: %s", abs) } - s := &site{root: abs, canonicalRoot: canonicalRoot, fallback: fallback, pages: map[string][]*Page{}} + s := &site{ + root: abs, canonicalRoot: canonicalRoot, fallback: fallback, + pages: map[string][]*Page{}, markdown: newMarkdown(), + } if err := s.discover(); err != nil { return err } if err := s.loadTemplates(); err != nil { return err } + if err := s.prepareMarkdown(); err != nil { + return err + } outputs, err := s.render() if err != nil { return err @@ -252,8 +271,10 @@ func (s *site) parsePage(rel, out string, info fs.FileInfo) (*Page, []string, er if err != nil { return nil, nil, err } - md := goldmark.New(goldmark.WithExtensions(extension.GFM), goldmark.WithParserOptions(parser.WithAutoHeadingID())) - doc := md.Parser().Parse(gmtext.NewReader(body)) + if s.markdown == nil { + s.markdown = newMarkdown() + } + doc := s.markdown.Parser().Parse(gmtext.NewReader(body)) rewriteLinks(doc) title := extractTitle(doc, body) var warnings []string @@ -266,10 +287,6 @@ func (s *site) parsePage(rel, out string, info fs.FileInfo) (*Page, []string, er warnings = append(warnings, fmt.Sprintf("%s has no H1; using filename-derived title %q", rel, title)) } } - sections, err := renderSections(md, doc, body) - if err != nil { - return nil, nil, err - } urlPath := "/" + out if path.Base(out) == "index.html" { urlPath = "/" + strings.TrimSuffix(out, "index.html") @@ -277,10 +294,17 @@ func (s *site) parsePage(rel, out string, info fs.FileInfo) (*Page, []string, er return &Page{ SourcePath: rel, OutputPath: out, URL: urlPath, CanonicalURL: s.canonicalRoot + urlPath, Title: title, Meta: meta, - ModTime: info.ModTime(), Sections: sections, + ModTime: info.ModTime(), markdownSource: body, markdownAST: doc, }, warnings, nil } +func newMarkdown() goldmark.Markdown { + return goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + ) +} + func frontmatter(raw []byte) (map[string]any, []byte, error) { meta := map[string]any{} if !bytes.HasPrefix(raw, []byte("---\n")) && !bytes.HasPrefix(raw, []byte("---\r\n")) { @@ -341,17 +365,12 @@ func rewriteDestination(destination []byte) []byte { func renderSections(md goldmark.Markdown, doc ast.Node, source []byte) ([]template.HTML, error) { var sections []template.HTML - for node := doc.FirstChild(); node != nil; { - next := node.NextSibling() - doc.RemoveChild(doc, node) - fragment := ast.NewDocument() - fragment.AppendChild(fragment, node) + for node := doc.FirstChild(); node != nil; node = node.NextSibling() { var out bytes.Buffer - if err := md.Renderer().Render(&out, source, fragment); err != nil { + if err := md.Renderer().Render(&out, source, node); err != nil { return nil, err } sections = append(sections, template.HTML(out.String())) // Goldmark raw HTML is disabled. - node = next } return sections, nil } @@ -387,10 +406,573 @@ func (s *site) loadTemplates() error { return fmt.Errorf("parse template %s: %w", src.path, err) } } + for _, tmpl := range t.Templates() { + if strings.HasPrefix(tmpl.Name(), "markdown/") && !supportedMarkdownTemplates[tmpl.Name()] { + return fmt.Errorf("unsupported Markdown template %q", tmpl.Name()) + } + } s.templates = t return nil } +func (s *site) prepareMarkdown() error { + r := &markdownRenderer{templates: s.templates} + r.renderer = s.markdown.Renderer() + s.markdown.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(r, 0))) + for _, src := range s.sources { + if src.kind != "markdown" { + continue + } + r.sourcePath = src.path + sections, err := renderSections(s.markdown, src.page.markdownAST, src.page.markdownSource) + if err != nil { + return err + } + src.page.Sections = sections + } + return nil +} + +var supportedMarkdownTemplates = map[string]bool{ + "markdown/heading1": true, "markdown/heading2": true, + "markdown/heading3": true, "markdown/heading4": true, + "markdown/heading5": true, "markdown/heading6": true, + "markdown/paragraph": true, "markdown/blockquote": true, + "markdown/code_block": true, "markdown/fenced_code_block": true, + "markdown/unordered_list": true, "markdown/ordered_list": true, + "markdown/list_item": true, "markdown/thematic_break": true, + "markdown/emphasis": true, "markdown/strong": true, + "markdown/code_span": true, "markdown/link": true, + "markdown/autolink": true, "markdown/email_autolink": true, + "markdown/image": true, "markdown/hard_break": true, + "markdown/strikethrough": true, "markdown/task_checkbox": true, + "markdown/table": true, "markdown/table_header": true, + "markdown/table_body": true, "markdown/table_row": true, + "markdown/table_header_cell": true, "markdown/table_cell": true, +} + +var tableMarkdownTemplates = []string{ + "markdown/table", "markdown/table_header", "markdown/table_body", + "markdown/table_row", "markdown/table_header_cell", "markdown/table_cell", +} + +type markdownContent struct{ Content template.HTML } +type markdownHeading struct { + Content template.HTML + ID string +} +type markdownFencedCode struct { + Content template.HTML + HighlightedContent template.HTML + Language string +} +type markdownList struct { + Content template.HTML + Tight bool +} +type markdownOrderedList struct { + Content template.HTML + Start int + Tight bool +} +type markdownLink struct { + Content template.HTML + Destination string + Title string +} +type markdownAutoLink struct { + Content template.HTML + Destination string +} +type markdownImage struct { + Alt string + Destination string + Title string +} +type markdownTask struct{ Checked bool } +type markdownCell struct { + Content template.HTML + Alignment string +} + +type markdownRenderer struct { + templates *template.Template + renderer renderer.Renderer + sourcePath string +} + +func (r *markdownRenderer) has(name string) bool { return r.templates.Lookup(name) != nil } + +func (r *markdownRenderer) hasAny(names ...string) bool { + for _, name := range names { + if r.has(name) { + return true + } + } + return false +} + +func (r *markdownRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + if r.hasAny("markdown/heading1", "markdown/heading2", "markdown/heading3", "markdown/heading4", "markdown/heading5", "markdown/heading6") { + reg.Register(ast.KindHeading, r.renderHeading) + } + if r.has("markdown/paragraph") { + reg.Register(ast.KindParagraph, r.renderContainer("markdown/paragraph")) + } + if r.has("markdown/blockquote") { + reg.Register(ast.KindBlockquote, r.renderContainer("markdown/blockquote")) + } + if r.has("markdown/code_block") { + reg.Register(ast.KindCodeBlock, r.renderCodeBlock) + } + if r.has("markdown/fenced_code_block") { + reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) + } + if r.hasAny("markdown/unordered_list", "markdown/ordered_list") { + reg.Register(ast.KindList, r.renderList) + } + if r.has("markdown/list_item") { + reg.Register(ast.KindListItem, r.renderContainer("markdown/list_item")) + } + if r.has("markdown/thematic_break") { + reg.Register(ast.KindThematicBreak, r.renderLeaf("markdown/thematic_break", struct{}{})) + } + if r.hasAny("markdown/emphasis", "markdown/strong") { + reg.Register(ast.KindEmphasis, r.renderEmphasis) + } + if r.has("markdown/code_span") { + reg.Register(ast.KindCodeSpan, r.renderCodeSpan) + } + if r.has("markdown/link") { + reg.Register(ast.KindLink, r.renderLink) + } + if r.hasAny("markdown/autolink", "markdown/email_autolink") { + reg.Register(ast.KindAutoLink, r.renderAutoLink) + } + if r.has("markdown/image") { + reg.Register(ast.KindImage, r.renderImage) + } + if r.has("markdown/hard_break") { + reg.Register(ast.KindText, r.renderText) + } + if r.has("markdown/strikethrough") { + reg.Register(extast.KindStrikethrough, r.renderContainer("markdown/strikethrough")) + } + if r.has("markdown/task_checkbox") { + reg.Register(extast.KindTaskCheckBox, r.renderTaskCheckbox) + } + if r.hasAny(tableMarkdownTemplates...) { + reg.Register(extast.KindTable, r.renderTable) + } +} + +func (r *markdownRenderer) children(source []byte, node ast.Node) (template.HTML, error) { + var out bytes.Buffer + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + if err := r.renderer.Render(&out, source, child); err != nil { + return "", err + } + } + return template.HTML(out.String()), nil +} + +func (r *markdownRenderer) execute(name string, data any) ([]byte, error) { + var out bytes.Buffer + if err := r.templates.ExecuteTemplate(&out, name, data); err != nil { + return nil, fmt.Errorf("render %s %s: %w", r.sourcePath, name, err) + } + return out.Bytes(), nil +} + +func (r *markdownRenderer) writeTemplate(w util.BufWriter, name string, data any) (ast.WalkStatus, error) { + out, err := r.execute(name, data) + if err != nil { + return ast.WalkStop, err + } + _, _ = w.Write(out) + return ast.WalkSkipChildren, nil +} + +func (r *markdownRenderer) renderContainer(name string) renderer.NodeRendererFunc { + return func(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + content, err := r.children(source, node) + if err != nil { + return ast.WalkStop, err + } + return r.writeTemplate(w, name, markdownContent{Content: content}) + } +} + +func (r *markdownRenderer) renderLeaf(name string, data any) renderer.NodeRendererFunc { + return func(w util.BufWriter, _ []byte, _ ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + return r.writeTemplate(w, name, data) + } +} + +func (r *markdownRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + heading := node.(*ast.Heading) + content, err := r.children(source, node) + if err != nil { + return ast.WalkStop, err + } + var id string + if value, ok := heading.AttributeString("id"); ok { + switch value := value.(type) { + case []byte: + id = string(value) + case string: + id = value + } + } + name := fmt.Sprintf("markdown/heading%d", heading.Level) + if r.has(name) { + return r.writeTemplate(w, name, markdownHeading{Content: content, ID: id}) + } + _, _ = fmt.Fprintf(w, "%s\n", content, heading.Level) + return ast.WalkSkipChildren, nil +} + +func rawLines(source []byte, node ast.Node) []byte { + var out bytes.Buffer + for i := 0; i < node.Lines().Len(); i++ { + line := node.Lines().At(i) + _, _ = out.Write(line.Value(source)) + } + return out.Bytes() +} + +func escapedMarkdown(source []byte) template.HTML { + return template.HTML(util.EscapeHTML(source)) +} + +func (r *markdownRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + return r.writeTemplate(w, "markdown/code_block", markdownContent{Content: escapedMarkdown(rawLines(source, node))}) +} + +func highlightedCode(language string, source []byte) template.HTML { + if language == "" { + return "" + } + known := false + for _, name := range lexers.Names(true) { + if strings.EqualFold(name, language) { + known = true + break + } + } + if !known { + return "" + } + lexer := lexers.Get(language) + if lexer == nil { + return "" + } + tokens, err := lexer.Tokenise(nil, string(source)) + if err != nil { + return "" + } + formatter := chromahtml.New( + chromahtml.WithClasses(true), + chromahtml.ClassPrefix("chroma-"), + chromahtml.PreventSurroundingPre(true), + ) + var out bytes.Buffer + if err := formatter.Format(&out, styles.Fallback, tokens); err != nil { + return "" + } + return template.HTML(out.String()) +} + +func (r *markdownRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + block := node.(*ast.FencedCodeBlock) + code := rawLines(source, node) + language := string(block.Language(source)) + return r.writeTemplate(w, "markdown/fenced_code_block", markdownFencedCode{ + Content: escapedMarkdown(code), HighlightedContent: highlightedCode(language, code), Language: language, + }) +} + +func (r *markdownRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + list := node.(*ast.List) + content, err := r.children(source, node) + if err != nil { + return ast.WalkStop, err + } + name := "markdown/unordered_list" + if list.IsOrdered() { + name = "markdown/ordered_list" + } + if r.has(name) { + if list.IsOrdered() { + return r.writeTemplate(w, name, markdownOrderedList{Content: content, Start: list.Start, Tight: list.IsTight}) + } + return r.writeTemplate(w, name, markdownList{Content: content, Tight: list.IsTight}) + } + tag := "ul" + if list.IsOrdered() { + tag = "ol" + } + _, _ = fmt.Fprintf(w, "<%s", tag) + if list.IsOrdered() && list.Start != 1 { + _, _ = fmt.Fprintf(w, ` start="%d"`, list.Start) + } + _, _ = fmt.Fprintf(w, ">\n%s\n", content, tag) + return ast.WalkSkipChildren, nil +} + +func (r *markdownRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + emphasis := node.(*ast.Emphasis) + content, err := r.children(source, node) + if err != nil { + return ast.WalkStop, err + } + name, tag := "markdown/emphasis", "em" + if emphasis.Level == 2 { + name, tag = "markdown/strong", "strong" + } + if r.has(name) { + return r.writeTemplate(w, name, markdownContent{Content: content}) + } + _, _ = fmt.Fprintf(w, "<%s>%s", tag, content, tag) + return ast.WalkSkipChildren, nil +} + +func (r *markdownRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + var code bytes.Buffer + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + value := child.(*ast.Text).Segment.Value(source) + if bytes.HasSuffix(value, []byte("\n")) { + _, _ = code.Write(value[:len(value)-1]) + _ = code.WriteByte(' ') + } else { + _, _ = code.Write(value) + } + } + return r.writeTemplate(w, "markdown/code_span", markdownContent{Content: escapedMarkdown(code.Bytes())}) +} + +func (r *markdownRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + link := node.(*ast.Link) + content, err := r.children(source, node) + if err != nil { + return ast.WalkStop, err + } + return r.writeTemplate(w, "markdown/link", markdownLink{ + Content: content, Destination: string(link.Destination), Title: string(link.Title), + }) +} + +func (r *markdownRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + link := node.(*ast.AutoLink) + destination := string(link.URL(source)) + name := "markdown/autolink" + if link.AutoLinkType == ast.AutoLinkEmail { + name = "markdown/email_autolink" + if !strings.HasPrefix(strings.ToLower(destination), "mailto:") { + destination = "mailto:" + destination + } + } + content := escapedMarkdown(link.Label(source)) + if r.has(name) { + return r.writeTemplate(w, name, markdownAutoLink{Content: content, Destination: destination}) + } + _, _ = w.WriteString(`%s`, content) + return ast.WalkSkipChildren, nil +} + +func imageAlt(source []byte, node ast.Node) string { + var out bytes.Buffer + writer := bufio.NewWriter(&out) + var walk func(ast.Node) + walk = func(parent ast.Node) { + for child := parent.FirstChild(); child != nil; child = child.NextSibling() { + switch child := child.(type) { + case *ast.Text: + gmhtml.DefaultWriter.Write(writer, child.Value(source)) + if child.SoftLineBreak() || child.HardLineBreak() { + _ = writer.WriteByte('\n') + } + case *ast.String: + if child.IsCode() { + _, _ = writer.Write(child.Value) + } else { + gmhtml.DefaultWriter.Write(writer, child.Value) + } + default: + walk(child) + } + } + } + walk(node) + _ = writer.Flush() + return stdhtml.UnescapeString(out.String()) +} + +func (r *markdownRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + image := node.(*ast.Image) + return r.writeTemplate(w, "markdown/image", markdownImage{ + Alt: imageAlt(source, node), Destination: string(image.Destination), Title: string(image.Title), + }) +} + +func (r *markdownRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + text := node.(*ast.Text) + if text.IsRaw() { + gmhtml.DefaultWriter.RawWrite(w, text.Segment.Value(source)) + } else { + gmhtml.DefaultWriter.Write(w, text.Segment.Value(source)) + } + if text.HardLineBreak() { + return r.writeTemplate(w, "markdown/hard_break", struct{}{}) + } + if text.SoftLineBreak() { + _ = w.WriteByte('\n') + } + return ast.WalkContinue, nil +} + +func (r *markdownRenderer) renderTaskCheckbox(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + checkbox := node.(*extast.TaskCheckBox) + return r.writeTemplate(w, "markdown/task_checkbox", markdownTask{Checked: checkbox.IsChecked}) +} + +func (r *markdownRenderer) tablePart(name string, fallback []byte, data any) ([]byte, error) { + if !r.has(name) { + return fallback, nil + } + return r.execute(name, data) +} + +func (r *markdownRenderer) tableCell(source []byte, node *extast.TableCell, header bool) ([]byte, error) { + content, err := r.children(source, node) + if err != nil { + return nil, err + } + name, tag := "markdown/table_cell", "td" + if header { + name, tag = "markdown/table_header_cell", "th" + } + if r.has(name) { + return r.execute(name, markdownCell{Content: content, Alignment: node.Alignment.String()}) + } + var out bytes.Buffer + _, _ = fmt.Fprintf(&out, "<%s", tag) + if node.Alignment != extast.AlignNone { + _, _ = fmt.Fprintf(&out, ` style="text-align:%s"`, node.Alignment.String()) + } + _, _ = fmt.Fprintf(&out, ">%s\n", content, tag) + return out.Bytes(), nil +} + +func (r *markdownRenderer) tableHeader(source []byte, header *extast.TableHeader) ([]byte, error) { + var content bytes.Buffer + for node := header.FirstChild(); node != nil; node = node.NextSibling() { + cell, err := r.tableCell(source, node.(*extast.TableCell), true) + if err != nil { + return nil, err + } + _, _ = content.Write(cell) + } + fallback := []byte("\n\n" + content.String() + "\n\n") + return r.tablePart("markdown/table_header", fallback, markdownContent{Content: template.HTML(content.String())}) +} + +func (r *markdownRenderer) tableRow(source []byte, row *extast.TableRow) ([]byte, error) { + var content bytes.Buffer + for node := row.FirstChild(); node != nil; node = node.NextSibling() { + cell, err := r.tableCell(source, node.(*extast.TableCell), false) + if err != nil { + return nil, err + } + _, _ = content.Write(cell) + } + fallback := []byte("\n" + content.String() + "\n") + return r.tablePart("markdown/table_row", fallback, markdownContent{Content: template.HTML(content.String())}) +} + +func (r *markdownRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + var content bytes.Buffer + child := node.FirstChild() + if header, ok := child.(*extast.TableHeader); ok { + rendered, err := r.tableHeader(source, header) + if err != nil { + return ast.WalkStop, err + } + _, _ = content.Write(rendered) + child = child.NextSibling() + } + if child != nil { + var rows bytes.Buffer + for ; child != nil; child = child.NextSibling() { + rendered, err := r.tableRow(source, child.(*extast.TableRow)) + if err != nil { + return ast.WalkStop, err + } + _, _ = rows.Write(rendered) + } + body, err := r.tablePart("markdown/table_body", []byte("\n"+rows.String()+"\n"), markdownContent{Content: template.HTML(rows.String())}) + if err != nil { + return ast.WalkStop, err + } + _, _ = content.Write(body) + } + if r.has("markdown/table") { + return r.writeTemplate(w, "markdown/table", markdownContent{Content: template.HTML(content.String())}) + } + _, _ = fmt.Fprintf(w, "\n%s
    \n", content.String()) + return ast.WalkSkipChildren, nil +} + func templateFuncs() template.FuncMap { return template.FuncMap{ "rel": relativePath, diff --git a/main_test.go b/main_test.go index f6c943e..cf292a6 100644 --- a/main_test.go +++ b/main_test.go @@ -1,14 +1,19 @@ package main import ( + "bytes" "encoding/json" "encoding/xml" + "errors" "html/template" + "io" "os" "path/filepath" "strings" "testing" "time" + + "golang.org/x/net/html" ) func writeTestFile(t *testing.T, root, name, contents string) { @@ -81,8 +86,17 @@ First paragraph with [local](other.md#part), [web](https://example.com/a.md), an page = src.page } } - if page == nil || page.Meta["summary"] != "hello" || len(page.Sections) != 5 { - t.Fatalf("unexpected page model: %#v", page) + if page == nil || page.Meta["summary"] != "hello" || len(page.Sections) != 0 || page.markdownAST == nil { + t.Fatalf("unexpected discovery model: %#v", page) + } + if err := s.loadTemplates(); err != nil { + t.Fatal(err) + } + if err := s.prepareMarkdown(); err != nil { + t.Fatal(err) + } + if len(page.Sections) != 5 { + t.Fatalf("prepared sections = %d, want 5", len(page.Sections)) } } @@ -440,3 +454,313 @@ func TestUnchangedOutputsKeepMtime(t *testing.T) { t.Error("changed output mtime not updated") } } + +func TestMarkdownNoOverrideMatchesGoldmark(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}`) + markdown := "# Heading\n\nParagraph with **strong**, ~~strike~~, and [link](#heading).\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n\n" + writeTestFile(t, root, "page.md", markdown) + if err := build(root); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(root, "page.html")) + if err != nil { + t.Fatal(err) + } + var want bytes.Buffer + if err := newMarkdown().Convert([]byte(markdown), &want); err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want.Bytes()) { + t.Fatalf("no-override output changed:\n got: %s\nwant: %s", got, want.Bytes()) + } +} + +func TestMarkdownCoreOverrideContract(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}} +{{define "markdown/heading1"}}

    {{.Content}}

    {{end}} +{{define "markdown/paragraph"}}

    {{.Content}}

    {{end}} +{{define "markdown/blockquote"}}{{.Content}}{{end}} +{{define "markdown/code_block"}}{{.Content}}{{end}} +{{define "markdown/fenced_code_block"}}{{if .HighlightedContent}}{{.HighlightedContent}}{{else}}{{.Content}}{{end}}{{end}} +{{define "markdown/ordered_list"}}
      {{.Content}}
    {{end}} +{{define "markdown/unordered_list"}}
      {{.Content}}
    {{end}} +{{define "markdown/list_item"}}
  • {{.Content}}
  • {{end}} +{{define "markdown/emphasis"}}{{.Content}}{{end}} +{{define "markdown/strong"}}{{.Content}}{{end}} +{{define "markdown/code_span"}}{{.Content}}{{end}} +{{define "markdown/link"}}{{.Content}}{{end}} +{{define "markdown/autolink"}}{{.Content}}{{end}} +{{define "markdown/email_autolink"}}{{.Content}}{{end}} +{{define "markdown/image"}}{{.Alt}}{{end}}`) + writeTestFile(t, root, "target.md", "# Target\n") + markdown := "# Root Heading\n\n## Default *heading*\n\nParagraph *em **deep*** ` a b ` [local](target.md \"Title\") [unsafe](javascript:alert(1)).\n\n ![A **very** & black](cat.jpg \"Mochi\")\n\n> quoted **strong**\n\n \n\n" + + "```go extra\nx := \"\"\n```\n\n```unknown\n\n```\n\n```\n\n```\n\n" + + "1. one\n1. two\n\n### split one\n\n3. three\n3. four\n\n### split two\n\n5. five\n99. six\n\n### split three\n\n- tight\n 1. nested\n\n### split four\n\n- loose\n\n- list\n" + writeTestFile(t, root, "page.md", markdown) + if err := build(root); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(root, "page.html")) + if err != nil { + t.Fatal(err) + } + got := string(raw) + for _, want := range []string{ + `

    Root Heading

    `, + `

    Default heading

    `, + `

    Paragraph em deep a b`, + `local`, + `unsafe`, + `https://example.com/a`, + `person@example.com`, + `A very & black`, + `

    quoted strong

    `, + `<script>indented</script>`, + ``, + `<a>`, + `<b>`, + `
      `, + `
        `, + `
          `, + `
          • tight`, + `
            1. nested`, + `
              • loose

                `, + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "