65d81f745a
Footnotes now use the single canonical Pandoc/Obsidian inline form: the note body is written AT the reference as `^[body]`, and the separate `<section data-footnotes>` list is NOT emitted in markdown — it is reassembled on import. New shared module src/lib/footnote.ts. Serialize (markdown-converter.ts): a top-of-convert pre-scan builds Map<id, definition> from the footnotesList; a footnoteReference emits `^[<rendered body>]` (body paragraphs joined by a literal `\n`, real backslash-n written `\\n`, stray unbalanced `[`/`]` escaped via balanceBrackets while a balanced `[link](url)` stays intact); footnotesList/footnoteDefinition emit nothing; an ORPHAN definition (no ref) is appended at doc end as its own `^[body]` line so bodies are never lost (intentional, documented). The raw-HTML path (inlineToHtml, columns) emits `<sup data-footnote-ref data-fn-text="…">`, carrying the text at the ref there too; blockToHtml keeps the schema `<section>`/`<div>` form for a list nested in a column. Parse (markdown-to-prosemirror.ts): a `^[…]` inline extension on the dedicated marked instance BALANCES brackets with a depth counter (respecting `\`-escapes), so `^[note [a] b]` captures the full content, unbalanced `^[` fails open to literal text. A post-marked assembleFootnotes pass collects every `<sup data-fn-text>`, dedups by the EXACT body string, assigns sequential ids (fn-1, fn-2, … first-seen), builds one `<div data-footnote-def>` per unique body in a single `<section data-footnotes>`, and strips data-fn-text. No hash is used (F1): dedup keying on the exact text makes an id collision between DIFFERENT bodies impossible, while identical bodies still merge; ids are never written to markdown, so round-trips stay byte-stable, and all id assignment is local to the one call (race-free). Correctness hardening from internal review: - F2: raw user backslashes in a footnote body are doubled (`\`->`\\`) at text emission (via a per-conversion inFootnoteBody closure flag) BEFORE the serializer's own escapes (`\[ \] \= \$`) are layered on, so a body ending in `\` (Windows path, LaTeX, regex) no longer breaks the `^[…]` envelope and round-trips exactly; parseInline decodes `\\`->`\`. The old `\n`->`\\n` step is subsumed by this and removed. - N1: assembleFootnotes runs to a FIXED POINT — parseInline of a def body can spawn a nested `<sup data-fn-text>` (a legal nested footnote `^[a ^[b] c]`), so the section is attached before the loop (querySelectorAll only sees attached nodes) and the scan repeats until no pending sup remains; the dedup map persists across rounds. Nested and 3+-level footnotes now round-trip byte-stably instead of silently dropping the inner body. Bounded by MAX_FOOTNOTE_ROUNDS as a fail-open safety net. - N2: the id counter is seeded past the highest existing fn-<N> so a reused section's ids can never collide with generated ones. - A literal `^[` in prose text is escaped `^\[` so it does not become a phantom footnote on re-import (codeBlock/inline-code excluded). No backward compat: reference form `[^id]`/`[^id]: def` is not parsed (stays literal). No existing golden asserted the old footnote HTML output. Tests: new footnote.test.ts (22 cases: basic byte-stable round-trip, bracket balancing, multi-paragraph `\n`, real backslash-n, dedup both directions, NESTED + 3-level nest, F1 hash-collision pair surviving as distinct defs, F2 backslash bodies byte-stable, N2 id-seed, column data-fn-text form, orphan def, no-backward-compat, literal-`^[` prose, fail-open, empty `^[]`). package vitest: 607 passed; tsc clean. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
62 lines
2.6 KiB
TypeScript
62 lines
2.6 KiB
TypeScript
/**
|
|
* #293 canon #2: inline footnotes `^[text]`.
|
|
*
|
|
* Shared, side-effect-free helpers used by BOTH the serializer
|
|
* (markdown-converter.ts) and the importer (markdown-to-prosemirror.ts) so the
|
|
* two directions cannot drift.
|
|
*
|
|
* The canonical markdown form is Pandoc/Obsidian inline footnotes: the note body
|
|
* is written AT the reference point as `^[body]`; there is no separate
|
|
* `[^id]: …` definition line and no bottom `<section>` list in the markdown. On
|
|
* import the body is re-assembled into the schema's doc-level
|
|
* `footnotesList`/`footnoteDefinition` so the editor sees the usual three-node
|
|
* footnote model, while identical bodies MERGE to a single definition shared by
|
|
* every reference. Ids are assigned by the importer's assembleFootnotes pass
|
|
* (dedup on the EXACT body text -> sequential `fn-N`), NOT derived from a hash,
|
|
* so two DIFFERENT bodies can never collide onto one definition (F1). The id is
|
|
* never written to markdown (`^[body]` carries only text), so the round trip
|
|
* stays byte-stable regardless of the concrete id.
|
|
*/
|
|
|
|
/**
|
|
* Split an ENCODED footnote body (the inner captured between `^[` and its
|
|
* matching `]`, or the value of a `data-fn-text` attribute) into its paragraph
|
|
* markdown strings.
|
|
*
|
|
* Paragraph boundaries are the two-character literal separator `\n` (backslash +
|
|
* n); a REAL backslash-n in the body was encoded as `\\n` (an escaped backslash
|
|
* followed by n) by the serializer, so it must NOT split. The scan therefore
|
|
* treats any `\<char>` as an escaped pair kept verbatim (so `\\` `n` stays a
|
|
* literal backslash-then-n and the trailing `n` is plain), and only an
|
|
* UNescaped `\n` is a separator. Every other backslash escape (`\=`, `\$`,
|
|
* `\[`, …) is preserved untouched so the per-paragraph `parseInline` decodes it.
|
|
*/
|
|
export function splitFootnoteParagraphs(encoded: string): string[] {
|
|
const paragraphs: string[] = [];
|
|
let current = "";
|
|
let i = 0;
|
|
while (i < encoded.length) {
|
|
const c = encoded[i];
|
|
if (c === "\\" && i + 1 < encoded.length) {
|
|
const next = encoded[i + 1];
|
|
if (next === "n") {
|
|
// Unescaped backslash-n: a paragraph separator.
|
|
paragraphs.push(current);
|
|
current = "";
|
|
i += 2;
|
|
continue;
|
|
}
|
|
// Any other escaped pair (including `\\`) is kept verbatim; consuming
|
|
// BOTH chars is what makes an encoded real `\n` (`\\n`) safe — the `\\`
|
|
// pair is taken here, leaving the following `n` as an ordinary literal.
|
|
current += c + next;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
current += c;
|
|
i++;
|
|
}
|
|
paragraphs.push(current);
|
|
return paragraphs;
|
|
}
|