From dea4180031c0b5a210620470d584e511e8ddc089 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:05:54 -0400 Subject: [PATCH] feat(md): Added "Heading IDs" extension. --- AGENTS.md | 3 +- TODOS.md | 4 +- markdown/heading_ids.odin | 196 +++++++++++++++++++++++++++++++++ markdown/heading_ids_test.odin | 96 ++++++++++++++++ markdown/markdown.odin | 10 +- 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 markdown/heading_ids.odin create mode 100644 markdown/heading_ids_test.odin diff --git a/AGENTS.md b/AGENTS.md index 140bc96..9716b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,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`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts`). Configurable via: +**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). Configurable via: - `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }` - CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive. @@ -203,6 +203,7 @@ raw markdown → md.inject_notes (if .Sidenotes — post-cmark) → md.inject_alerts (if .Alerts — post-cmark) → md.highlight_code (if .Highlight — post-cmark) + → md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections) → md.wrap_sections (if .Sections — post-cmark) ``` diff --git a/TODOS.md b/TODOS.md index 43e313a..a73021a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -7,6 +7,7 @@ - [ ] Load grammars dynamically - [ ] consider adding a limit to the context stack in mustache. - [ ] better diagnostics for syntax errors in treesitter. +- [x] Add heading ids as a default on extension. ## Performance @@ -28,6 +29,8 @@ - pass each code block to the treesitter queue - continue working on the page, - `await` the highlighted code. +- [ ] can markdown extensions run in parallel? +- [ ] enforce MAX_SLUG_LENGTH ## Memory Management @@ -48,7 +51,6 @@ ## Markdown - [ ] Add overloads for every extension - accept ^strings.Builder. - [ ] Add conventional (Hugo style) footnotes option. -- [ ] Add heading ids as a default on extension. - [ ] Add opt-in deflist support. - [ ] Decide if lambdas actually provide any value. diff --git a/markdown/heading_ids.odin b/markdown/heading_ids.odin new file mode 100644 index 0000000..a19cb12 --- /dev/null +++ b/markdown/heading_ids.odin @@ -0,0 +1,196 @@ +package markdown + +import "core:fmt" +import "core:log" +import "core:strings" + +// A hypothetical maximum slug length. +// May be enforced in a later version (for performance) +MAX_SLUG_LENGTH :: #config(MAX_SLUG_LENGTH, 255) + +inject_heading_ids :: proc(html: string, allocator := context.allocator) -> string { + sb := strings.builder_make_len_cap(0, len(html) + 256, allocator) + defer strings.builder_destroy(&sb) + + seen := make(map[string]bool, 8, context.temp_allocator) + empty_count := 0 + + pos := 0 + for { + h_start := find_heading_open(html, pos) + if h_start < 0 { + strings.write_string(&sb, html[pos:]) + break + } + + if h_start > pos { + strings.write_string(&sb, html[pos:h_start]) + } + + level := int(html[h_start + 2] - '0') + + close_buf: [5]u8 + close_buf[0] = '<'; close_buf[1] = '/'; close_buf[2] = 'h' + close_buf[3] = html[h_start + 2] + close_buf[4] = '>' + close_tag := string(close_buf[:]) + + close_rel := strings.index(html[h_start:], close_tag) + if close_rel < 0 { + strings.write_string(&sb, html[h_start:]) + break + } + + open_tag_end := h_start + 4 + close_start := h_start + close_rel + close_end := close_start + 5 + + inner_html := html[open_tag_end:close_start] + + text := extract_plain_text(inner_html, context.temp_allocator) + slug := slugify(text) + if len(slug) == 0 { + empty_count += 1 + slug = fmt.tprintf("section-%d", empty_count) + } + slug = make_unique(slug, &seen) + + fmt.sbprintf(&sb, ``, level, slug) + strings.write_string(&sb, inner_html) + strings.write_string(&sb, close_tag) + + pos = close_end + } + + return strings.to_string(sb) +} + +find_heading_open :: proc(html: string, start: int) -> int { + pos := start + for pos < len(html) - 3 { + if html[pos] == '<' && + html[pos + 1] == 'h' && + html[pos + 2] >= '1' && + html[pos + 2] <= '6' && + html[pos + 3] == '>' { + return pos + } + pos += 1 + } + return -1 +} + +extract_plain_text :: proc(html: string, allocator := context.temp_allocator) -> string { + sb := strings.builder_make(allocator) + defer strings.builder_destroy(&sb) + + in_tag := false + i := 0 + for i < len(html) { + c := html[i] + if in_tag { + if c == '>' { + in_tag = false + } + i += 1 + continue + } + if c == '<' { + in_tag = true + i += 1 + continue + } + if c == '&' { + semi := strings.index(html[i:], ";") + if semi > 0 && semi <= 5 { + entity := html[i:i + semi + 1] + replacement := "" + switch entity { + case "&": + replacement = "&" + case "<": + replacement = "<" + case ">": + replacement = ">" + case """: + replacement = "\"" + case "'", "'": + replacement = "'" + case: + replacement = "" + } + if replacement != "" { + strings.write_string(&sb, replacement) + i += semi + 1 + continue + } + } + strings.write_byte(&sb, '&') + i += 1 + continue + } + strings.write_byte(&sb, c) + i += 1 + } + + return strings.to_string(sb) +} + +slugify :: proc(text: string, allocator := context.temp_allocator) -> string { + sb := strings.builder_make_len_cap(0, 255, allocator) + defer strings.builder_destroy(&sb) + + has_hyphen := false + + for i in 0 ..< len(text) { + c := text[i] + if c >= 'A' && c <= 'Z' { + strings.write_byte(&sb, c + 32) + has_hyphen = false + } else if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + strings.write_byte(&sb, c) + has_hyphen = false + } else { + if !has_hyphen { + strings.write_byte(&sb, '-') + has_hyphen = true + } + } + } + + result := strings.to_string(sb) + if len(result) > 0 && result[len(result) - 1] == '-' { + result = result[:len(result) - 1] + } + + if len(result) > MAX_SLUG_LENGTH { + log.warnf( + "Long slug detected (%d > %d). " + + "This may break in later versions of thor. " + + "slug=%s input=\"%s\"", + len(result), + MAX_SLUG_LENGTH, + result, + text, + ) + } + return result +} + +make_unique :: proc(slug: string, seen: ^map[string]bool) -> string { + if _, ok := seen^[slug]; !ok { + seen^[slug] = true + return slug + } + n := 1 + for { + candidate := fmt.tprintf("%s-%d", slug, n) + if _, ok := seen^[candidate]; !ok { + seen^[candidate] = true + return candidate + } + n += 1 + } + return "" +} + diff --git a/markdown/heading_ids_test.odin b/markdown/heading_ids_test.odin new file mode 100644 index 0000000..0f6c46b --- /dev/null +++ b/markdown/heading_ids_test.odin @@ -0,0 +1,96 @@ +#+test +package markdown + +import "core:testing" + +@(test) +test_heading_simple :: proc(t: ^testing.T) { + result := inject_heading_ids("

Hello World

") + testing.expect_value(t, result, `

