summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-31 16:40:49 -0600
committert <t@tjp.lol>2026-07-31 16:40:49 -0600
commitc0c7686926f5f997ef38c817600523c934d05c44 (patch)
treebc63b21d553252d23cf86abb660fe1b732460737
parentfd8b956ab23176e669c29e4086c1f016be419ed9 (diff)
Add recursive Markdown rendering templates
-rw-r--r--README.md71
-rw-r--r--go.mod5
-rw-r--r--go.sum10
-rw-r--r--main.go612
-rw-r--r--main_test.go328
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 `<tbody>` 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 `<pre>` or `<code>`
+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"}}<p class="weft">{{.Content}}</p>{{end}}
+{{define "markdown/ordered_list"}}
+<ol class="weft"{{if ne .Start 1}} start="{{.Start}}"{{end}}>{{.Content}}</ol>
+{{end}}
+{{define "markdown/list_item"}}<li class="weft">{{.Content}}</li>{{end}}
+{{define "markdown/fenced_code_block"}}
+<pre class="weft chroma"><code class="weft language-{{.Language}}">{{if .HighlightedContent}}{{.HighlightedContent}}{{else}}{{.Content}}{{end}}</code></pre>
+{{end}}
+{{define "markdown/table_header"}}<thead class="weft"><tr class="weft">{{.Content}}</tr></thead>{{end}}
+{{define "markdown/table_body"}}<tbody class="weft">{{.Content}}</tbody>{{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, "<h%d", heading.Level)
+ if id != "" {
+ _, _ = w.WriteString(` id="`)
+ _, _ = w.Write(util.EscapeHTML([]byte(id)))
+ _ = w.WriteByte('"')
+ }
+ _, _ = fmt.Fprintf(w, ">%s</h%d>\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</%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</%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(`<a href="`)
+ _, _ = w.Write(util.EscapeHTML(util.URLEscape([]byte(destination), false)))
+ _, _ = fmt.Fprintf(w, `">%s</a>`, 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</%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("<thead>\n<tr>\n" + content.String() + "</tr>\n</thead>\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("<tr>\n" + content.String() + "</tr>\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("<tbody>\n"+rows.String()+"</tbody>\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, "<table>\n%s</table>\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<script>bad()</script>\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"}}<H1 data-id="{{.ID}}">{{.Content}}</H1>{{end}}
+{{define "markdown/paragraph"}}<P>{{.Content}}</P>{{end}}
+{{define "markdown/blockquote"}}<BQ>{{.Content}}</BQ>{{end}}
+{{define "markdown/code_block"}}<I>{{.Content}}</I>{{end}}
+{{define "markdown/fenced_code_block"}}<F lang="{{.Language}}" highlighted="{{if .HighlightedContent}}yes{{else}}no{{end}}">{{if .HighlightedContent}}{{.HighlightedContent}}{{else}}{{.Content}}{{end}}</F>{{end}}
+{{define "markdown/ordered_list"}}<OL start="{{.Start}}" tight="{{.Tight}}">{{.Content}}</OL>{{end}}
+{{define "markdown/unordered_list"}}<UL tight="{{.Tight}}">{{.Content}}</UL>{{end}}
+{{define "markdown/list_item"}}<LI>{{.Content}}</LI>{{end}}
+{{define "markdown/emphasis"}}<E>{{.Content}}</E>{{end}}
+{{define "markdown/strong"}}<S>{{.Content}}</S>{{end}}
+{{define "markdown/code_span"}}<C>{{.Content}}</C>{{end}}
+{{define "markdown/link"}}<L href="{{.Destination}}" title="{{.Title}}">{{.Content}}</L>{{end}}
+{{define "markdown/autolink"}}<AU dest="{{.Destination}}">{{.Content}}</AU>{{end}}
+{{define "markdown/email_autolink"}}<AE dest="{{.Destination}}">{{.Content}}</AE>{{end}}
+{{define "markdown/image"}}<IMG alt="{{.Alt}}" dest="{{.Destination}}" title="{{.Title}}">{{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<https://example.com/a> <person@example.com> ![A **very** & black](cat.jpg \"Mochi\")\n\n> quoted **strong**\n\n <script>indented</script>\n\n" +
+ "```go extra\nx := \"</span><script>bad()</script>\"\n```\n\n```unknown\n<a>\n```\n\n```\n<b>\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{
+ `<H1 data-id="root-heading">Root Heading</H1>`,
+ `<h2 id="default-heading">Default <E>heading</E></h2>`,
+ `<P>Paragraph <E>em <S>deep</S></E> <C>a b</C>`,
+ `<L href="target.html" title="Title">local</L>`,
+ `<L href="#ZgotmplZ" title="">unsafe</L>`,
+ `<AU dest="https://example.com/a">https://example.com/a</AU>`,
+ `<AE dest="mailto:person@example.com">person@example.com</AE>`,
+ `<IMG alt="A very &amp; black" dest="cat.jpg" title="Mochi">`,
+ `<BQ><P>quoted <S>strong</S></P>`,
+ `<I>&lt;script&gt;indented&lt;/script&gt;`,
+ `<F lang="go" highlighted="yes">`,
+ `<F lang="unknown" highlighted="no">&lt;a&gt;`,
+ `<F lang="" highlighted="no">&lt;b&gt;`,
+ `<OL start="1" tight="true">`,
+ `<OL start="3" tight="true">`,
+ `<OL start="5" tight="true">`,
+ `<UL tight="true"><LI>tight`,
+ `<OL start="1" tight="true"><LI>nested`,
+ `<UL tight="false"><LI><P>loose</P>`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("output missing %q:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "<script>") || !strings.Contains(got, "&lt;/span&gt;") {
+ t.Errorf("highlighted hostile source was not escaped:\n%s", got)
+ }
+ if strings.Count(got, `<OL start="1"`) != 2 || strings.Count(got, `<OL start="3"`) != 1 || strings.Count(got, `<OL start="5"`) != 1 {
+ t.Errorf("ordered list starts are wrong:\n%s", got)
+ }
+}
+
+func TestMarkdownSubtypeFallbacks(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}
+{{define "markdown/heading2"}}<h2 class="custom">{{.Content}}</h2>{{end}}
+{{define "markdown/strong"}}<strong class="custom">{{.Content}}</strong>{{end}}
+{{define "markdown/ordered_list"}}<ol class="custom">{{.Content}}</ol>{{end}}`)
+ writeTestFile(t, root, "page.md", "# Default H1\n\n## Custom H2\n\n*default emphasis with **custom strong***\n\n- default unordered **nested**\n\n1. custom ordered *nested default*\n")
+ 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{
+ `<h1 id="default-h1">Default H1</h1>`,
+ `<h2 class="custom">Custom H2</h2>`,
+ `<em>default emphasis with <strong class="custom">custom strong</strong></em>`,
+ `<ul>`, `default unordered <strong class="custom">nested</strong>`,
+ `<ol class="custom">`, `custom ordered <em>nested default</em>`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("output missing %q:\n%s", want, got)
+ }
+ }
+}
+
+func TestMarkdownGFMAndTableContract(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}
+{{define "markdown/strikethrough"}}<DEL>{{.Content}}</DEL>{{end}}
+{{define "markdown/task_checkbox"}}<CHECKED value="{{.Checked}}">{{end}}
+{{define "markdown/table"}}<TABLE>{{.Content}}</TABLE>{{end}}
+{{define "markdown/table_header"}}<THEAD>{{.Content}}</THEAD>{{end}}
+{{define "markdown/table_body"}}<TBODY>{{.Content}}</TBODY>{{end}}
+{{define "markdown/table_row"}}<TR>{{.Content}}</TR>{{end}}
+{{define "markdown/table_header_cell"}}<TH align="{{.Alignment}}">{{.Content}}</TH>{{end}}
+{{define "markdown/table_cell"}}<TD align="{{.Alignment}}">{{.Content}}</TD>{{end}}`)
+ writeTestFile(t, root, "page.md", "# GFM\n\n~~gone~~\n\n- [ ] no\n- [x] yes\n\n| L | C | R | N |\n| :-- | :-: | --: | --- |\n| a | b | c | d |\n\n| Empty |\n| --- |\n")
+ 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{
+ `<DEL>gone</DEL>`, `<CHECKED value="false">`, `<CHECKED value="true">`,
+ `<TH align="left">L</TH>`, `<TH align="center">C</TH>`, `<TH align="right">R</TH>`, `<TH align="none">N</TH>`,
+ `<TD align="left">a</TD>`, `<TD align="center">b</TD>`, `<TD align="right">c</TD>`, `<TD align="none">d</TD>`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("output missing %q:\n%s", want, got)
+ }
+ }
+ if strings.Count(got, "<TABLE>") != 2 || strings.Count(got, "<THEAD>") != 2 || strings.Count(got, "<TBODY>") != 1 || strings.Count(got, "<TR>") != 1 {
+ t.Errorf("table semantic pieces are wrong:\n%s", got)
+ }
+}
+
+func TestMarkdownEveryElementCanBeOverridden(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}
+{{define "markdown/heading1"}}<h1 class="weft" id="{{.ID}}">{{.Content}}</h1>{{end}}
+{{define "markdown/heading2"}}<h2 class="weft" id="{{.ID}}">{{.Content}}</h2>{{end}}
+{{define "markdown/heading3"}}<h3 class="weft" id="{{.ID}}">{{.Content}}</h3>{{end}}
+{{define "markdown/heading4"}}<h4 class="weft" id="{{.ID}}">{{.Content}}</h4>{{end}}
+{{define "markdown/heading5"}}<h5 class="weft" id="{{.ID}}">{{.Content}}</h5>{{end}}
+{{define "markdown/heading6"}}<h6 class="weft" id="{{.ID}}">{{.Content}}</h6>{{end}}
+{{define "markdown/paragraph"}}<p class="weft">{{.Content}}</p>{{end}}
+{{define "markdown/blockquote"}}<blockquote class="weft">{{.Content}}</blockquote>{{end}}
+{{define "markdown/code_block"}}<pre class="weft"><code class="weft">{{.Content}}</code></pre>{{end}}
+{{define "markdown/fenced_code_block"}}<pre class="weft"><code class="weft">{{.Content}}</code></pre>{{end}}
+{{define "markdown/unordered_list"}}<ul class="weft">{{.Content}}</ul>{{end}}
+{{define "markdown/ordered_list"}}<ol class="weft" start="{{.Start}}">{{.Content}}</ol>{{end}}
+{{define "markdown/list_item"}}<li class="weft">{{.Content}}</li>{{end}}
+{{define "markdown/thematic_break"}}<hr class="weft">{{end}}
+{{define "markdown/emphasis"}}<em class="weft">{{.Content}}</em>{{end}}
+{{define "markdown/strong"}}<strong class="weft">{{.Content}}</strong>{{end}}
+{{define "markdown/code_span"}}<code class="weft">{{.Content}}</code>{{end}}
+{{define "markdown/link"}}<a class="weft" href="{{.Destination}}">{{.Content}}</a>{{end}}
+{{define "markdown/autolink"}}<a class="weft" href="{{.Destination}}">{{.Content}}</a>{{end}}
+{{define "markdown/email_autolink"}}<a class="weft" href="{{.Destination}}">{{.Content}}</a>{{end}}
+{{define "markdown/image"}}<img class="weft" alt="{{.Alt}}" src="{{.Destination}}">{{end}}
+{{define "markdown/hard_break"}}<br class="weft">{{end}}
+{{define "markdown/strikethrough"}}<s class="weft">{{.Content}}</s>{{end}}
+{{define "markdown/task_checkbox"}}<input class="weft" type="checkbox" disabled{{if .Checked}} checked{{end}}>{{end}}
+{{define "markdown/table"}}<table class="weft">{{.Content}}</table>{{end}}
+{{define "markdown/table_header"}}<thead class="weft"><tr class="weft">{{.Content}}</tr></thead>{{end}}
+{{define "markdown/table_body"}}<tbody class="weft">{{.Content}}</tbody>{{end}}
+{{define "markdown/table_row"}}<tr class="weft">{{.Content}}</tr>{{end}}
+{{define "markdown/table_header_cell"}}<th class="weft">{{.Content}}</th>{{end}}
+{{define "markdown/table_cell"}}<td class="weft">{{.Content}}</td>{{end}}`)
+ markdown := "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6\n\n> quote\n\n code\n\n```unknown\nfenced\n```\n\n- item\n\n1. ordered\n\n---\n\n*em* **strong** `code` [link](#h1) <https://example.com> <a@example.com> ![alt](image.png) ~~gone~~ \nbreak\n\n- [x] task\n\n| H |\n| - |\n| C |\n\n<div>raw</div>\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)
+ }
+ z := html.NewTokenizer(bytes.NewReader(raw))
+ elements := 0
+ for {
+ typ := z.Next()
+ if typ == html.ErrorToken {
+ if !errors.Is(z.Err(), io.EOF) {
+ t.Fatal(z.Err())
+ }
+ break
+ }
+ if typ != html.StartTagToken && typ != html.SelfClosingTagToken {
+ continue
+ }
+ token := z.Token()
+ elements++
+ found := false
+ for _, attr := range token.Attr {
+ if attr.Key == "class" && strings.Contains(" "+attr.Val+" ", " weft ") {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("<%s> lacks class=weft in:\n%s", token.Data, raw)
+ }
+ }
+ if elements < len(supportedMarkdownTemplates) {
+ t.Errorf("only saw %d elements for %d supported templates", elements, len(supportedMarkdownTemplates))
+ }
+ if strings.Contains(string(raw), "<div>") {
+ t.Errorf("raw HTML was re-enabled:\n%s", raw)
+ }
+}
+
+func TestMarkdownPartialTableOverrides(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ definition string
+ want string
+ }{
+ {"cell", `{{define "markdown/table_cell"}}<td class="only-cell">{{.Content}}</td>{{end}}`, `<td class="only-cell">B</td>`},
+ {"body", `{{define "markdown/table_body"}}<tbody class="only-body">{{.Content}}</tbody>{{end}}`, `<tbody class="only-body">`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}`+tc.definition)
+ writeTestFile(t, root, "page.md", "# Table\n\n| A |\n| - |\n| B |\n")
+ 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)
+ if !strings.Contains(got, tc.want) || strings.Count(got, "<table>") != 1 || strings.Count(got, "</table>") != 1 || strings.Count(got, "<thead>") != 1 || strings.Count(got, "</thead>") != 1 || strings.Count(got, "<tr>") != strings.Count(got, "</tr>") {
+ t.Errorf("invalid partial table output:\n%s", got)
+ }
+ })
+ }
+}
+
+func TestMarkdownTemplateErrorsAreAtomic(t *testing.T) {
+ t.Run("unknown name", func(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}{{define "markdown/paragaph"}}bad{{end}}`)
+ writeTestFile(t, root, "page.md", "# Page\n")
+ err := build(root)
+ if err == nil || !strings.Contains(err.Error(), `unsupported Markdown template "markdown/paragaph"`) {
+ t.Fatalf("error = %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "page.html")); !os.IsNotExist(err) {
+ t.Fatalf("failed build wrote page.html: %v", err)
+ }
+ })
+
+ t.Run("execution context and preservation", func(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}`)
+ writeTestFile(t, root, "target.md", "# Target\n")
+ writeTestFile(t, root, "page.md", "# Old\n")
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(filepath.Join(root, "page.html"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}{{define "markdown/link"}}{{.Missing}}{{end}}`)
+ writeTestFile(t, root, "page.md", "# New\n\n[link](target.md)\n")
+ err = build(root)
+ if err == nil || !strings.Contains(err.Error(), "render page.md markdown/link") {
+ t.Fatalf("error = %v", err)
+ }
+ after, readErr := os.ReadFile(filepath.Join(root, "page.html"))
+ if readErr != nil || !bytes.Equal(after, before) {
+ t.Fatalf("failed build changed output: %v\n%s", readErr, after)
+ }
+ })
+}
+
+func TestExplicitOutputSeesAllPreparedSections(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}{{define "markdown/paragraph"}}<p class="prepared">{{.Content}}</p>{{end}}`)
+ writeTestFile(t, root, "a.xml.tmpl", `<pages>{{range index .Pages ""}}<page>{{xml (joinSections .Sections)}}</page>{{end}}</pages>`)
+ writeTestFile(t, root, "z.md", "# Zed\n\nlast source\n")
+ writeTestFile(t, root, "b.md", "# Bee\n\nsecond source\n")
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(filepath.Join(root, "a.xml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := string(raw)
+ for _, want := range []string{"&lt;h1 id=&#34;bee&#34;&gt;Bee&lt;/h1&gt;", "&lt;p class=&#34;prepared&#34;&gt;second source&lt;/p&gt;", "&lt;h1 id=&#34;zed&#34;&gt;Zed&lt;/h1&gt;", "&lt;p class=&#34;prepared&#34;&gt;last source&lt;/p&gt;"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("explicit output missing %q:\n%s", want, got)
+ }
+ }
+}