From 695548bada8c90a3a17c234a3716af415832f950 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:34:13 -0400 Subject: [PATCH] feat: Added 'footnotes' markdown extension. --- AGENTS.md | 10 ++-- TODOS.md | 6 ++- markdown/footnotes.odin | 102 +++++++++++++++++++++++++++++++++-- markdown/footnotes_test.odin | 99 ++++++++++++++++++++++++++++++++++ markdown/markdown.odin | 26 +++++++-- site.odin | 1 + site/thor.json | 3 ++ 7 files changed, 234 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45f939b..3026156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ thor/ |---|---|---| | `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars | | `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path, allocator)` — full pipeline (clones cmark output, frees original), `parse_extension_list`, `apply_extension_config` | -| | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark). cmark output freed via `defer cm.free_string(raw_html)` on separate variable. | +| | `footnotes.odin` | `strip_definitions` (pre-cmark, shared by `.Sidenotes` + `.Footnotes`), `inject_notes` (post-cmark sidenote rendering), `inject_footnotes` (post-cmark standard footnote rendering — numbered `` links + `
    ` at bottom). `.Sidenotes` and `.Footnotes` are mutually exclusive; `resolve_extension_conflicts` in `markdown.odin` picks `.Footnotes` if both are set. | | | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) | | | `emoji.odin` | `expand_emoji` — `:shortcode:` → unicode emoji | | | `sectionate.odin` | `wrap_sections` — splits HTML at `

    ` into `
    ` wrappers | @@ -178,7 +178,7 @@ Config is split into three structs with a clear 5-step initialization flow: **`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`. -**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via: +**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`, `Footnotes`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via: - `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }` - CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive. @@ -258,11 +258,12 @@ Lives in the `markdown` package. Entry point: `md.process(body, ext, file_path)` ``` raw markdown - → md.strip_definitions (if .Sidenotes — pre-cmark) + → md.strip_definitions (if .Sidenotes || .Footnotes — pre-cmark) → md.convert_deflists (if .DefLists — pre-cmark) → cmark markdown_to_html (Unsafe mode for HTML passthrough) → md.expand_emoji (if .Emoji — post-cmark) - → md.inject_notes (if .Sidenotes — post-cmark) + → md.inject_notes (if .Sidenotes — post-cmark, sidenote rendering) + → md.inject_footnotes (if .Footnotes — post-cmark, standard footnote rendering) → md.inject_alerts (if .Alerts — post-cmark) → md.highlight_code (if .Highlight — post-cmark) → md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections) @@ -514,6 +515,7 @@ These are things that are easy to get wrong: - **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default` — `or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly. - **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`). - **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output. +- **`fmt.sbprintf` writes directly to a `strings.Builder`.** Prefer `fmt.sbprintf(&sb, fmt, args...)` over `fmt.aprintf(fmt, args...)` + `defer delete` + `strings.write_string`. The `aprintf` pattern allocates an intermediate string, requires manual cleanup, and queues a `defer delete` per loop iteration. `sbprintf` avoids all of this. ## TODO diff --git a/TODOS.md b/TODOS.md index 95eb84c..5ff8a72 100644 --- a/TODOS.md +++ b/TODOS.md @@ -13,6 +13,8 @@ - [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)? - [ ] Improve diagnostics - [x] keep track of every error and don't report them more than once. + - [ ] show parsed arg when -extension is unrecognized + - [ ] also do typo detection? - [ ] `*` make sure the frontmatter parser has good diagnostics. - [ ] fix the diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TODOS.yaml) - [ ] only report format errors once. @@ -106,8 +108,8 @@ ## Markdown - [ ] Add overloads for every extension - accept ^strings.Builder. -- [ ] Add conventional (Hugo style) footnotes option. -- [ ] Add opt-in deflist support. +- [x] Add conventional (Hugo style) footnotes option. +- [x] Add opt-in deflist support. - [x] Decide if lambdas actually provide any value. - [ ] add tables extension - [x] Table of contents support. diff --git a/markdown/footnotes.odin b/markdown/footnotes.odin index 2e37bed..bf5b0a2 100644 --- a/markdown/footnotes.odin +++ b/markdown/footnotes.odin @@ -174,21 +174,22 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin note: string defer delete(note) if is_margin { - note = fmt.aprintf( + fmt.sbprintf( + &parts, `%s`, id, id, def_html, ) } else { - note = fmt.aprintf( + fmt.sbprintf( + &parts, `%s`, id, id, def_html, ) } - strings.write_string(&parts, note) remaining = remaining[ref_end:] } @@ -196,3 +197,98 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin return strings.to_string(parts) } +// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers +// them sequentially by order of appearance, and replaces them with links. +// Appends a
      at the end with definitions. +// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally. +inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string { + if len(sn_defs) == 0 && len(mn_defs) == 0 { + return html + } + + parts: strings.Builder + strings.builder_init_len(&parts, 0) + defer strings.builder_destroy(&parts) + + number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator) + ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator) + next_num := 1 + + remaining := html + + for { + sn_pos := strings.index(remaining, "[^") + mn_pos := strings.index(remaining, "[*") + + is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos) + pos := sn_pos + if is_margin { + pos = mn_pos + } + if pos < 0 { + strings.write_string(&parts, remaining) + break + } + + strings.write_string(&parts, remaining[:pos]) + + close := strings.index(remaining[pos + 2:], "]") + if close < 0 { + strings.write_string(&parts, remaining[pos:]) + break + } + + id := remaining[pos + 2:pos + 2 + close] + ref_end := pos + 2 + close + 1 + + defs := sn_defs + if is_margin { + defs = mn_defs + } + def_text, found := defs[id] + if !found { + strings.write_string(&parts, remaining[pos:ref_end]) + remaining = remaining[ref_end:] + continue + } + + num, seen := number_of[id] + if !seen { + num = next_num + number_of[id] = num + next_num += 1 + append(&ordered_ids, id) + } + + fmt.sbprintf(&parts, `%d`, num, num, num) + + remaining = remaining[ref_end:] + } + + if len(ordered_ids) > 0 { + strings.write_string(&parts, "\n
      \n
      \n
        \n") + for id in ordered_ids { + def_text, ok := sn_defs[id] + if !ok { + def_text, ok = mn_defs[id] + } + if !ok do continue + + def_html := render_inline_md(def_text) + num := number_of[id] + + fmt.sbprintf( + &parts, + `
      1. %s ↩︎
      2. ` + + "\n", + num, + def_html, + num, + ) + } + strings.write_string(&parts, "
      \n
      ") + } + + return strings.to_string(parts) +} + diff --git a/markdown/footnotes_test.odin b/markdown/footnotes_test.odin index 0f84c25..0d3e9a3 100644 --- a/markdown/footnotes_test.odin +++ b/markdown/footnotes_test.odin @@ -119,3 +119,102 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) { testing.expect(t, strings.contains(out, "[^missing]")) testing.expect(t, strings.contains(out, "[*missing]")) } + +@(test) +test_inject_footnotes_basic :: proc(t: ^testing.T) { + html := "Text[^a] end." + sn := map[string]string { + "a" = "a footnote", + } + defer delete_map(sn) + mn := make(map[string]string) + defer delete_map(mn) + + out := inject_footnotes(html, sn, mn) + + testing.expect(t, strings.contains(out, `1`)) + testing.expect(t, strings.contains(out, `
    1. a footnote`)) + testing.expect(t, strings.contains(out, `class="footnote-backref"`)) + testing.expect(t, strings.contains(out, `
      `)) + testing.expect(t, strings.contains(out, "
      ")) +} + +@(test) +test_inject_footnotes_numbered_by_appearance :: proc(t: ^testing.T) { + html := "Second[^b] then first[^a]." + sn := map[string]string { + "a" = "def a", + "b" = "def b", + } + defer delete_map(sn) + mn := make(map[string]string) + defer delete_map(mn) + + out := inject_footnotes(html, sn, mn) + + // b appears first in the text → 1, a → 2 + testing.expect(t, strings.contains(out, `id="fnref-1">1`)) + testing.expect(t, strings.contains(out, `id="fnref-2">2`)) + testing.expect(t, strings.contains(out, `
    2. def b`)) + testing.expect(t, strings.contains(out, `
    3. def a`)) +} + +@(test) +test_inject_footnotes_marginnote_treated_same :: proc(t: ^testing.T) { + html := "Sidenote[^a] and marginnote[*b]." + sn := map[string]string { + "a" = "sn def", + } + defer delete_map(sn) + mn := map[string]string { + "b" = "mn def", + } + defer delete_map(mn) + + out := inject_footnotes(html, sn, mn) + + // Both get numbered as regular footnotes + testing.expect(t, strings.contains(out, `id="fnref-1">1`)) + testing.expect(t, strings.contains(out, `id="fnref-2">2`)) + testing.expect(t, strings.contains(out, `
    4. sn def`)) + testing.expect(t, strings.contains(out, `
    5. mn def`)) +} + +@(test) +test_inject_footnotes_no_defs :: proc(t: ^testing.T) { + html := "No notes here." + sn := make(map[string]string) + mn := make(map[string]string) + testing.expect(t, inject_footnotes(html, sn, mn) == html) +} + +@(test) +test_inject_footnotes_missing_def :: proc(t: ^testing.T) { + html := "Ref[^missing] end." + sn := map[string]string { + "other" = "x", + } + defer delete_map(sn) + mn := make(map[string]string) + defer delete_map(mn) + + out := inject_footnotes(html, sn, mn) + + testing.expect(t, strings.contains(out, "[^missing]")) + testing.expect(t, !strings.contains(out, "link`)) +} diff --git a/markdown/markdown.odin b/markdown/markdown.odin index 893d68e..0b210f8 100644 --- a/markdown/markdown.odin +++ b/markdown/markdown.odin @@ -3,6 +3,7 @@ package markdown import cm "vendor:commonmark" import "core:encoding/json" +import "core:log" import "core:strings" Extension :: enum { @@ -13,6 +14,7 @@ Extension :: enum { Sections, HeadingIDs, DefLists, + Footnotes, } DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists} @@ -27,7 +29,7 @@ process :: proc( side_notes := make(map[string]string) margin_notes := make(map[string]string) clean_body := body - if .Sidenotes in ext { + if .Sidenotes in ext || .Footnotes in ext { clean_body, side_notes, margin_notes = strip_definitions(body) } if .DefLists in ext { @@ -40,7 +42,9 @@ process :: proc( if .Emoji in ext { html = expand_emoji(html) } - if .Sidenotes in ext { + if .Footnotes in ext { + html = inject_footnotes(html, side_notes, margin_notes) + } else if .Sidenotes in ext { html = inject_notes(html, side_notes, margin_notes) } if .Alerts in ext { @@ -63,8 +67,11 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) { for part in strings.split(s, ",", allocator = context.temp_allocator) { name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator) e, ok := extension_from_name(name) - if !ok && name != "" { - panic("!ok") // TODO: handle this + if !ok { + if name != "" { + log.warnf("unknown extension '%s'", name) + } + continue } result += {e} } @@ -103,6 +110,8 @@ extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) { e = .HeadingIDs case "deflists": e = .DefLists + case "footnotes": + e = .Footnotes case: // Do nothing } @@ -110,3 +119,12 @@ extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) { return e, ok || e != .Emoji } +// resolve_extension_conflicts resolves mutually exclusive extensions. +// Footnotes and Sidenotes share the same [^id] syntax but render differently; +// if both are enabled (e.g. from defaults + CLI), Footnotes wins. +resolve_extension_conflicts :: proc(ext: ^bit_set[Extension]) { + if .Footnotes in ext^ && .Sidenotes in ext^ { + ext^ -= {.Sidenotes} + } +} + diff --git a/site.odin b/site.odin index 4514bb0..5b03681 100644 --- a/site.odin +++ b/site.odin @@ -243,6 +243,7 @@ site_apply_cli_flags :: proc(site: ^Site, flags: Flags) { site.markdown_extensions += md.parse_extension_list(flags.md_enable) site.markdown_extensions -= md.parse_extension_list(flags.md_disable) + md.resolve_extension_conflicts(&site.markdown_extensions) } site_allocator :: proc(site: ^Site) -> mem.Allocator { diff --git a/site/thor.json b/site/thor.json index 680fc33..dc2f8ee 100644 --- a/site/thor.json +++ b/site/thor.json @@ -1,4 +1,7 @@ { + "markdown_extensions": { + "footnotes": true + }, "params": { "author": { "name": "Spencer Brower"