From 1767be0db4ad34e0bc02f599b77405431ef7bb15 Mon Sep 17 00:00:00 2001 From: juprodh Date: Mon, 25 May 2026 15:49:23 +0800 Subject: [PATCH] Add markdown footnotes --- bun.lock | 3 +++ package.json | 1 + public/editor/preview.ts | 2 ++ public/style.css | 22 +++++++++++++++++++++ src/lib/markdown.ts | 2 ++ src/lib/markdown/sanitize.ts | 1 + src/types.d.ts | 7 +++++++ tests/lib/markdown.test.ts | 38 ++++++++++++++++++++++++++++++++++++ 8 files changed, 76 insertions(+) diff --git a/bun.lock b/bun.lock index 7b617a7..0a5a1ad 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "fflate": "^0.8.3", "katex": "^0.16.47", "markdown-it": "^14.1.1", + "markdown-it-footnote": "^4.0.0", "markdown-it-mark": "^4.0.0", "sanitize-html": "^2.17.4", "sharp": "^0.34.5", @@ -587,6 +588,8 @@ "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "markdown-it-footnote": ["markdown-it-footnote@4.0.0", "", {}, "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ=="], + "markdown-it-mark": ["markdown-it-mark@4.0.0", "", {}, "sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], diff --git a/package.json b/package.json index f874101..9a296e0 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "fflate": "^0.8.3", "katex": "^0.16.47", "markdown-it": "^14.1.1", + "markdown-it-footnote": "^4.0.0", "markdown-it-mark": "^4.0.0", "sanitize-html": "^2.17.4", "sharp": "^0.34.5" diff --git a/public/editor/preview.ts b/public/editor/preview.ts index 3e37d0d..d9ccbe4 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -1,5 +1,6 @@ import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; +import footnotePlugin from "markdown-it-footnote"; import markPlugin from "markdown-it-mark"; import { commentPlugin } from "../../src/lib/markdown/comment-plugin.ts"; import { katexPlugin } from "../../src/lib/markdown/katex-plugin.ts"; @@ -8,6 +9,7 @@ import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; const md = new MarkdownIt({ html: false, linkify: true }); md.use(commentPlugin); md.use(wikilinkPlugin); +md.use(footnotePlugin); md.use(markPlugin); md.use(katexPlugin, { throwOnError: false }); diff --git a/public/style.css b/public/style.css index 8e7a056..3cde34a 100644 --- a/public/style.css +++ b/public/style.css @@ -7,6 +7,28 @@ line-height: 1.2; } +/* Footnotes */ +.footnotes { + font-size: 0.875rem; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.footnotes-sep { + display: none; +} + +.footnote-ref a, +.footnote-backref { + text-decoration: none; +} + +.footnote-backref { + margin-left: 0.25rem; +} + /* ==highlight== markup */ mark { background-color: var(--accent-soft); diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index d57bc4a..5520e23 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,4 +1,5 @@ import MarkdownIt from "markdown-it"; +import footnotePlugin from "markdown-it-footnote"; import markPlugin from "markdown-it-mark"; import { commentPlugin } from "./markdown/comment-plugin.ts"; import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts"; @@ -62,6 +63,7 @@ function youtubePlugin(mdi: MarkdownIt): void { md.use(commentPlugin); md.use(wikilinkPlugin); md.use(headingAnchorPlugin); +md.use(footnotePlugin); md.use(markPlugin); md.use(vizPlugin); md.use(katexPlugin, { throwOnError: false }); diff --git a/src/lib/markdown/sanitize.ts b/src/lib/markdown/sanitize.ts index 3c9b3ae..bbc0ded 100644 --- a/src/lib/markdown/sanitize.ts +++ b/src/lib/markdown/sanitize.ts @@ -51,6 +51,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { "td", "div", "span", + "section", "iframe", // katex emits these for math rendering "math", diff --git a/src/types.d.ts b/src/types.d.ts index f04e0d7..4420e8c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -4,3 +4,10 @@ declare module "markdown-it-mark" { const plugin: MarkdownIt.PluginSimple; export default plugin; } + +declare module "markdown-it-footnote" { + import type MarkdownIt from "markdown-it"; + + const plugin: MarkdownIt.PluginSimple; + export default plugin; +} diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index 8a989b0..5e77e40 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -227,6 +227,44 @@ describe("highlights", () => { }); }); +describe("footnotes", () => { + test("renders reference + definition", () => { + const { html } = renderMarkdown("Sentence.[^1]\n\n[^1]: Note text."); + expect(html).toContain('class="footnote-ref"'); + expect(html).toContain('class="footnotes"'); + expect(html).toContain("Note text"); + }); + + test("renders inline footnote (^[...])", () => { + const { html } = renderMarkdown("Inline.^[Inline note]"); + expect(html).toContain('class="footnote-ref"'); + expect(html).toContain("Inline note"); + }); + + test("supports multi-paragraph footnotes", () => { + const src = "Ref.[^a]\n\n[^a]: First para.\n\n Second para."; + const { html } = renderMarkdown(src); + expect(html).toContain("First para"); + expect(html).toContain("Second para"); + }); + + test("does not collide with wikilinks", () => { + const { html } = renderMarkdown( + "See [[other-note]] and ref.[^1]\n\n[^1]: foo", + "wiki", + "alice.test", + ); + expect(html).toContain('class="wikilink"'); + expect(html).toContain('class="footnote-ref"'); + }); + + test("sanitizer preserves
", () => { + const { html } = renderMarkdown("x[^1]\n\n[^1]: y"); + expect(html).toContain(" { test("inline comment is stripped", () => { const { html } = renderMarkdown("Hello %%hidden%% world"); -- 2.51.2