feat: Added 'footnotes' markdown extension.

This commit is contained in:
Spencer Brower
2026-08-04 14:34:13 -04:00
parent fde54c1f94
commit 695548bada
7 changed files with 234 additions and 13 deletions
+6 -4
View File
@@ -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 | | `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` | | `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 `<sup>` links + `<section class="footnotes"><ol>` 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.) | | | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
| | `emoji.odin` | `expand_emoji``:shortcode:` → unicode emoji | | | `emoji.odin` | `expand_emoji``:shortcode:` → unicode emoji |
| | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers | | | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` 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`. **`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, ... }` - `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive. - 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 raw markdown
→ md.strip_definitions (if .Sidenotes — pre-cmark) → md.strip_definitions (if .Sidenotes || .Footnotes — pre-cmark)
→ md.convert_deflists (if .DefLists — pre-cmark) → md.convert_deflists (if .DefLists — pre-cmark)
→ cmark markdown_to_html (Unsafe mode for HTML passthrough) → cmark markdown_to_html (Unsafe mode for HTML passthrough)
→ md.expand_emoji (if .Emoji — post-cmark) → 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.inject_alerts (if .Alerts — post-cmark)
→ md.highlight_code (if .Highlight — post-cmark) → md.highlight_code (if .Highlight — post-cmark)
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections) → 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)` 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)`). - **`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. - **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 ## TODO
+4 -2
View File
@@ -13,6 +13,8 @@
- [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)? - [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)?
- [ ] Improve diagnostics - [ ] Improve diagnostics
- [x] keep track of every error and don't report them more than once. - [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. - [ ] `*` make sure the frontmatter parser has good diagnostics.
- [ ] fix the diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TODOS.yaml) - [ ] fix the diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TODOS.yaml)
- [ ] only report format errors once. - [ ] only report format errors once.
@@ -106,8 +108,8 @@
## Markdown ## Markdown
- [ ] Add overloads for every extension - accept ^strings.Builder. - [ ] Add overloads for every extension - accept ^strings.Builder.
- [ ] Add conventional (Hugo style) footnotes option. - [x] Add conventional (Hugo style) footnotes option.
- [ ] Add opt-in deflist support. - [x] Add opt-in deflist support.
- [x] Decide if lambdas actually provide any value. - [x] Decide if lambdas actually provide any value.
- [ ] add tables extension - [ ] add tables extension
- [x] Table of contents support. - [x] Table of contents support.
+99 -3
View File
@@ -174,21 +174,22 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
note: string note: string
defer delete(note) defer delete(note)
if is_margin { if is_margin {
note = fmt.aprintf( fmt.sbprintf(
&parts,
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`, `<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
id, id,
id, id,
def_html, def_html,
) )
} else { } else {
note = fmt.aprintf( fmt.sbprintf(
&parts,
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`, `<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
id, id,
id, id,
def_html, def_html,
) )
} }
strings.write_string(&parts, note)
remaining = remaining[ref_end:] 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) 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 <sup> links.
// Appends a <section class="footnotes"><ol> 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, `<sup><a href="#fn-%d" id="fnref-%d">%d</a></sup>`, num, num, num)
remaining = remaining[ref_end:]
}
if len(ordered_ids) > 0 {
strings.write_string(&parts, "\n<section class=\"footnotes\">\n<hr>\n<ol>\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,
`<li id="fn-%d">%s <a href="#fnref-%d" class="footnote-backref">↩︎</a></li>` +
"\n",
num,
def_html,
num,
)
}
strings.write_string(&parts, "</ol>\n</section>")
}
return strings.to_string(parts)
}
+99
View File
@@ -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]"))
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, `<sup><a href="#fn-1" id="fnref-1">1</a></sup>`))
testing.expect(t, strings.contains(out, `<li id="fn-1">a footnote`))
testing.expect(t, strings.contains(out, `class="footnote-backref"`))
testing.expect(t, strings.contains(out, `<section class="footnotes">`))
testing.expect(t, strings.contains(out, "</section>"))
}
@(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</a></sup>`))
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
testing.expect(t, strings.contains(out, `<li id="fn-1">def b`))
testing.expect(t, strings.contains(out, `<li id="fn-2">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</a></sup>`))
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
testing.expect(t, strings.contains(out, `<li id="fn-1">sn def`))
testing.expect(t, strings.contains(out, `<li id="fn-2">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, "<section"))
}
@(test)
test_inject_footnotes_inline_markdown :: proc(t: ^testing.T) {
html := "Text[^a] end."
sn := map[string]string {
"a" = "see [link](http://example.com) here",
}
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, `<a href="http://example.com">link</a>`))
}
+22 -4
View File
@@ -3,6 +3,7 @@ package markdown
import cm "vendor:commonmark" import cm "vendor:commonmark"
import "core:encoding/json" import "core:encoding/json"
import "core:log"
import "core:strings" import "core:strings"
Extension :: enum { Extension :: enum {
@@ -13,6 +14,7 @@ Extension :: enum {
Sections, Sections,
HeadingIDs, HeadingIDs,
DefLists, DefLists,
Footnotes,
} }
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists} DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
@@ -27,7 +29,7 @@ process :: proc(
side_notes := make(map[string]string) side_notes := make(map[string]string)
margin_notes := make(map[string]string) margin_notes := make(map[string]string)
clean_body := body clean_body := body
if .Sidenotes in ext { if .Sidenotes in ext || .Footnotes in ext {
clean_body, side_notes, margin_notes = strip_definitions(body) clean_body, side_notes, margin_notes = strip_definitions(body)
} }
if .DefLists in ext { if .DefLists in ext {
@@ -40,7 +42,9 @@ process :: proc(
if .Emoji in ext { if .Emoji in ext {
html = expand_emoji(html) 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) html = inject_notes(html, side_notes, margin_notes)
} }
if .Alerts in ext { 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) { for part in strings.split(s, ",", allocator = context.temp_allocator) {
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator) name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
e, ok := extension_from_name(name) e, ok := extension_from_name(name)
if !ok && name != "" { if !ok {
panic("!ok") // TODO: handle this if name != "" {
log.warnf("unknown extension '%s'", name)
}
continue
} }
result += {e} result += {e}
} }
@@ -103,6 +110,8 @@ extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
e = .HeadingIDs e = .HeadingIDs
case "deflists": case "deflists":
e = .DefLists e = .DefLists
case "footnotes":
e = .Footnotes
case: case:
// Do nothing // Do nothing
} }
@@ -110,3 +119,12 @@ extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
return e, ok || e != .Emoji 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}
}
}
+1
View File
@@ -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_enable)
site.markdown_extensions -= md.parse_extension_list(flags.md_disable) site.markdown_extensions -= md.parse_extension_list(flags.md_disable)
md.resolve_extension_conflicts(&site.markdown_extensions)
} }
site_allocator :: proc(site: ^Site) -> mem.Allocator { site_allocator :: proc(site: ^Site) -> mem.Allocator {
+3
View File
@@ -1,4 +1,7 @@
{ {
"markdown_extensions": {
"footnotes": true
},
"params": { "params": {
"author": { "author": {
"name": "Spencer Brower" "name": "Spencer Brower"