Skip to content

Commit 1cc3e07

Browse files
committed
feat: make markdown footnotes navigable
Markdown footnotes were rendered by maud as bare paragraphs with no anchors, and markers had no href, so they were not navigable. Render the footnote definitions ourselves into an anchored <section><ol> with back-links (bypassing maud's bare append by clearing the document's footnotes before rendering the body), and make the default footnote component a clickable link to the definition. The footnote <section><ol> markup is now shared between the markdown and djot renderers via a new internal footnote module, so both formats produce the same structure. The djot marker is routed back through the (now clickable) footnote component.
1 parent 1b4d885 commit 1cc3e07

6 files changed

Lines changed: 186 additions & 59 deletions

File tree

examples/simple_blog/blog/getting-started/index.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,16 @@ A post directory contains:
4343

4444
Run `gleam run` to build your site. The generated HTML, feeds, and sitemap will be written to the output directory.
4545

46+
## Footnotes
47+
48+
Blogatto supports footnotes in Markdown posts[^obsidian]. References are
49+
rendered as clickable links to the definitions at the bottom of the post[^nav],
50+
and each definition links back to where it was referenced[^backref].
51+
4652
Happy blogging!
53+
54+
[^obsidian]: Footnotes use the Obsidian syntax: `[^label]` for the reference and `[^label]: ...` for the definition.
55+
56+
[^nav]: Click a footnote number to jump to its definition.
57+
58+
[^backref]: Use the back-link to return to the reference.

src/blogatto/config/post.gleam

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121

2222
import blogatto/config/post/code
2323
import blogatto/post.{type Post, type PostMetadata}
24+
import gleam/int
2425
import gleam/list
2526
import gleam/option.{type Option, None}
27+
import lustre/attribute
2628
import lustre/element.{type Element}
29+
import lustre/element/html
2730
import maud/components as maud_components
2831

