feat(md): Added "Heading IDs" extension.

This commit is contained in:
Spencer Brower
2026-07-28 10:05:54 -04:00
parent 838c55f73c
commit dea4180031
5 changed files with 306 additions and 3 deletions
+2 -1
View File
@@ -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)
```
+3 -1
View File
@@ -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.
+196
View File
@@ -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, `<h%d id="%s">`, 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 "&amp;":
replacement = "&"
case "&lt;":
replacement = "<"
case "&gt;":
replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
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 ""
}
+96
View File
@@ -0,0 +1,96 @@
#+test
package markdown
import "core:testing"
@(test)
test_heading_simple :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Hello World</h2>")
testing.expect_value(t, result, `<h2 id="hello-world">Hello World</h2>`)
}
@(test)
test_heading_dedup :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
testing.expect_value(t, result, `<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`)
}
@(test)
test_heading_nested_html :: proc(t: ^testing.T) {
result := inject_heading_ids("<h3>With <code>code</code></h3>")
testing.expect_value(t, result, `<h3 id="with-code">With <code>code</code></h3>`)
}
@(test)
test_heading_entities :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Cats &amp; Dogs</h2>")
testing.expect_value(t, result, `<h2 id="cats-dogs">Cats &amp; Dogs</h2>`)
}
@(test)
test_heading_punctuation :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Hello, World!</h2>")
testing.expect_value(t, result, `<h2 id="hello-world">Hello, World!</h2>`)
}
@(test)
test_heading_all_levels :: proc(t: ^testing.T) {
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
testing.expect_value(t, result,
`<h1 id="a">A</h1>` +
`<h2 id="b">B</h2>` +
`<h3 id="c">C</h3>` +
`<h4 id="d">D</h4>` +
`<h5 id="e">E</h5>` +
`<h6 id="f">F</h6>`,
)
}
@(test)
test_heading_preserves_text :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>It is <em>bold</em></h2>")
testing.expect_value(t, result, `<h2 id="it-is-bold">It is <em>bold</em></h2>`)
}
@(test)
test_heading_non_heading_tags :: proc(t: ^testing.T) {
input := "<header>Nav</header><h2>Title</h2><hr>"
result := inject_heading_ids(input)
testing.expect_value(t, result, `<header>Nav</header><h2 id="title">Title</h2><hr>`)
}
@(test)
test_heading_existing_attrs_skipped :: proc(t: ^testing.T) {
input := `<h2 class="foo">Title</h2>`
result := inject_heading_ids(input)
testing.expect_value(t, result, input)
}
@(test)
test_heading_empty :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2></h2><h3></h3>")
testing.expect_value(t, result, `<h2 id="section-1"></h2><h3 id="section-2"></h3>`)
}
@(test)
test_heading_with_surrounding_content :: proc(t: ^testing.T) {
input := "<p>Before</p><h2>Title</h2><p>After</p>"
result := inject_heading_ids(input)
testing.expect_value(t, result, `<p>Before</p><h2 id="title">Title</h2><p>After</p>`)
}
@(test)
test_heading_numbers :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Chapter 12</h2>")
testing.expect_value(t, result, `<h2 id="chapter-12">Chapter 12</h2>`)
}
@(test)
test_heading_triple_dedup :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
testing.expect_value(t, result,
`<h2 id="foo">Foo</h2>` +
`<h2 id="foo-1">Foo</h2>` +
`<h2 id="foo-2">Foo</h2>`,
)
}
+9 -1
View File
@@ -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}}
}
}
}