Files
gitmost/packages/prosemirror-markdown/src/lib/footnote.ts
T
claude code agent 227 65d81f745a feat(prosemirror-markdown): inline footnotes ^[text] (#293 canon #2)
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>
2026-07-04 10:31:00 +03:00

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;
}