mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat(md): Added "Heading IDs" extension.
This commit is contained in:
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 "&":
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -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 & Dogs</h2>")
|
||||
testing.expect_value(t, result, `<h2 id="cats-dogs">Cats & 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>`,
|
||||
)
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user