diff options
| author | t <t@tjp.lol> | 2026-07-12 17:46:15 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-12 17:46:20 -0600 |
| commit | b3673ecd7865c9144b68e5bfd34318c98e398931 (patch) | |
| tree | df96a27443f8498a3137a97fa4a07ab5bd371f8f | |
| parent | 5cef3624289c11b21c661b314973bc85183bc3c6 (diff) | |
include the title h1 in `.Sections`
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | main.go | 26 | ||||
| -rw-r--r-- | main_test.go | 19 |
3 files changed, 36 insertions, 19 deletions
@@ -11,7 +11,8 @@ go build -o weft . Markdown uses CommonMark with GFM tables, strikethrough, task lists, and autolinks. Raw Markdown HTML is disabled. Optional YAML frontmatter is exposed -as `.Meta`; the first H1 becomes `.Title` and is removed from `.Sections`. +as `.Meta`; the first H1 becomes `.Title`, while `.Sections` preserves every +top-level Markdown node, including that H1. ## Site templates @@ -46,10 +47,10 @@ The current `Page` is embedded, so its fields can be accessed as either | `.OutputPath` | Slash-separated generated path relative to the site root. | | `.URL` | Root-relative URL. Markdown `index.html` outputs use their directory URL. | | `.CanonicalURL` | `.URL` beneath `canonical-root` | -| `.Title` | First H1, then frontmatter `title`, then a filename-derived fallback. The extracted H1 is removed from the body. | +| `.Title` | First H1, then frontmatter `title`, then a filename-derived fallback. | | `.Meta` | `map[string]any` containing optional YAML frontmatter. | | `.ModTime` | Source-file modification time as `time.Time`. | -| `.Sections` | `[]template.HTML` containing one rendered fragment per top-level Markdown node. Raw Markdown HTML is disabled before these trusted fragments are created. | +| `.Sections` | `[]template.HTML` containing every rendered top-level Markdown node in source order, including the H1 used for `.Title`. Raw Markdown HTML is disabled before these trusted fragments are created. | | `.Pages` | All discovered Markdown pages grouped by ancestor directory. This field belongs to the root template value, not `Page`. | `.Pages` uses slash-separated directory keys. The root key is `""`. A page at @@ -67,7 +68,7 @@ have no Markdown title, modification time, or sections. | Function | Result | | --- | --- | | `rel from target` | Makes a root-relative site target relative to the directory containing `from`. HTTP(S), `mailto:`, and fragment targets pass through. A trailing `/` is preserved, so `rel "about/index.html" "/"` returns `../`. | -| `sortPages pages key` | Returns a copy. `"title"` sorts ascending case-insensitively; `"mod_time"` sorts newest first; every other key sorts newest first using that frontmatter value as a date. | +| `sortPages pages keys...` | Returns a copy. `"title"` sorts ascending case-insensitively. Date keys sort newest first, using the first valid value for each page; `"mod_time"` selects `.ModTime` and other keys select frontmatter values. | | `filterPages pages key value` | Keeps pages whose value stringifies equally. `"title"` and `"output"` select `.Title` and `.OutputPath`; other keys select `.Meta[key]`. | | `slicePages pages start end` | Returns the half-open range `[start:end]`, clamping both bounds instead of failing. | | `joinSections sections` | Concatenates rendered Markdown sections and returns trusted `template.HTML`. | @@ -80,6 +81,7 @@ Examples: ```gotemplate <a href="{{rel .OutputPath "/notes/"}}">notes</a> {{range sortPages (index .Pages "weblog") "post_date"}}{{.Title}}{{end}} +{{range sortPages (index .Pages "weblog") "post_date" "mod_time"}}{{.Title}}{{end}} {{with index .Sections 0}}{{.}}{{end}} {{joinSections (slice .Sections 1)}} <content type="html">{{xml (joinSections .Sections)}}</content> @@ -312,9 +312,7 @@ func extractTitle(doc ast.Node, source []byte) string { if !ok || heading.Level != 1 { continue } - title := strings.TrimSpace(string(heading.Text(source))) - doc.RemoveChild(doc, node) - return title + return strings.TrimSpace(string(heading.Text(source))) } return "" } @@ -427,17 +425,25 @@ func relativePath(from, target string) string { return rel } -func sortPages(pages []*Page, key string) []*Page { +func sortPages(pages []*Page, keys ...string) []*Page { result := slices.Clone(pages) - sort.SliceStable(result, func(i, j int) bool { - if key == "mod_time" { - return result[i].ModTime.After(result[j].ModTime) + date := func(page *Page) time.Time { + for _, key := range keys { + if key == "mod_time" { + return page.ModTime + } + if value, ok := asTime(page.Meta[key]); ok { + return value + } } - if key == "title" { + return time.Time{} + } + sort.SliceStable(result, func(i, j int) bool { + if len(keys) == 1 && keys[0] == "title" { return strings.ToLower(result[i].Title) < strings.ToLower(result[j].Title) } - a, _ := asTime(result[i].Meta[key]) - b, _ := asTime(result[j].Meta[key]) + a := date(result[i]) + b := date(result[j]) return a.After(b) }) return result diff --git a/main_test.go b/main_test.go index f2aae0a..a1eda14 100644 --- a/main_test.go +++ b/main_test.go @@ -38,6 +38,8 @@ func TestMarkdownGFMFrontmatterTitleSectionsAndLinks(t *testing.T) { writeTestFile(t, root, "page.md", `--- summary: hello --- +> Read this first. + # My *Page* First paragraph with [local](other.md#part), [web](https://example.com/a.md), and [fragment](#part). @@ -56,13 +58,16 @@ First paragraph with [local](other.md#part), [web](https://example.com/a.md), an t.Fatal(err) } got := string(raw) - for _, want := range []string{"<title>My Page</title>", `href="other.html#part"`, `href="https://example.com/a.md"`, `href="#part"`, "<table>"} { + for _, want := range []string{"<title>My Page</title>", "<blockquote>", "<h1", `href="other.html#part"`, `href="https://example.com/a.md"`, `href="#part"`, "<table>"} { if !strings.Contains(got, want) { t.Errorf("output missing %q:\n%s", want, got) } } - if strings.Contains(got, "<h1") || strings.Contains(got, "<script>") { - t.Errorf("H1 duplicated or raw HTML rendered:\n%s", got) + if strings.Index(got, "<blockquote>") > strings.Index(got, "<h1") { + t.Errorf("sections rendered out of source order:\n%s", got) + } + if strings.Contains(got, "<script>") { + t.Errorf("raw HTML rendered:\n%s", got) } s := &site{root: root, pages: map[string][]*Page{}} @@ -75,7 +80,7 @@ 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) != 3 { + if page == nil || page.Meta["summary"] != "hello" || len(page.Sections) != 5 { t.Fatalf("unexpected page model: %#v", page) } } @@ -350,10 +355,14 @@ func TestGitignoreTracksOnlyCurrentGeneratedOutputs(t *testing.T) { func TestSortingFilteringSlicing(t *testing.T) { a := &Page{Title: "A", Meta: map[string]any{"post_date": "2024-01-01", "kind": "x"}, ModTime: time.Unix(1, 0)} - b := &Page{Title: "B", Meta: map[string]any{"post_date": "2025-01-01", "kind": "y"}, ModTime: time.Unix(2, 0)} + b := &Page{Title: "B", Meta: map[string]any{"post_date": "2025-01-01", "kind": "y"}, ModTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} if got := sortPages([]*Page{a, b}, "post_date"); got[0] != b { t.Fatal("date sort") } + b.Meta["post_date"] = "invalid" + if got := sortPages([]*Page{a, b}, "post_date", "mod_time"); got[0] != b { + t.Fatal("date fallback sort") + } if got := filterPages([]*Page{a, b}, "kind", "x"); len(got) != 1 || got[0] != a { t.Fatal("filter") } |