2932
/// Configuration for discovering and rendering blog posts.
@@ -179,7 +182,23 @@ pub fn default_options() -> Options {
179182
/// Return the default components, rendering each markdown element as its
180183
/// corresponding HTML element without additional attributes or styling.
181184
pub fn default_components() -> Components(msg) {
182-
from_maud_components(maud_components.default())
185+
Components(
186+
..from_maud_components(maud_components.default()),
187+
footnote: default_footnote,
188+
)
189+
}
190+
191+
/// Default footnote reference marker: a superscript link to the footnote
192+
/// definition (`#fn-N`), anchored (`id="fnref-N"`) so the definition can link
193+
/// back. `number` is the footnote number; `children` is the marker text.
194+
fn default_footnote(number: Int, children: List(Element(msg))) -> Element(msg) {
195+
let num = int.to_string(number)
196+
html.sup([], [
197+
html.a(
198+
[attribute.id("fnref-" <> num), attribute.href("#fn-" <> num)],
199+
children,
200+
),
201+
])
183202
}
184203

185204
/// Set the `Components` used for rendering posts.

src/blogatto/internal/builder/post/djot.gleam

Lines changed: 13 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//// approach so other source formats can be added as sibling submodules.
66

77
import blogatto/config/post.{type Components, type PostConfig}
8+
import blogatto/internal/builder/post/footnote
89
import frontmatter as fm_extractor
910
import gleam/dict.{type Dict}
1011
import gleam/int
@@ -63,47 +64,14 @@ fn render_footnotes(
6364
footnotes: Dict(String, List(jot.Container)),
6465
ctx: Context(msg),
6566
) -> List(Element(msg)) {
66-
let items =
67-
ordered_refs
68-
|> list.filter_map(fn(reference) {
69-
use containers <- result.map(dict.get(footnotes, reference))
70-
let number =
71-
ctx.footnote_numbers |> dict.get(reference) |> result.unwrap(0)
72-
render_footnote_item(number, containers, ctx)
73-
})
74-
case items {
75-
[] -> []
76-
_ -> [
77-
element.element("section", [attribute.class("footnotes")], [
78-
element.element("ol", [], items),
79-
]),
80-
]
81-
}
82-
}
83-
84-
/// Render a single footnote definition as an `<li id="fn-N">` containing the
85-
/// definition body followed by a back-link to its reference site.
86-
fn render_footnote_item(
87-
number: Int,
88-
containers: List(jot.Container),
89-
ctx: Context(msg),
90-
) -> Element(msg) {
91-
let num = int.to_string(number)
92-
let body = list.map(containers, render_container(_, ctx, jot.Loose))
93-
let backlink =
94-
element.element(
95-
"a",
96-
[
97-
attribute.href("#fnref-" <> num),
98-
attribute.class("footnote-backref"),
99-
],
100-
[element.text("↩")],
101-
)
102-
element.element(
103-
"li",
104-
[attribute.id("fn-" <> num)],
105-
list.append(body, [backlink]),
106-
)
67+
ordered_refs
68+
|> list.filter_map(fn(reference) {
69+
use containers <- result.map(dict.get(footnotes, reference))
70+
let number = ctx.footnote_numbers |> dict.get(reference) |> result.unwrap(0)
71+
let body = list.map(containers, render_container(_, ctx, jot.Loose))
72+
footnote.item(number, body)
73+
})
74+
|> footnote.section
10775
}
10876

10977
/// Assign a footnote number to each reference in order of first appearance.
@@ -316,18 +284,10 @@ fn render_inline(inline: jot.Inline, ctx: Context(msg)) -> Element(msg) {
316284
ctx.footnote_numbers
317285
|> dict.get(reference)
318286
|> result.unwrap(0)
319-
let num = int.to_string(number)
320-
// Render the marker directly: it must carry both an `id` (the back-link
321-
// target) and an `href` to the definition, which the `footnote`
322-
// component (number + children only) cannot express. The visible text is
323-
// the number, not the raw reference key (jot keeps its leading `^`).
324-
element.element("sup", [], [
325-
element.element(
326-
"a",
327-
[attribute.id("fnref-" <> num), attribute.href("#fn-" <> num)],
328-
[element.text(num)],
329-
),
330-
])
287+
// The visible marker text is the number, not the raw reference key (jot
288+
// keeps its leading `^`). The default `footnote` component renders it as
289+
// a link to the definition.
290+
ctx.components.footnote(number, [element.text(int.to_string(number))])
331291
}
332292
jot.Code(content: text) -> ctx.components.code(None, [element.text(text)])
333293
jot.MathInline(content: latex) ->
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//// Shared footnote markup helpers for the post renderers.
2+
////
3+
//// Both the markdown and djot renderers emit the footnote definitions as a
4+
//// `<section class="footnotes"><ol>` of anchored `<li id="fn-N">` items, each
5+
//// ending with a back-link to its reference site. Keeping the markup here
6+
//// guarantees both source formats produce the same structure.
7+
8+
import gleam/int
9+
import gleam/list
10+
import lustre/attribute
11+
import lustre/element.{type Element}
12+
13+
/// Wrap the rendered footnote definition `items` in a `<section><ol>`. Returns
14+
/// an empty list when there are no footnotes, so callers can append it
15+
/// unconditionally.
16+
pub fn section(items: List(Element(msg))) -> List(Element(msg)) {
17+
case items {
18+
[] -> []
19+
_ -> [
20+
element.element("section", [attribute.class("footnotes")], [
21+
element.element("ol", [], items),
22+
]),
23+
]
24+
}
25+
}
26+
27+
/// Render a single footnote definition as an `<li id="fn-N">` containing the
28+
/// already-rendered definition `body` followed by a back-link to its reference
29+
/// site (`#fnref-N`).
30+
pub fn item(number: Int, body: List(Element(msg))) -> Element(msg) {
31+
let num = int.to_string(number)
32+
let backlink =
33+
element.element(
34+
"a",
35+
[attribute.href("#fnref-" <> num), attribute.class("footnote-backref")],
36+
[element.text("↩")],
37+
)
38+
element.element(
39+
"li",
40+
[attribute.id("fn-" <> num)],
41+
list.append(body, [backlink]),
42+
)
43+
}

src/blogatto/internal/builder/post/markdown.gleam

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,66 @@
1010

1111
import blogatto/config/post.{type PostConfig}
1212
import blogatto/config/post/code
13+
import blogatto/internal/builder/post/footnote
14+
import gleam/dict
15+
import gleam/int
1316
import gleam/list
1417
import gleam/option.{type Option}
1518
import gleam/string
1619
import lustre/element.{type Element}
1720
import maud
1821
import maud/components as maud_components
22+
import mork
1923
import mork/document as mork_document
2024

2125
/// File extension recognized by the markdown source format.
2226
pub const extension: String = "md"
2327

2428
/// Render markdown `content` to Lustre elements using the post configuration.
29+
///
30+
/// The document body is rendered first, then the footnote definitions are
31+
/// rendered separately into an anchored `<section><ol>` (with back-links) so
32+
/// footnotes are navigable, matching the djot renderer. maud's own footnote
33+
/// append (bare paragraphs, no anchors) is bypassed by clearing the
34+
/// document's footnotes before rendering the body.
2535
pub fn render(config: PostConfig(msg), content: String) -> List(Element(msg)) {
26-
maud.render_markdown(
27-
content,
28-
mork_options(config.options),
29-
to_maud_components(config),
30-
)
36+
let components = to_maud_components(config)
37+
let document =
38+
mork.parse_with_options(
39+
options: mork_options(config.options),
40+
input: content,
41+
)
42+
let body =
43+
maud.render_document(
44+
mork_document.Document(..document, footnotes: dict.new()),
45+
components,
46+
)
47+
list.append(body, render_footnotes(document, components))
48+
}
49+
50+
/// Render the markdown footnote definitions into an anchored `<section><ol>`,
51+
/// ordered by footnote number. Each definition's blocks are rendered through
52+
/// maud so they share the configured components.
53+
fn render_footnotes(
54+
document: mork_document.Document,
55+
components: maud_components.Components(msg),
56+
) -> List(Element(msg)) {
57+
document.footnotes
58+
|> dict.values
59+
|> list.sort(fn(a, b) { int.compare(a.num, b.num) })
60+
|> list.map(fn(data) {
61+
let body =
62+
maud.render_document(
63+
mork_document.Document(
64+
..document,
65+
blocks: data.blocks,
66+
footnotes: dict.new(),
67+
),
68+
components,
69+
)
70+
footnote.item(data.num, body)
71+
})
72+
|> footnote.section
3173
}
3274

3375
/// Convert blogatto `Components` to maud `Components`, wrapping the `code`
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import blogatto/config/post as post_cfg
2+
import blogatto/internal/builder/post/markdown
3+
import gleam/string
4+
import gleeunit/should
5+
import lustre/element
6+
7+
fn render(content: String) -> String {
8+
let config = post_cfg.default()
9+
markdown.render(config, content)
10+
|> element.fragment
11+
|> element.to_string
12+
}
13+
14+
// --- footnotes ---
15+
16+
pub fn footnote_definition_content_is_rendered_test() {
17+
let html = render("Body[^a].\n\n[^a]: A footnote.\n")
18+
html |> string.contains("A footnote.") |> should.be_true
19+
}
20+
21+
pub fn footnote_marker_shows_number_test() {
22+
let html = render("Body[^note].\n\n[^note]: A footnote.\n")
23+
html |> string.contains(">1</a>") |> should.be_true
24+
}
25+
26+
pub fn footnote_marker_links_to_definition_test() {
27+
let html = render("Body[^a].\n\n[^a]: A footnote.\n")
28+
// Marker is a clickable link to the definition.
29+
html |> string.contains("href=\"#fn-1\"") |> should.be_true
30+
// Definition is anchored so the marker can jump to it.
31+
html |> string.contains("id=\"fn-1\"") |> should.be_true
32+
// Definition links back to the marker.
33+
html |> string.contains("href=\"#fnref-1\"") |> should.be_true
34+
html |> string.contains("id=\"fnref-1\"") |> should.be_true
35+
}
36+
37+
pub fn footnotes_numbered_sequentially_test() {
38+
let html = render("One[^a] two[^b].\n\n[^a]: Alpha.\n\n[^b]: Bravo.\n")
39+
let assert Ok(one_index) = string.split_once(html, ">1</a>")
40+
let assert Ok(two_index) = string.split_once(html, ">2</a>")
41+
// Marker "1" must appear before marker "2" in document order.
42+
{ string.length(one_index.0) < string.length(two_index.0) }
43+
|> should.be_true
44+
}
45+
46+
pub fn footnote_definition_links_back_to_correct_marker_test() {
47+
let html = render("One[^a] two[^b].\n\n[^a]: Alpha.\n\n[^b]: Bravo.\n")
48+
// Definition 1 is anchored as fn-1 and holds the first footnote body.
49+
let assert Ok(#(_, after_fn1)) = string.split_once(html, "id=\"fn-1\"")
50+
after_fn1 |> string.contains("Alpha.") |> should.be_true
51+
}

0 commit comments

Comments
 (0)