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 `
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")
+ 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,
+ `
\n