mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added 'footnotes' markdown extension.
This commit is contained in:
+99
-3
@@ -174,21 +174,22 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
note: string
|
||||
defer delete(note)
|
||||
if is_margin {
|
||||
note = fmt.aprintf(
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
} else {
|
||||
note = fmt.aprintf(
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
}
|
||||
strings.write_string(&parts, note)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
@@ -196,3 +197,98 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
return strings.to_string(parts)
|
||||
}
|
||||
|
||||
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers
|
||||
// them sequentially by order of appearance, and replaces them with <sup> links.
|
||||
// Appends a <section class="footnotes"><ol> 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, `<sup><a href="#fn-%d" id="fnref-%d">%d</a></sup>`, num, num, num)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
|
||||
if len(ordered_ids) > 0 {
|
||||
strings.write_string(&parts, "\n<section class=\"footnotes\">\n<hr>\n<ol>\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,
|
||||
`<li id="fn-%d">%s <a href="#fnref-%d" class="footnote-backref">↩︎</a></li>` +
|
||||
"\n",
|
||||
num,
|
||||
def_html,
|
||||
num,
|
||||
)
|
||||
}
|
||||
strings.write_string(&parts, "</ol>\n</section>")
|
||||
}
|
||||
|
||||
return strings.to_string(parts)
|
||||
}
|
||||
|
||||
|
||||
@@ -119,3 +119,102 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) {
|
||||
testing.expect(t, strings.contains(out, "[^missing]"))
|
||||
testing.expect(t, strings.contains(out, "[*missing]"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_basic :: proc(t: ^testing.T) {
|
||||
html := "Text[^a] end."
|
||||
sn := map[string]string {
|
||||
"a" = "a footnote",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
testing.expect(t, strings.contains(out, `<sup><a href="#fn-1" id="fnref-1">1</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-1">a footnote`))
|
||||
testing.expect(t, strings.contains(out, `class="footnote-backref"`))
|
||||
testing.expect(t, strings.contains(out, `<section class="footnotes">`))
|
||||
testing.expect(t, strings.contains(out, "</section>"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_numbered_by_appearance :: proc(t: ^testing.T) {
|
||||
html := "Second[^b] then first[^a]."
|
||||
sn := map[string]string {
|
||||
"a" = "def a",
|
||||
"b" = "def b",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
// b appears first in the text → 1, a → 2
|
||||
testing.expect(t, strings.contains(out, `id="fnref-1">1</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-1">def b`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-2">def a`))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_marginnote_treated_same :: proc(t: ^testing.T) {
|
||||
html := "Sidenote[^a] and marginnote[*b]."
|
||||
sn := map[string]string {
|
||||
"a" = "sn def",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := map[string]string {
|
||||
"b" = "mn def",
|
||||
}
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
// Both get numbered as regular footnotes
|
||||
testing.expect(t, strings.contains(out, `id="fnref-1">1</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-1">sn def`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-2">mn def`))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_no_defs :: proc(t: ^testing.T) {
|
||||
html := "No notes here."
|
||||
sn := make(map[string]string)
|
||||
mn := make(map[string]string)
|
||||
testing.expect(t, inject_footnotes(html, sn, mn) == html)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_missing_def :: proc(t: ^testing.T) {
|
||||
html := "Ref[^missing] end."
|
||||
sn := map[string]string {
|
||||
"other" = "x",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
testing.expect(t, strings.contains(out, "[^missing]"))
|
||||
testing.expect(t, !strings.contains(out, "<section"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_inline_markdown :: proc(t: ^testing.T) {
|
||||
html := "Text[^a] end."
|
||||
sn := map[string]string {
|
||||
"a" = "see [link](http://example.com) here",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
testing.expect(t, strings.contains(out, `<a href="http://example.com">link</a>`))
|
||||
}
|
||||
|
||||
+22
-4
@@ -3,6 +3,7 @@ package markdown
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
Extension :: enum {
|
||||
@@ -13,6 +14,7 @@ Extension :: enum {
|
||||
Sections,
|
||||
HeadingIDs,
|
||||
DefLists,
|
||||
Footnotes,
|
||||
}
|
||||
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
|
||||
@@ -27,7 +29,7 @@ process :: proc(
|
||||
side_notes := make(map[string]string)
|
||||
margin_notes := make(map[string]string)
|
||||
clean_body := body
|
||||
if .Sidenotes in ext {
|
||||
if .Sidenotes in ext || .Footnotes in ext {
|
||||
clean_body, side_notes, margin_notes = strip_definitions(body)
|
||||
}
|
||||
if .DefLists in ext {
|
||||
@@ -40,7 +42,9 @@ process :: proc(
|
||||
if .Emoji in ext {
|
||||
html = expand_emoji(html)
|
||||
}
|
||||
if .Sidenotes in ext {
|
||||
if .Footnotes in ext {
|
||||
html = inject_footnotes(html, side_notes, margin_notes)
|
||||
} else if .Sidenotes in ext {
|
||||
html = inject_notes(html, side_notes, margin_notes)
|
||||
}
|
||||
if .Alerts in ext {
|
||||
@@ -63,8 +67,11 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
for part in strings.split(s, ",", allocator = context.temp_allocator) {
|
||||
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
|
||||
e, ok := extension_from_name(name)
|
||||
if !ok && name != "" {
|
||||
panic("!ok") // TODO: handle this
|
||||
if !ok {
|
||||
if name != "" {
|
||||
log.warnf("unknown extension '%s'", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
result += {e}
|
||||
}
|
||||
@@ -103,6 +110,8 @@ extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
|
||||
e = .HeadingIDs
|
||||
case "deflists":
|
||||
e = .DefLists
|
||||
case "footnotes":
|
||||
e = .Footnotes
|
||||
case:
|
||||
// Do nothing
|
||||
}
|
||||
@@ -110,3 +119,12 @@ extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
|
||||
return e, ok || e != .Emoji
|
||||
}
|
||||
|
||||
// resolve_extension_conflicts resolves mutually exclusive extensions.
|
||||
// Footnotes and Sidenotes share the same [^id] syntax but render differently;
|
||||
// if both are enabled (e.g. from defaults + CLI), Footnotes wins.
|
||||
resolve_extension_conflicts :: proc(ext: ^bit_set[Extension]) {
|
||||
if .Footnotes in ext^ && .Sidenotes in ext^ {
|
||||
ext^ -= {.Sidenotes}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user