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:
@@ -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