Hello World

`) +} + +@(test) +test_heading_dedup :: proc(t: ^testing.T) { + result := inject_heading_ids("

Intro

text

Intro

") + testing.expect_value(t, result, `

Intro

text

Intro

`) +} + +@(test) +test_heading_nested_html :: proc(t: ^testing.T) { + result := inject_heading_ids("

With code

") + testing.expect_value(t, result, `

With code

`) +} + +@(test) +test_heading_entities :: proc(t: ^testing.T) { + result := inject_heading_ids("

Cats & Dogs

") + testing.expect_value(t, result, `

Cats & Dogs

`) +} + +@(test) +test_heading_punctuation :: proc(t: ^testing.T) { + result := inject_heading_ids("

Hello, World!

") + testing.expect_value(t, result, `

Hello, World!

`) +} + +@(test) +test_heading_all_levels :: proc(t: ^testing.T) { + result := inject_heading_ids("

A

B

C

D

E
F
") + testing.expect_value(t, result, + `

A

` + + `

B

` + + `

C

` + + `

D

` + + `
E
` + + `
F
`, + ) +} + +@(test) +test_heading_preserves_text :: proc(t: ^testing.T) { + result := inject_heading_ids("

It is bold

") + testing.expect_value(t, result, `

It is bold

`) +} + +@(test) +test_heading_non_heading_tags :: proc(t: ^testing.T) { + input := "
Nav

Title


" + result := inject_heading_ids(input) + testing.expect_value(t, result, `
Nav

Title


`) +} + +@(test) +test_heading_existing_attrs_skipped :: proc(t: ^testing.T) { + input := `

Title

` + result := inject_heading_ids(input) + testing.expect_value(t, result, input) +} + +@(test) +test_heading_empty :: proc(t: ^testing.T) { + result := inject_heading_ids("

") + testing.expect_value(t, result, `

`) +} + +@(test) +test_heading_with_surrounding_content :: proc(t: ^testing.T) { + input := "

Before

Title

After

" + result := inject_heading_ids(input) + testing.expect_value(t, result, `

Before

Title

After

`) +} + +@(test) +test_heading_numbers :: proc(t: ^testing.T) { + result := inject_heading_ids("

Chapter 12

") + testing.expect_value(t, result, `

Chapter 12

`) +} + +@(test) +test_heading_triple_dedup :: proc(t: ^testing.T) { + result := inject_heading_ids("

Foo

Foo

Foo

") + testing.expect_value(t, result, + `

Foo

` + + `

Foo

` + + `

Foo

`, + ) +} diff --git a/markdown/markdown.odin b/markdown/markdown.odin index 771a64e..1cb6887 100644 --- a/markdown/markdown.odin +++ b/markdown/markdown.odin @@ -11,9 +11,10 @@ Extension :: enum { Alerts, Highlight, Sections, + HeadingIDs, } -DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts} +DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs} process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string { side_notes := make(map[string]string) @@ -35,6 +36,9 @@ process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> str if .Highlight in ext { html = highlight_code(html, file_path) } + if .HeadingIDs in ext { + html = inject_heading_ids(html) + } if .Sections in ext { html = wrap_sections(html) } @@ -56,6 +60,8 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) { result += {.Highlight} case "sections": result += {.Sections} + case "heading_ids": + result += {.HeadingIDs} } } return result @@ -77,6 +83,8 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) { if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}} case "sections": if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}} + case "heading_ids": + if enabled {ext^ += {.HeadingIDs}} else {ext^ -= {.HeadingIDs}} } } }