diff --git a/content.odin b/content.odin index 782502c..bbfc97c 100644 --- a/content.odin +++ b/content.odin @@ -173,7 +173,9 @@ load_page :: proc( if strings.has_suffix(file_path, ".html") { page.body_html = strings.clone(body) } else { - page.body_html = cm.markdown_to_html_from_string(body, {.Unsafe}) + clean_body, defs := strip_definitions(body) + html := cm.markdown_to_html_from_string(clean_body, {.Unsafe}) + page.body_html = inject_sidenotes(html, defs) } switch page_type { diff --git a/footnotes.odin b/footnotes.odin new file mode 100644 index 0000000..d31af10 --- /dev/null +++ b/footnotes.odin @@ -0,0 +1,160 @@ +package main + +import cm "vendor:commonmark" + +import "core:fmt" +import "core:strings" + +// strip_definitions scans markdown text for footnote definitions ([^id]: text), +// removes them, and returns the cleaned text plus a map of id→definition. +// Handles multi-line definitions with indented continuation lines. +strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string]string) { + defs = make(map[string]string) + + lines := strings.split(body, "\n") + output_lines: [dynamic]string + defer delete(output_lines) + + i := 0 + for i < len(lines) { + line := lines[i] + + id, def_text, is_def := parse_def_line(line) + if !is_def { + append(&output_lines, line) + i += 1 + continue + } + + // Collect definition text (initial line + multi-line continuations) + def_parts: [dynamic]string + if def_text != "" { + append(&def_parts, def_text) + } + + i += 1 + for i < len(lines) { + next := lines[i] + // Stop at blank lines + if len(next) == 0 { + break + } + // Stop at new footnote definitions + _, _, is_new_def := parse_def_line(next) + if is_new_def { + break + } + // Include as continuation (trim indented lines) + if is_indented(next) { + append(&def_parts, strings.trim_left(next, " \t")) + } else { + append(&def_parts, next) + } + i += 1 + } + + defs[id] = strings.join(def_parts[:], "\n") + delete(def_parts) + } + + clean_body = strings.join(output_lines[:], "\n") + return +} + +// parse_def_line checks if a line is a footnote definition: [^id]: text +parse_def_line :: proc(line: string) -> (id: string, text: string, ok: bool) { + if len(line) < 5 || line[0] != '[' || line[1] != '^' { + return + } + + close := strings.index(line[2:], "]") + if close < 0 { + return + } + close += 2 + + if close + 1 >= len(line) || line[close + 1] != ':' { + return + } + + id = line[2:close] + text = strings.trim_left(line[close + 2:], " \t") + ok = true + return +} + +is_indented :: proc(line: string) -> bool { + if len(line) == 0 { + return false + } + return line[0] == ' ' || line[0] == '\t' +} + +// inject_sidenotes finds [^id] references in rendered HTML and replaces them +// with sidenote markup. Each definition is rendered through cmark separately. +inject_sidenotes :: proc(html: string, defs: map[string]string) -> string { + if len(defs) == 0 { + return html + } + + parts: [dynamic]string + defer delete(parts) + + remaining := html + + for { + pos := strings.index(remaining, "[^") + if pos < 0 { + append(&parts, remaining) + break + } + + // Append text before [^ + append(&parts, remaining[:pos]) + + close := strings.index(remaining[pos + 2:], "]") + if close < 0 { + append(&parts, remaining[pos:]) + break + } + + id := remaining[pos + 2 : pos + 2 + close] + ref_end := pos + 2 + close + 1 + + def_text, found := defs[id] + if !found { + // No definition found, leave as literal text + append(&parts, remaining[pos:ref_end]) + remaining = remaining[ref_end:] + continue + } + + // Render definition through cmark for markdown support + def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe}) + def_html = strip_p_tags(def_html) + + sidenote := fmt.aprintf( + `%s`, + id, + id, + def_html, + ) + append(&parts, sidenote) + + remaining = remaining[ref_end:] + } + + return strings.join(parts[:], "") +} + +// strip_p_tags removes surrounding

if the HTML is a single paragraph. +strip_p_tags :: proc(html: string) -> string { + s := html + if len(s) > 0 && s[len(s) - 1] == '\n' { + s = s[:len(s) - 1] + } + if strings.has_prefix(s, "

") && strings.has_suffix(s, "

") { + return s[3:len(s) - 4] + } + return s +} diff --git a/render.odin b/render.odin index a0625fa..ceb1100 100644 --- a/render.odin +++ b/render.odin @@ -148,6 +148,7 @@ render_chrome :: proc(page_title: string, body: string) -> string { %s + @@ -157,6 +158,8 @@ render_chrome :: proc(page_title: string, body: string) -> string {

Proudly built with Odin

© 2026

+ + `,