From 4d17befb0d5e5110bfb8fd209c341e867036861a Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 11:39:00 +0300 Subject: [PATCH] feat(editor): footnotes (reference + definitions model) Adds footnotes: a superscript marker in the text linked to an editable definition in a Footnotes section at the end of the page, with auto-numbering and a read-only hover popover. Chose the reference+definitions model (3 plain nodes) over an inline atom with a sub-editor specifically for collaboration safety. editor-ext (packages/editor-ext/src/lib/footnote/): - footnoteReference (inline atom, id), footnotesList (block, last child), footnoteDefinition (paragraph+, id). renderHTML emits sup[data-footnote-ref] / section[data-footnotes] / div[data-footnote-def]; parse-rule priority makes the empty reference win over the Superscript mark (else it is dropped on the server save). - numbering: a decoration-only plugin (pure function of doc order) -> every client computes identical numbers, no document mutation, Yjs-safe. - sync plugin: single-pass, always SYNC_META-tagged and skipping remote txns (terminates, no loop), idempotent; canonicalizes to one trailing footnotesList (merging duplicates), creates missing definitions, drops orphans, and coexists with TrailingNode. Disabled in read-only. - commands setFootnote (one tx: reference + definition at the matching index + focus) / removeFootnote (cascade, one undo) / scrollTo*. slash /footnote. client: superscript NodeView + floating-ui read-only popover; bottom-list and definition NodeViews; registered in mainExtensions. server: the three nodes registered in tiptapExtensions so collab/save/export keep them. Round-trip regression spec guards the Superscript parse-priority. markdown: turndown/marked round-trip to pandoc/GFM [^id] (+ a code-fence guard so footnote-like lines inside code blocks are not extracted). MCP mirror: schema + markdown-converter + commentsToFootnotes rewritten to real footnote nodes + diff marker counting; NUL sentinels written as \u0000 escapes. v2 follow-ups (per plan): definition reordering on reference move, id-collision regeneration on paste, multiple references to one footnote. Implements docs/footnotes-plan.md (variant B). Co-Authored-By: Claude Opus 4.8 --- .../public/locales/ru-RU/translation.json | 7 + .../footnote/footnote-definition-view.tsx | 47 ++ .../footnote/footnote-reference-view.tsx | 145 +++++ .../components/footnote/footnote.module.css | 106 ++++ .../footnote/footnotes-list-view.tsx | 20 + .../components/slash-menu/menu-items.ts | 9 + .../features/editor/extensions/extensions.ts | 19 + .../features/editor/readonly-page-editor.tsx | 13 +- .../src/collaboration/collaboration.util.ts | 6 + .../footnote-superscript-roundtrip.spec.ts | 61 ++ packages/editor-ext/package.json | 3 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/footnote/footnote-definition.ts | 72 +++ .../lib/footnote/footnote-markdown.test.ts | 56 ++ .../src/lib/footnote/footnote-numbering.ts | 75 +++ .../src/lib/footnote/footnote-reference.ts | 328 +++++++++++ .../src/lib/footnote/footnote-sync.ts | 197 +++++++ .../src/lib/footnote/footnote-util.ts | 77 +++ .../src/lib/footnote/footnote.test.ts | 536 ++++++++++++++++++ .../src/lib/footnote/footnotes-list.ts | 56 ++ packages/editor-ext/src/lib/footnote/index.ts | 6 + .../src/lib/markdown/utils/footnote.marked.ts | 115 ++++ .../src/lib/markdown/utils/marked.utils.ts | 24 +- .../src/lib/markdown/utils/turndown.utils.ts | 89 ++- packages/editor-ext/tsconfig.json | 3 +- packages/editor-ext/vitest.config.ts | 8 + packages/mcp/build/lib/collaboration.js | 67 ++- packages/mcp/build/lib/diff.js | 31 +- packages/mcp/build/lib/docmost-schema.js | 75 +++ packages/mcp/build/lib/markdown-converter.js | 21 + packages/mcp/build/lib/transforms.js | 176 ++++-- packages/mcp/src/lib/collaboration.ts | 77 ++- packages/mcp/src/lib/diff.ts | 31 +- packages/mcp/src/lib/docmost-schema.ts | 80 +++ packages/mcp/src/lib/markdown-converter.ts | 24 + packages/mcp/src/lib/transforms.ts | 192 +++++-- packages/mcp/test/unit/footnotes.test.mjs | 120 ++++ packages/mcp/test/unit/transforms.test.mjs | 84 ++- 38 files changed, 2906 insertions(+), 151 deletions(-) create mode 100644 apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx create mode 100644 apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx create mode 100644 apps/client/src/features/editor/components/footnote/footnote.module.css create mode 100644 apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx create mode 100644 apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-definition.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-numbering.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-reference.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-sync.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-util.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote.test.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnotes-list.ts create mode 100644 packages/editor-ext/src/lib/footnote/index.ts create mode 100644 packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts create mode 100644 packages/editor-ext/vitest.config.ts create mode 100644 packages/mcp/test/unit/footnotes.test.mjs diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 25ff2530..414e75b8 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -391,6 +391,13 @@ "Toggle block": "Сворачиваемый блок", "Callout": "Выноска", "Insert callout notice.": "Вставить выноску с сообщением.", + "Footnote": "Сноска", + "Insert a footnote reference.": "Вставить ссылку на сноску.", + "Footnotes": "Примечания", + "Footnote {{number}}": "Сноска {{number}}", + "Go to footnote": "Перейти к сноске", + "Back to reference": "Вернуться к ссылке", + "Empty footnote": "Пустая сноска", "Math inline": "Строчная формула", "Insert inline math equation.": "Вставить математическое выражение в строку.", "Math block": "Блок формулы", diff --git a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx new file mode 100644 index 00000000..b5aa5486 --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx @@ -0,0 +1,47 @@ +import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; +import { computeFootnoteNumbers } from "@docmost/editor-ext"; +import classes from "./footnote.module.css"; + +/** + * NodeView for a single footnote definition: a decorative number marker, the + * editable content (NodeViewContent), and a "↩" back-link to its reference. + * The number is derived from the document (not stored). + */ +export default function FootnoteDefinitionView(props: NodeViewProps) { + const { node, editor } = props; + const { t } = useTranslation(); + const id = node.attrs.id as string; + + const numbers = computeFootnoteNumbers(editor.state.doc); + const number = numbers.get(id) ?? "?"; + + const handleBack = (e: React.MouseEvent) => { + e.preventDefault(); + editor.commands.scrollToReference(id); + }; + + return ( + + + {number}. + + + + ↩ + + + ); +} diff --git a/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx new file mode 100644 index 00000000..c75766da --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; +import { + FOOTNOTE_DEFINITION_NAME, + computeFootnoteNumbers, +} from "@docmost/editor-ext"; +import { ActionIcon } from "@mantine/core"; +import { IconArrowDown } from "@tabler/icons-react"; +import classes from "./footnote.module.css"; + +/** + * Read the plain text of the footnote definition with `id` directly from the + * editor state. No sub-editor: the popover is read-only. + */ +function getDefinitionText(editor: NodeViewProps["editor"], id: string): string { + let text = ""; + editor.state.doc.descendants((node) => { + if ( + node.type.name === FOOTNOTE_DEFINITION_NAME && + node.attrs.id === id + ) { + text = node.textContent; + return false; + } + return undefined; + }); + return text; +} + +export default function FootnoteReferenceView(props: NodeViewProps) { + const { node, editor, selected } = props; + const { t } = useTranslation(); + const id = node.attrs.id as string; + + const anchorRef = useRef(null); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + // Number is derived (not stored) — recompute from the current doc. + const numbers = computeFootnoteNumbers(editor.state.doc); + const number = numbers.get(id) ?? "?"; + const defText = open ? getDefinitionText(editor, id) : ""; + + const position = useCallback(() => { + const anchor = anchorRef.current; + const popup = popoverRef.current; + if (!anchor || !popup) return; + computePosition(anchor, popup, { + placement: "top", + middleware: [offset(6), flip(), shift({ padding: 8 })], + }).then(({ x, y }) => { + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + }); + }, []); + + useEffect(() => { + if (!open) return; + const anchor = anchorRef.current; + const popup = popoverRef.current; + if (!anchor || !popup) return; + + const cleanup = autoUpdate(anchor, popup, position); + + const onPointerDown = (e: PointerEvent) => { + if ( + popup.contains(e.target as Node) || + anchor.contains(e.target as Node) + ) { + return; + } + setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown, true); + + return () => { + cleanup(); + document.removeEventListener("pointerdown", onPointerDown, true); + }; + }, [open, position]); + + const handleGoTo = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + editor.commands.scrollToFootnote(id); + }; + + return ( + + (anchorRef.current = el)} + data-footnote-ref="" + data-id={id} + className={`${classes.reference} ${selected ? classes.selected : ""}`} + onMouseEnter={() => setOpen(true)} + onClick={(e) => { + e.preventDefault(); + setOpen((v) => !v); + }} + // The decoration sets --footnote-number; provide a fallback inline. + style={{ ["--footnote-number" as any]: `"${number}"` }} + aria-label={t("Footnote {{number}}", { number })} + role="button" + /> + {open && + createPortal( +
setOpen(false)} + > +
+ + {t("Footnote {{number}}", { number })} + + + + +
+
+ {defText || t("Empty footnote")} +
+
, + document.body, + )} +
+ ); +} diff --git a/apps/client/src/features/editor/components/footnote/footnote.module.css b/apps/client/src/features/editor/components/footnote/footnote.module.css new file mode 100644 index 00000000..11c391bd --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnote.module.css @@ -0,0 +1,106 @@ +/* Superscript reference marker. The visible number comes from the numbering + plugin decoration which sets the --footnote-number CSS variable. */ +.reference { + cursor: pointer; + color: var(--mantine-color-blue-6); + font-weight: 500; + vertical-align: super; + font-size: 0.75em; + line-height: 0; + user-select: none; + white-space: nowrap; +} + +.reference::after { + content: var(--footnote-number, ""); +} + +.reference:hover { + text-decoration: underline; +} + +.reference.selected { + background-color: var(--mantine-color-blue-1); + border-radius: 2px; +} + +/* Read-only popover shown on hover/click of a reference. */ +.popover { + position: absolute; + z-index: 1000; + max-width: 360px; + padding: var(--mantine-spacing-sm); + background: var(--mantine-color-body); + color: var(--mantine-color-default-color); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + box-shadow: var(--mantine-shadow-md); + font-size: var(--mantine-font-size-sm); + line-height: 1.4; +} + +.popoverHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-xs); + margin-bottom: 4px; +} + +.popoverNumber { + font-weight: 600; + color: var(--mantine-color-dimmed); +} + +.popoverBody { + white-space: pre-wrap; + word-break: break-word; +} + +/* Bottom footnotes container. */ +.list { + margin-top: var(--mantine-spacing-lg); + padding-top: var(--mantine-spacing-md); + border-top: 1px solid var(--mantine-color-default-border); +} + +.listHeading { + font-weight: 600; + font-size: var(--mantine-font-size-sm); + color: var(--mantine-color-dimmed); + margin-bottom: var(--mantine-spacing-xs); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.definition { + display: flex; + align-items: flex-start; + gap: var(--mantine-spacing-xs); + padding: 2px 0; +} + +.definitionMarker { + flex: 0 0 auto; + min-width: 1.5em; + font-variant-numeric: tabular-nums; + color: var(--mantine-color-dimmed); + user-select: none; +} + +.definitionContent { + flex: 1 1 auto; + min-width: 0; +} + +.backLink { + flex: 0 0 auto; + cursor: pointer; + color: var(--mantine-color-blue-6); + user-select: none; + font-size: 0.9em; +} + +.backLink:hover { + text-decoration: underline; +} diff --git a/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx b/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx new file mode 100644 index 00000000..7b2eb51b --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx @@ -0,0 +1,20 @@ +import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; +import classes from "./footnote.module.css"; + +/** + * NodeView for the bottom footnotes container. Renders a visual separator and a + * localized heading, then the editable list of definitions via NodeViewContent. + */ +export default function FootnotesListView(_props: NodeViewProps) { + const { t } = useTranslation(); + + return ( + +
+
{t("Footnotes")}
+
+ +
+ ); +} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 7f856755..12a5639c 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -28,6 +28,7 @@ import { IconTag, IconMoodSmile, IconRotate2, + IconSuperscript, } from "@tabler/icons-react"; import { CommandProps, @@ -366,6 +367,14 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setDetails().run(), }, + { + title: "Footnote", + description: "Insert a footnote reference.", + searchTerms: ["footnote", "note", "reference", "сноска", "примечание"], + icon: IconSuperscript, + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).setFootnote().run(), + }, { title: "Callout", description: "Insert callout notice.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 87c7b9e5..9c78ffb0 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -61,6 +61,9 @@ import { TransclusionSource, TransclusionReference, TableView, + FootnoteReference, + FootnotesList, + FootnoteDefinition, } from "@docmost/editor-ext"; import { randomElement, @@ -91,6 +94,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; +import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx"; +import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx"; +import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx"; import { common, createLowlight } from "lowlight"; import plaintext from "highlight.js/lib/languages/plaintext"; import powershell from "highlight.js/lib/languages/powershell"; @@ -381,6 +387,19 @@ export const mainExtensions = [ TransclusionReference.configure({ view: TransclusionReferenceView, }), + FootnoteReference.configure({ + view: FootnoteReferenceView, + // Skip orphan-cleanup on remote/collaboration steps so collaborating + // clients never fight over footnote integrity (deterministic numbering + // decorations handle the rest). + isRemoteTransaction: (tr: any) => isChangeOrigin(tr), + }), + FootnotesList.configure({ + view: FootnotesListView, + }), + FootnoteDefinition.configure({ + view: FootnoteDefinitionView, + }), MarkdownClipboard.configure({ transformPastedText: true, }), diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index cd4878a9..e2912893 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({ }, []); const extensions = useMemo(() => { - const filteredExtensions = mainExtensions.filter( - (ext) => ext.name !== "uniqueID", - ); + const filteredExtensions = mainExtensions + .filter((ext) => ext.name !== "uniqueID") + // Read-only must only DECORATE footnotes (numbering), never mutate the + // doc. Disable the footnote sync/integrity plugin so a programmatic + // setContent on a doc the viewer can't edit is never rewritten. + .map((ext) => + ext.name === "footnoteReference" + ? ext.configure({ enableSync: false }) + : ext, + ); return [ ...filteredExtensions, diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 554aa43b..0d91d676 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -44,6 +44,9 @@ import { htmlToMarkdown, TransclusionSource, TransclusionReference, + FootnoteReference, + FootnotesList, + FootnoteDefinition, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -109,6 +112,9 @@ export const tiptapExtensions = [ Status, TransclusionSource, TransclusionReference, + FootnoteReference, + FootnotesList, + FootnoteDefinition, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts b/apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts new file mode 100644 index 00000000..c496ed66 --- /dev/null +++ b/apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts @@ -0,0 +1,61 @@ +import { htmlToJson, jsonToHtml } from './collaboration.util'; + +const findFirst = (json: any, type: string): any | undefined => { + if (!json || typeof json !== 'object') return undefined; + if (json.type === type) return json; + if (Array.isArray(json.content)) { + for (const child of json.content) { + const found = findFirst(child, type); + if (found) return found; + } + } + return undefined; +}; + +/** + * Guards the fragile parse-priority approach that lets a `footnoteReference` + * NODE win over the `Superscript` MARK for `` elements. In the server + * `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes, + * so without the priority guard a `` would be parsed as + * an (empty) superscript mark and the footnote reference would be lost. + */ +describe('footnote reference vs superscript mark (server schema round-trip)', () => { + const HTML = + '

Water' + + '' + + ' here.

' + + '
' + + '

First note.

' + + '
'; + + it('parses into a footnoteReference NODE (not a superscript mark)', () => { + const json = htmlToJson(HTML); + + const ref = findFirst(json, 'footnoteReference'); + expect(ref).toBeDefined(); + expect(ref.attrs.id).toBe('fn1'); + + // It must NOT have been swallowed as a superscript mark on text. + const superscriptText = JSON.stringify(json).includes('"superscript"'); + expect(superscriptText).toBe(false); + + // The matching definition survives too. + const def = findFirst(json, 'footnoteDefinition'); + expect(def).toBeDefined(); + expect(def.attrs.id).toBe('fn1'); + }); + + it('round-trips an empty footnoteReference back to ', () => { + const json = htmlToJson(HTML); + const html = jsonToHtml(json); + + expect(html).toContain('data-footnote-ref'); + expect(html).toContain('data-id="fn1"'); + + // And a second parse still yields the node (stable round-trip). + const json2 = htmlToJson(html); + const ref2 = findFirst(json2, 'footnoteReference'); + expect(ref2).toBeDefined(); + expect(ref2.attrs.id).toBe('fn1'); + }); +}); diff --git a/packages/editor-ext/package.json b/packages/editor-ext/package.json index 23ddcaff..3ada7a59 100644 --- a/packages/editor-ext/package.json +++ b/packages/editor-ext/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "build": "tsc --build", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "vitest run" }, "main": "dist/index.js", "module": "./src/index.ts", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 003d2288..c629c904 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -33,4 +33,5 @@ export * from "./lib/status"; export * from "./lib/pdf"; export * from "./lib/page-break"; export * from "./lib/resizable-nodeview"; +export * from "./lib/footnote"; diff --git a/packages/editor-ext/src/lib/footnote/footnote-definition.ts b/packages/editor-ext/src/lib/footnote/footnote-definition.ts new file mode 100644 index 00000000..819adb70 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-definition.ts @@ -0,0 +1,72 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { FOOTNOTE_DEFINITION_NAME } from "./footnote-util"; + +export interface FootnoteDefinitionOptions { + HTMLAttributes: Record; + view: any; +} + +/** + * A single footnote definition: an editable block (paragraphs only, no nested + * footnotes) keyed by `id` to its reference. Lives only inside `footnotesList`. + */ +export const FootnoteDefinition = Node.create({ + name: FOOTNOTE_DEFINITION_NAME, + + // paragraph+ keeps definitions simple. Note this does NOT block nested + // footnote references on its own: a footnoteReference is inline and the + // paragraphs here accept inline content, so the schema would permit one. + // Nested references are instead prevented by the setFootnote command and the + // sync plugin (which refuse to create/keep a reference inside a definition). + content: "paragraph+", + defining: true, + isolating: true, + selectable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute("data-id"), + renderHTML: (attributes) => { + if (!attributes.id) return {}; + return { "data-id": attributes.id }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "div[data-footnote-def]", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-footnote-def": "", class: "footnote-def" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addNodeView() { + if (!this.options.view) return null; + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts new file mode 100644 index 00000000..a6f3d4ab --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { htmlToMarkdown } from "../markdown/utils/turndown.utils"; +import { markdownToHtml } from "../markdown/utils/marked.utils"; + +// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div). +const HTML = + `

Water and clay.

` + + `
` + + `

First note.

` + + `

Second note.

` + + `
`; + +describe("footnote markdown round-trip", () => { + it("HTML -> Markdown produces pandoc footnote syntax", () => { + const md = htmlToMarkdown(HTML); + expect(md).toContain("[^fn1]"); + expect(md).toContain("[^fn2]"); + expect(md).toContain("[^fn1]: First note."); + expect(md).toContain("[^fn2]: Second note."); + }); + + it("Markdown -> HTML rebuilds the footnote nodes' HTML", async () => { + const md = htmlToMarkdown(HTML); + const html = await markdownToHtml(md); + expect(html).toContain('data-footnote-ref data-id="fn1"'); + expect(html).toContain('data-footnote-ref data-id="fn2"'); + expect(html).toContain("data-footnotes"); + expect(html).toContain('data-footnote-def data-id="fn1"'); + expect(html).toContain("First note."); + expect(html).toContain("Second note."); + }); + + it("preserves a [^id]: line shown inside a fenced code block (not a definition)", async () => { + // A document that DOCUMENTS footnote syntax inside a code fence. The + // `[^demo]: ...` line is example text, not a real definition, and must + // survive the Markdown -> HTML conversion verbatim. + const md = [ + "Here is how footnotes look:", + "", + "```markdown", + "Some text[^demo]", + "", + "[^demo]: this is the definition", + "```", + "", + "End of doc.", + ].join("\n"); + + const html = await markdownToHtml(md); + // The example definition line is kept inside the rendered code block. + expect(html).toContain("[^demo]: this is the definition"); + // It did NOT get pulled out into a real footnotes section. + expect(html).not.toContain("data-footnotes"); + expect(html).not.toContain("data-footnote-def"); + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-numbering.ts b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts new file mode 100644 index 00000000..f93a3b08 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts @@ -0,0 +1,75 @@ +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { + FOOTNOTE_DEFINITION_NAME, + FOOTNOTE_REFERENCE_NAME, + computeFootnoteNumbers, +} from "./footnote-util"; + +export const footnoteNumberingPluginKey = new PluginKey("footnoteNumbering"); + +/** + * Build the decoration set for footnote numbers. Pure function of the document: + * walk references in document order, assign 1-based numbers, then attach a + * node decoration (carrying the number via a CSS variable + data attribute) to + * every reference and to every matching definition. Because it is deterministic + * from the document alone, all collaborating clients compute identical numbers + * with no document mutation. + */ +export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet { + const numbers = computeFootnoteNumbers(doc); + const decorations: Decoration[] = []; + + doc.descendants((node, pos) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) { + const num = numbers.get(node.attrs.id); + if (num != null) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + "data-footnote-number": String(num), + style: `--footnote-number: "${num}";`, + }), + ); + } + } + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + const num = numbers.get(node.attrs.id); + if (num != null) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + "data-footnote-number": String(num), + style: `--footnote-number: "${num}";`, + }), + ); + } + } + }); + + return DecorationSet.create(doc, decorations); +} + +/** + * ProseMirror plugin that renders footnote numbers as decorations. It never + * mutates the document (safe in read-only / share and in collaboration) — it + * only recomputes decorations from the current doc on each transaction. + */ +export function footnoteNumberingPlugin(): Plugin { + return new Plugin({ + key: footnoteNumberingPluginKey, + state: { + init(_, { doc }) { + return buildFootnoteDecorations(doc); + }, + apply(tr, old) { + if (!tr.docChanged) return old; + return buildFootnoteDecorations(tr.doc); + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +} diff --git a/packages/editor-ext/src/lib/footnote/footnote-reference.ts b/packages/editor-ext/src/lib/footnote/footnote-reference.ts new file mode 100644 index 00000000..90f5e109 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-reference.ts @@ -0,0 +1,328 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { TextSelection, Transaction } from "@tiptap/pm/state"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { + FOOTNOTE_DEFINITION_NAME, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, + generateFootnoteId, +} from "./footnote-util"; +import { footnoteNumberingPlugin } from "./footnote-numbering"; +import { footnoteSyncPlugin } from "./footnote-sync"; + +export interface FootnoteReferenceOptions { + HTMLAttributes: Record; + view: any; + /** + * Optional predicate identifying remote/collaboration transactions so the + * sync plugin skips them (orphan cleanup must run only on local changes). + */ + isRemoteTransaction?: (tr: Transaction) => boolean; + /** + * When false, the footnote sync/integrity plugin is fully disabled — it never + * appends a transaction. Numbering decorations stay active. Set this in + * read-only / share editors so a viewer's doc is decorated (numbered) but + * never mutated (e.g. by a programmatic setContent). Defaults to true. + */ + enableSync?: boolean; +} + +declare module "@tiptap/core" { + interface Commands { + footnote: { + /** + * Insert a footnote reference at the cursor and create the matching + * (empty) definition in the bottom footnotes list, in one transaction. + */ + setFootnote: () => ReturnType; + /** + * Remove a footnote reference and cascade-delete its definition (one + * transaction so a single undo restores both). + */ + removeFootnote: (id: string) => ReturnType; + /** Scroll to (and focus) a footnote definition by id. */ + scrollToFootnote: (id: string) => ReturnType; + /** Scroll to (and select) a footnote reference by id. */ + scrollToReference: (id: string) => ReturnType; + }; + } +} + +/** + * Inline atom that marks a footnote reference in the body text. It holds only + * an `id` linking it to its `footnoteDefinition`; the visible number is NOT + * stored — it is rendered by the numbering plugin as a decoration (see + * footnote-numbering.ts). Modeled on mention.ts (inline atom). + * + * The reference is forbidden inside code blocks and inside footnote definitions + * (no nested footnotes); those restrictions are enforced by the `setFootnote` + * command and the sync plugin rather than by schema content expressions, since + * an inline group node cannot express "not inside X" declaratively. + */ +export const FootnoteReference = Node.create({ + name: FOOTNOTE_REFERENCE_NAME, + + // Higher than the default (100) so its parse rule is considered before the + // Superscript mark's rule. + priority: 101, + + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + isRemoteTransaction: undefined, + enableSync: true, + }; + }, + + addProseMirrorPlugins() { + const plugins = [footnoteNumberingPlugin()]; + // Numbering always runs (decoration-only). The sync/integrity plugin is + // skipped entirely when sync is disabled (read-only / share) so the viewer's + // doc is never mutated. + if (this.options.enableSync !== false) { + plugins.push(footnoteSyncPlugin(this.options.isRemoteTransaction)); + } + return plugins; + }, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute("data-id"), + renderHTML: (attributes) => { + if (!attributes.id) return {}; + return { "data-id": attributes.id }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + // High priority so the Superscript mark (which also matches ) does + // not claim a footnote reference and drop it as empty content. + tag: "sup[data-footnote-ref]", + priority: 100, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "sup", + mergeAttributes( + { "data-footnote-ref": "", class: "footnote-ref" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + // Plain-text representation (used by generateText / markdown text fallbacks). + renderText({ node }) { + return `[^${node.attrs.id ?? ""}]`; + }, + + addNodeView() { + if (!this.options.view) return null; + // Force the react node view to render immediately using flush sync. + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, + + addCommands() { + return { + setFootnote: + () => + ({ state, tr, dispatch, editor }) => { + const { schema, selection } = state; + const refType = schema.nodes[FOOTNOTE_REFERENCE_NAME]; + const listType = schema.nodes[FOOTNOTES_LIST_NAME]; + const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME]; + if (!refType || !listType || !defType) return false; + + const { $from } = selection; + + // Forbid references inside code blocks and inside footnote definitions + // (no nested footnotes). + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if ( + node.type.spec.code || + node.type.name === FOOTNOTE_DEFINITION_NAME || + node.type.name === FOOTNOTES_LIST_NAME + ) { + return false; + } + } + + // Make sure the parent accepts an inline atom here. + const insertPos = selection.from; + if (!$from.parent.type.spec.content?.includes("inline") && + !$from.parent.isTextblock) { + return false; + } + + const id = generateFootnoteId(); + + // 1) Count references that occur strictly before the insertion point; + // the new definition goes at that index in the bottom list so the + // list order matches reference order. + let refsBefore = 0; + state.doc.nodesBetween(0, insertPos, (node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) refsBefore++; + }); + + // 2) Insert the reference at the cursor. + tr.insert(insertPos, refType.create({ id })); + + // 3) Locate (or create) the footnotes list, then insert the new + // definition at index `refsBefore`. + const emptyParagraph = schema.nodes.paragraph.create(); + const definition = defType.create({ id }, emptyParagraph); + + // Find existing list (always the last top-level child if present). + let listPos: number | null = null; + let listNode: any = null; + tr.doc.forEach((child, offset) => { + if (child.type.name === FOOTNOTES_LIST_NAME) { + listPos = offset; + listNode = child; + } + }); + + let defInsidePos: number | null = null; + if (listNode == null) { + // Create a new list at the very end of the document. + const list = listType.create(null, definition); + const end = tr.doc.content.size; + tr.insert(end, list); + // Cursor target: inside the new definition's first paragraph. + // end -> list open, +1 definition open, +1 paragraph open. + defInsidePos = end + 3; + } else { + // Insert at the right index within the existing list. + const listStart = listPos! + 1; // position of the first definition + let pos = listStart; + let index = 0; + listNode.forEach((defChild: any, defOffset: number) => { + if (index < refsBefore) { + pos = listStart + defOffset + defChild.nodeSize; + index++; + } + }); + tr.insert(pos, definition); + defInsidePos = pos + 2; // +1 enter definition, +1 enter paragraph + } + + if (dispatch) { + // Move the cursor into the new definition's paragraph so the user + // can immediately type the footnote text. + try { + const resolved = tr.doc.resolve( + Math.min(defInsidePos!, tr.doc.content.size), + ); + tr.setSelection(TextSelection.near(resolved)); + } catch { + // Selection placement is best-effort; ignore failures. + } + tr.scrollIntoView(); + dispatch(tr); + } + + return true; + }, + + removeFootnote: + (id: string) => + ({ state, tr, dispatch }) => { + if (!id) return false; + + // Collect: reference range(s), the definition range, and the list. + const refRanges: Array<{ from: number; to: number }> = []; + let defRange: { from: number; to: number } | null = null; + let listInfo: { pos: number; size: number; count: number } | null = + null; + + state.doc.descendants((node, pos) => { + if ( + node.type.name === FOOTNOTE_REFERENCE_NAME && + node.attrs.id === id + ) { + refRanges.push({ from: pos, to: pos + node.nodeSize }); + } + if ( + node.type.name === FOOTNOTE_DEFINITION_NAME && + node.attrs.id === id + ) { + defRange = { from: pos, to: pos + node.nodeSize }; + } + if (node.type.name === FOOTNOTES_LIST_NAME) { + listInfo = { + pos, + size: node.nodeSize, + count: node.childCount, + }; + } + }); + + if (refRanges.length === 0 && !defRange) return false; + + // Build the list of ranges to delete. If removing this definition + // would empty the list (it is the list's only child), delete the + // entire list instead — an empty footnotesList is invalid schema and + // a leftover empty list would be ugly. + const ranges: Array<{ from: number; to: number }> = [...refRanges]; + if (defRange) { + if (listInfo && (listInfo as any).count <= 1) { + const li = listInfo as { pos: number; size: number }; + ranges.push({ from: li.pos, to: li.pos + li.size }); + } else { + ranges.push(defRange); + } + } + + // Delete from the end so earlier positions stay valid. + ranges + .sort((a, b) => b.from - a.from) + .forEach(({ from, to }) => tr.delete(from, to)); + + if (dispatch) dispatch(tr); + return true; + }, + + scrollToFootnote: + (id: string) => + ({ editor }) => { + if (!id) return false; + const dom = editor.view.dom.querySelector( + `[data-footnote-def][data-id="${id}"]`, + ) as HTMLElement | null; + if (!dom) return false; + dom.scrollIntoView({ behavior: "smooth", block: "center" }); + return true; + }, + + scrollToReference: + (id: string) => + ({ editor }) => { + if (!id) return false; + const dom = editor.view.dom.querySelector( + `sup[data-footnote-ref][data-id="${id}"]`, + ) as HTMLElement | null; + if (!dom) return false; + dom.scrollIntoView({ behavior: "smooth", block: "center" }); + return true; + }, + }; + }, +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-sync.ts b/packages/editor-ext/src/lib/footnote/footnote-sync.ts new file mode 100644 index 00000000..ffd2e136 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-sync.ts @@ -0,0 +1,197 @@ +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode, Fragment } from "@tiptap/pm/model"; +import { + FOOTNOTE_DEFINITION_NAME, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, +} from "./footnote-util"; + +export const footnoteSyncPluginKey = new PluginKey("footnoteSync"); + +const SYNC_META = "footnoteSyncApplied"; + +interface FootnoteScan { + /** Reference ids in document order, first occurrence only, de-duplicated. */ + referenceIds: string[]; + /** definition id -> node (last occurrence wins, matching scan order). */ + definitions: Map; + /** Every top-level footnotesList node, in document order. */ + lists: Array<{ pos: number; node: ProseMirrorNode }>; +} + +function scan(doc: ProseMirrorNode): FootnoteScan { + const referenceIds: string[] = []; + const seenRefs = new Set(); + const definitions = new Map(); + const lists: Array<{ pos: number; node: ProseMirrorNode }> = []; + + doc.descendants((node, pos) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) { + const id = node.attrs.id; + if (id && !seenRefs.has(id)) { + seenRefs.add(id); + referenceIds.push(id); + } + } + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + const id = node.attrs.id; + if (id) definitions.set(id, node); + } + if (node.type.name === FOOTNOTES_LIST_NAME) { + lists.push({ pos, node }); + } + }); + + return { referenceIds, definitions, lists }; +} + +/** + * Idempotent integrity pass for footnotes. Runs only on LOCAL document changes + * (skips remote/collaboration steps and — crucially — its own appended meta) so + * the plugin can never re-trigger itself, guaranteeing termination. + * + * Everything is computed against the CURRENT document in a SINGLE invocation and + * emitted as AT MOST ONE transaction, always tagged with SYNC_META (and + * addToHistory:false). The strategy is "rebuild the canonical footnotes section + * from the desired end-state" rather than running several self-triggering + * passes: + * + * 1. Collect every footnote reference id in document order (the source of + * truth for which definitions must exist and in what order). + * 2. Compute the desired list of definitions: one per referenced id, in + * reference order, reusing the existing definition node when present or + * creating an empty one when missing. Orphan definitions (no matching + * reference) are dropped. + * 3. Compare against the actual footnotesList state: + * - no references -> there must be NO list (remove any); + * - references present -> there must be exactly ONE list, holding + * exactly the desired definitions, and it + * must sit after all real body content. + * 4. If the document already matches the desired end-state, return null (no + * transaction) — this idempotence is what stops oscillation. + * + * Placement note: the list is considered correctly placed when nothing but + * EMPTY paragraphs follow it. This is deliberate so the plugin coexists with a + * trailing-node plugin (which keeps an empty paragraph at the very end of the + * doc): the footnote list does not need to be the literal last child, only the + * last block of meaningful content. Without this, the two plugins would + * ping-pong forever (list moved to end -> trailing paragraph appended -> list + * no longer last -> moved again ...). + * + * Paste id-collision regeneration is left to the paste handler / v2; the common + * cases (orphans, missing definitions, multiple/empty/misplaced lists) are + * covered here. + */ +export function footnoteSyncPlugin( + isRemoteTransaction?: (tr: Transaction) => boolean, +): Plugin { + return new Plugin({ + key: footnoteSyncPluginKey, + appendTransaction(transactions, _oldState, newState) { + // Only react to document changes. + if (!transactions.some((t) => t.docChanged)) return null; + // Skip our OWN appended transaction. This is the guard that makes the + // plugin loop-safe: the transaction we emit carries SYNC_META, so when + // ProseMirror feeds it back to appendTransaction we bail out immediately + // and never produce a follow-up. (Termination invariant.) + if (transactions.some((t) => t.getMeta(SYNC_META))) return null; + // Skip remote/collab steps (orphan cleanup must run only on local edits). + if ( + isRemoteTransaction && + transactions.some((t) => isRemoteTransaction(t)) + ) { + return null; + } + + const { doc, schema } = newState; + const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME]; + const listType = schema.nodes[FOOTNOTES_LIST_NAME]; + const paragraphType = schema.nodes.paragraph; + if (!defType || !listType || !paragraphType) return null; + + const info = scan(doc); + + // 1) Desired definitions: one per referenced id, in reference order, + // reusing existing definition nodes (preserving their content) and + // synthesizing empty ones for references that lack a definition. + const desiredDefs: ProseMirrorNode[] = info.referenceIds.map((id) => { + const existing = info.definitions.get(id); + if (existing) return existing; + return defType.create({ id }, paragraphType.create()); + }); + + // 2) Determine whether the document already matches the desired end-state. + const hasRefs = desiredDefs.length > 0; + + // Is the existing single list already exactly the desired list, placed + // after all meaningful content (nothing but empty paragraphs after it)? + const isEmptyParagraph = (node: ProseMirrorNode) => + node.type === paragraphType && node.content.size === 0; + + let alreadyCanonical = false; + if (!hasRefs) { + // Canonical when there is no footnotesList at all. + alreadyCanonical = info.lists.length === 0; + } else if (info.lists.length === 1) { + const { pos, node } = info.lists[0]; + // Same definitions, same order, same identity (no rewrite needed)? + const sameDefs = + node.childCount === desiredDefs.length && + desiredDefs.every((d, i) => node.child(i) === d); + + // Placement: only empty paragraphs may follow the list. + const listEnd = pos + node.nodeSize; + let onlyEmptyParasAfter = true; + doc.nodesBetween(listEnd, doc.content.size, (child, childPos) => { + // Only inspect top-level children that start at/after the list end. + if (childPos >= listEnd && child !== node) { + if (!isEmptyParagraph(child)) onlyEmptyParasAfter = false; + } + return false; // do not descend + }); + + alreadyCanonical = sameDefs && onlyEmptyParasAfter; + } + + if (alreadyCanonical) return null; + + // 3) Rebuild: produce exactly ONE transaction that reaches the end-state. + const tr = newState.tr; + + // Delete every existing footnotesList (from the end so earlier positions + // stay valid while we mutate). + [...info.lists] + .sort((a, b) => b.pos - a.pos) + .forEach(({ pos, node }) => { + tr.delete(pos, pos + node.nodeSize); + }); + + if (hasRefs) { + // Insert a single canonical list holding the desired definitions. Place + // it after the last meaningful (non-empty-paragraph) top-level block, so + // it lands before any trailing empty paragraph the trailing-node plugin + // maintains. This keeps both plugins idempotent. + const mappedDoc = tr.doc; + let insertPos = mappedDoc.content.size; + for (let i = mappedDoc.childCount - 1; i >= 0; i--) { + const child = mappedDoc.child(i); + if (isEmptyParagraph(child)) { + // skip trailing empty paragraphs; insert before them + insertPos -= child.nodeSize; + } else { + break; + } + } + + const merged = listType.create(null, Fragment.fromArray(desiredDefs)); + tr.insert(insertPos, merged); + } + + if (!tr.docChanged) return null; + + tr.setMeta(SYNC_META, true); + tr.setMeta("addToHistory", false); + return tr; + }, + }); +} diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.ts b/packages/editor-ext/src/lib/footnote/footnote-util.ts new file mode 100644 index 00000000..41698686 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-util.ts @@ -0,0 +1,77 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +/** + * Node type names for the footnote feature. Centralized so every part of the + * feature (nodes, plugins, commands) references the same string. + */ +export const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; +export const FOOTNOTES_LIST_NAME = "footnotesList"; +export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; + +/** + * Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext + * does not need a runtime dependency on the `uuid` package; matches the + * lexicographically-sortable layout uuidv7 produces. + */ +export function generateFootnoteId(): string { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + + const rand = (length: number) => { + let out = ""; + for (let i = 0; i < length; i++) { + out += Math.floor(Math.random() * 16).toString(16); + } + return out; + }; + + // version 7 nibble, then variant (8..b) nibble. + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + + return ( + timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12) + ); +} + +/** + * Collect every `footnoteReference` id in document order. This is the single + * source of truth for numbering and ordering — a pure function of the document + * so every collaborating client computes the same result. + */ +export function collectReferenceIds(doc: ProseMirrorNode): string[] { + const ids: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) { + const id = node.attrs.id; + if (id) ids.push(id); + } + }); + return ids; +} + +/** + * Build a map of `referenceId -> displayNumber` (1-based) from document order. + * Pure function — the basis for the numbering decorations and any test. + */ +export function computeFootnoteNumbers( + doc: ProseMirrorNode, +): Map { + const numbers = new Map(); + let n = 0; + for (const id of collectReferenceIds(doc)) { + if (!numbers.has(id)) { + numbers.set(id, ++n); + } + } + return numbers; +} diff --git a/packages/editor-ext/src/lib/footnote/footnote.test.ts b/packages/editor-ext/src/lib/footnote/footnote.test.ts new file mode 100644 index 00000000..a68685a3 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote.test.ts @@ -0,0 +1,536 @@ +import { describe, it, expect } from "vitest"; +import { Editor, Extension, getSchema } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { Superscript } from "@tiptap/extension-superscript"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { FootnoteReference } from "./footnote-reference"; +import { FootnotesList } from "./footnotes-list"; +import { FootnoteDefinition } from "./footnote-definition"; +import { TrailingNode } from "../trailing-node"; +import { + computeFootnoteNumbers, + collectReferenceIds, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, + FOOTNOTE_DEFINITION_NAME, +} from "./footnote-util"; + +const extensions = [ + Document, + Paragraph, + Text, + FootnoteReference, + FootnotesList, + FootnoteDefinition, +]; + +function makeEditor(content?: any) { + return new Editor({ + extensions, + content: content ?? { type: "doc", content: [{ type: "paragraph" }] }, + }); +} + +function countType(doc: PMNode, name: string): number { + let n = 0; + doc.descendants((node) => { + if (node.type.name === name) n++; + }); + return n; +} + +describe("footnote numbering (pure function)", () => { + it("numbers references in document order", () => { + const schema = getSchema(extensions); + const doc = PMNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph" }], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [{ type: "paragraph" }], + }, + ], + }, + ], + }); + + expect(collectReferenceIds(doc)).toEqual(["x", "y"]); + const numbers = computeFootnoteNumbers(doc); + expect(numbers.get("x")).toBe(1); + expect(numbers.get("y")).toBe(2); + }); +}); + +describe("setFootnote command", () => { + it("inserts a reference and a matching definition in the footnotes list", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Hello" }] }, + ], + }); + // Cursor at end of the word. + editor.commands.setTextSelection(6); + const ok = editor.commands.setFootnote(); + expect(ok).toBe(true); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1); + + // The reference id and the definition id match. + let refId: string | null = null; + let defId: string | null = null; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) refId = node.attrs.id; + if (node.type.name === FOOTNOTE_DEFINITION_NAME) defId = node.attrs.id; + }); + expect(refId).toBeTruthy(); + expect(refId).toBe(defId); + editor.destroy(); + }); + + it("inserts the definition at the correct position matching reference order", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "AAAA" }] }, + { type: "paragraph", content: [{ type: "text", text: "BBBB" }] }, + ], + }); + + // First footnote: place inside the SECOND paragraph (after "BBBB"). + editor.commands.setTextSelection(11); // end of BBBB + editor.commands.setFootnote(); + + // Second footnote: place inside the FIRST paragraph (after "AAAA"), + // which is BEFORE the first reference in document order. + editor.commands.setTextSelection(5); // end of AAAA + editor.commands.setFootnote(); + + const doc = editor.state.doc; + // Reference order in document. + const refOrder = collectReferenceIds(doc); + // Definition order in the list. + const defOrder: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + defOrder.push(node.attrs.id); + } + }); + + expect(defOrder).toEqual(refOrder); + expect(defOrder.length).toBe(2); + editor.destroy(); + }); +}); + +describe("removeFootnote command (cascade)", () => { + it("removes both the reference and its definition, and drops the empty list", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Hello" }] }, + ], + }); + editor.commands.setTextSelection(6); + editor.commands.setFootnote(); + + let id: string | null = null; + editor.state.doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) id = node.attrs.id; + }); + expect(id).toBeTruthy(); + + editor.commands.removeFootnote(id!); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(0); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0); + // empty list removed + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0); + editor.destroy(); + }); +}); + +describe("footnote sync plugin (orphans)", () => { + it("creates an empty definition for a reference pasted without one", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "x" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } }, + ], + }, + ], + }); + // Trigger a doc change so appendTransaction runs. + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + let defFound = false; + doc.descendants((node) => { + if ( + node.type.name === FOOTNOTE_DEFINITION_NAME && + node.attrs.id === "orphan-ref" + ) { + defFound = true; + } + }); + expect(defFound).toBe(true); + editor.destroy(); + }); + + it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + // First (stray) footnotes list, e.g. from a paste/collab merge. + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }], + }, + ], + }, + { type: "paragraph", content: [{ type: "text", text: "tail" }] }, + // Second footnotes list (the "real" trailing one). + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "Y note" }] }], + }, + ], + }, + ], + }); + // Trigger a local doc change so appendTransaction runs. + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + // Converged to exactly ONE list. + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + // Both definitions preserved (no tracking lost). + const defIds: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id); + }); + expect(defIds.sort()).toEqual(["x", "y"]); + // The single list is the LAST child of the document. + const lastChild = doc.child(doc.childCount - 1); + expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME); + editor.destroy(); + }); + + it("leaves a correct doc (single trailing list) unchanged — no merge loop", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }], + }, + ], + }, + ], + }); + const before = editor.state.doc.toJSON(); + // A change that doesn't touch footnote structure. + editor.commands.insertContentAt(1, "z"); + const doc = editor.state.doc; + // Still exactly one list, still last, definition preserved. + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + const lastChild = doc.child(doc.childCount - 1); + expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME); + // The footnotes list subtree is identical to before (no spurious rewrite). + const beforeList = before.content.find( + (n: any) => n.type === FOOTNOTES_LIST_NAME, + ); + const afterList = doc + .toJSON() + .content.find((n: any) => n.type === FOOTNOTES_LIST_NAME); + expect(afterList).toEqual(beforeList); + editor.destroy(); + }); + + it("removes an orphan definition with no matching reference", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "x" }] }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "orphan-def" }, + content: [{ type: "paragraph" }], + }, + ], + }, + ], + }); + editor.commands.insertContentAt(1, "y"); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0); + editor.destroy(); + }); +}); + +/** + * Live-editor regression tests for the sync-plugin infinite loop (the hard + * freeze when activating /footnote). These drive a REAL Tiptap editor through + * the same plugin pipeline the browser uses — including the TrailingNode plugin, + * which is what turned the "move list to the end" pass into an infinite + * ping-pong (list moved last -> trailing paragraph appended after it -> list no + * longer last -> moved again -> ...). + * + * If the loop regresses, ProseMirror's appendTransaction round loop never + * terminates and these tests HANG (the vitest timeout fails them). The + * transaction counter additionally fails fast with a bounded iteration cap, so + * a regression surfaces as an explicit error instead of only a slow timeout. + */ +describe("footnote sync plugin (no infinite loop — live editor)", () => { + // Hard cap on how many doc-changing appendTransaction rounds we tolerate for a + // single user action. Convergence takes a couple of rounds at most; anything + // approaching this means the plugins are oscillating. + const MAX_ROUNDS = 50; + + // The production editor wires FootnoteReference alongside TrailingNode and + // Superscript; both participate in the loop the bug exhibited, so we mirror + // that here. + function makeLiveEditor(content?: any) { + let rounds = 0; + // A guard plugin that counts doc-changing appendTransaction rounds and + // throws if they exceed the cap, converting a would-be infinite loop into a + // deterministic failure instead of a wall-clock hang. + const LoopGuard = Extension.create({ + name: "footnoteLoopGuard", + // Run last so it observes every other plugin's appended transaction. + priority: -1000, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("footnoteLoopGuard"), + appendTransaction(transactions) { + if (transactions.some((t) => t.docChanged)) { + rounds += 1; + if (rounds > MAX_ROUNDS) { + throw new Error( + `footnote sync did not converge: exceeded ${MAX_ROUNDS} appendTransaction rounds (infinite loop)`, + ); + } + } + return null; + }, + }), + ]; + }, + }); + + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + Superscript, + TrailingNode, + LoopGuard, + FootnoteReference, + FootnotesList, + FootnoteDefinition, + ], + content: content ?? { type: "doc", content: [{ type: "paragraph" }] }, + }); + return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) }; + } + + function lastFootnotesListIsTrailing(doc: PMNode): boolean { + // Canonical placement: the list is the last meaningful block — only empty + // paragraphs (the trailing-node) may follow it. + let listIndex = -1; + for (let i = 0; i < doc.childCount; i++) { + if (doc.child(i).type.name === FOOTNOTES_LIST_NAME) listIndex = i; + } + if (listIndex === -1) return false; + for (let i = listIndex + 1; i < doc.childCount; i++) { + const child = doc.child(i); + if (!(child.type.name === "paragraph" && child.content.size === 0)) { + return false; + } + } + return true; + } + + it("setFootnote() RETURNS (no hang) and produces one ref + one def in a trailing list", () => { + const { editor } = makeLiveEditor({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], + }); + editor.commands.setTextSelection(3); + const ok = editor.commands.setFootnote(); + expect(ok).toBe(true); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1); + expect(lastFootnotesListIsTrailing(doc)).toBe(true); + editor.destroy(); + }); + + it("a second setFootnote() does not hang: two refs + two defs in one list", () => { + const { editor } = makeLiveEditor({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], + }); + editor.commands.setTextSelection(3); + editor.commands.setFootnote(); + editor.commands.setTextSelection(3); + editor.commands.setFootnote(); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(2); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(lastFootnotesListIsTrailing(doc)).toBe(true); + editor.destroy(); + }); + + it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => { + const { editor, getRounds, resetRounds } = makeLiveEditor({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], + }); + editor.commands.setTextSelection(3); + editor.commands.setFootnote(); + + // Now the doc is canonical. Dispatch an unrelated edit (insert text) and + // assert the sync plugin converges in a bounded number of rounds and the + // document is stable (one ref/def/list, list trailing). + resetRounds(); + editor.commands.insertContentAt(1, "Z"); + const afterFirst = editor.state.doc.toJSON(); + const roundsAfterEdit = getRounds(); + expect(roundsAfterEdit).toBeLessThan(MAX_ROUNDS); + + // A follow-up no-op-ish edit must not re-trigger structural rewrites: the + // footnotes section is identical before and after a further unrelated edit. + editor.commands.insertContentAt(2, "Y"); + const afterSecond = editor.state.doc.toJSON(); + + const listOf = (json: any) => + json.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME); + expect(listOf(afterSecond)).toEqual(listOf(afterFirst)); + expect(countType(editor.state.doc, FOOTNOTES_LIST_NAME)).toBe(1); + editor.destroy(); + }); + + it("two footnotesList nodes converge to one (merge) without looping", () => { + const { editor } = makeLiveEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "X" }] }, + ], + }, + ], + }, + { type: "paragraph", content: [{ type: "text", text: "tail" }] }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Y" }] }, + ], + }, + ], + }, + ], + }); + // Trigger a local doc change so appendTransaction runs (must not hang). + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + const defIds: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) + defIds.push(node.attrs.id); + }); + expect(defIds.sort()).toEqual(["x", "y"]); + expect(lastFootnotesListIsTrailing(doc)).toBe(true); + editor.destroy(); + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnotes-list.ts b/packages/editor-ext/src/lib/footnote/footnotes-list.ts new file mode 100644 index 00000000..516fcf45 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnotes-list.ts @@ -0,0 +1,56 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { FOOTNOTES_LIST_NAME } from "./footnote-util"; + +export interface FootnotesListOptions { + HTMLAttributes: Record; + view: any; +} + +/** + * Block container that holds all footnote definitions. There is a single + * instance per document and it is always the last child of the doc (enforced by + * the sync plugin). Modeled on the callout block node. + */ +export const FootnotesList = Node.create({ + name: FOOTNOTES_LIST_NAME, + + group: "block", + content: "footnoteDefinition+", + isolating: true, + selectable: false, + defining: true, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + parseHTML() { + return [ + { + tag: "section[data-footnotes]", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "section", + mergeAttributes( + { "data-footnotes": "", class: "footnotes" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addNodeView() { + if (!this.options.view) return null; + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/footnote/index.ts b/packages/editor-ext/src/lib/footnote/index.ts new file mode 100644 index 00000000..02defff1 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/index.ts @@ -0,0 +1,6 @@ +export * from "./footnote-util"; +export * from "./footnote-reference"; +export * from "./footnotes-list"; +export * from "./footnote-definition"; +export * from "./footnote-numbering"; +export * from "./footnote-sync"; diff --git a/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts new file mode 100644 index 00000000..ad47cc52 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts @@ -0,0 +1,115 @@ +import { marked } from "marked"; + +/** + * Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline. + * + * Two pieces: + * - an INLINE tokenizer for `[^id]` references -> (matches the editor-ext FootnoteReference renderHTML); + * - a document hook (`preprocess`/`walkTokens` is awkward for collecting + + * removing definitions, so we use a regex preprocessing step instead) that + * pulls every `[^id]: text` definition line out of the body and appends a + * single
with one
per + * definition, so the round-trip rebuilds footnotesList + footnoteDefinition. + * + * Only definitions that have a matching reference are emitted (and vice-versa + * the sync plugin fills any gaps on the editor side), keeping the output valid. + */ + +const DEFINITION_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; +const REFERENCE_RE = /\[\^([^\]\s]+)\]/; + +interface FootnoteRefToken { + type: "footnoteRef"; + raw: string; + id: string; +} + +export const footnoteReferenceExtension = { + name: "footnoteRef", + level: "inline" as const, + start(src: string) { + return src.match(/\[\^/)?.index ?? -1; + }, + tokenizer(src: string): FootnoteRefToken | undefined { + const match = REFERENCE_RE.exec(src); + // Only match at the very start of the remaining inline source. + if (match && match.index === 0) { + return { + type: "footnoteRef", + raw: match[0], + id: match[1], + }; + } + return undefined; + }, + renderer(token: FootnoteRefToken) { + return ``; + }, +}; + +function escapeAttr(value: string): string { + return String(value).replace(/&/g, "&").replace(/"/g, """); +} + +/** + * Extract `[^id]: text` definition lines from the markdown body, returning the + * cleaned body plus a rendered
(empty string when no + * definitions). Call this BEFORE marked.parse and append the section to the + * resulting HTML. + */ +export function extractFootnoteDefinitions(markdown: string): { + body: string; + section: string; +} { + const lines = markdown.split("\n"); + const bodyLines: string[] = []; + const definitions: Array<{ id: string; text: string }> = []; + + // Track fenced-code state so a `[^id]: ...` line that merely SHOWS footnote + // syntax inside a ``` / ~~~ code block is left in the body verbatim and not + // mistaken for a real definition. + let fence: string | null = null; + + for (const line of lines) { + const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[2][0]; + if (fence === null) { + fence = marker; // opening fence + } else if (marker === fence) { + fence = null; // closing fence (matching delimiter type) + } + bodyLines.push(line); + continue; + } + + const m = fence === null ? DEFINITION_RE.exec(line) : null; + if (m) { + definitions.push({ id: m[1], text: m[2] }); + } else { + bodyLines.push(line); + } + } + + if (definitions.length === 0) { + return { body: markdown, section: "" }; + } + + const defsHtml = definitions + .map((d) => { + // Render the definition text as inline markdown so emphasis/links inside + // a footnote survive the round-trip; wrap in a paragraph (the node's + // content is paragraph+). + const inner = marked.parseInline(d.text || ""); + return `

${inner}

`; + }) + .join(""); + + return { + body: bodyLines.join("\n"), + section: `
${defsHtml}
`, + }; +} diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 7556aa4f..82de5761 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -2,6 +2,10 @@ import { marked } from "marked"; import { calloutExtension } from "./callout.marked"; import { mathBlockExtension } from "./math-block.marked"; import { mathInlineExtension } from "./math-inline.marked"; +import { + footnoteReferenceExtension, + extractFootnoteDefinitions, +} from "./footnote.marked"; marked.use({ renderer: { @@ -34,7 +38,12 @@ marked.use({ }); marked.use({ - extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], + extensions: [ + calloutExtension, + mathBlockExtension, + mathInlineExtension, + footnoteReferenceExtension, + ], }); marked.setOptions({ breaks: true }); @@ -48,5 +57,16 @@ export function markdownToHtml( .replace(YAML_FONT_MATTER_REGEX, "") .trimStart(); - return marked.parse(markdown).toString(); + // Pull `[^id]: ...` definition lines out of the body, render the body, then + // append a single
so the round-trip rebuilds the + // footnotesList + footnoteDefinition nodes. + const { body, section } = extractFootnoteDefinitions(markdown); + + const parsed = marked.parse(body); + if (!section) return parsed; + + if (typeof parsed === "string") { + return parsed + section; + } + return parsed.then((html) => html + section); } diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index ebfc3423..75d923ba 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -12,12 +12,44 @@ function sanitizeMdLinkText(value: string): string { .replace(/[\r\n]+/g, ' '); } +// Tags turndown treats as void (self-closing). Footnote references render as an +// empty whose meaning lives entirely in its data-id; +// without marking it void, turndown's blank-node removal drops it before our +// rule runs, losing the `[^id]` marker. Mirrors turndown's built-in list. +const TURNDOWN_VOID_ELEMENTS = [ + 'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', + 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', +]; + +function isVoidNode(node: any): boolean { + const name = node?.nodeName?.toUpperCase?.(); + if (!name) return false; + if (name === 'SUP' && node.hasAttribute?.('data-footnote-ref')) { + return true; + } + return TURNDOWN_VOID_ELEMENTS.indexOf(name) !== -1; +} + +/** + * An empty is "blank" to turndown, which removes blank + * inline nodes (RootNode/Node use a module-level isVoid the options cannot + * override). To survive, inject the id as text content so the node is non-blank; + * the footnoteReference rule then reads data-id and emits `[^id]`. + */ +function fillEmptyFootnoteRefs(html: string): string { + return html.replace( + /]*\bdata-footnote-ref\b[^>]*)>\s*<\/sup>/gi, + (_m, attrs) => ``, + ); +} + export function htmlToMarkdown(html: string): string { const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', hr: '---', bulletListMarker: '-', + isVoid: isVoidNode, }); turndownService.use([ @@ -34,8 +66,12 @@ export function htmlToMarkdown(html: string): string { iframeEmbed, image, video, + footnoteReference, + footnotesList, ]); - return turndownService.turndown(html).replaceAll('
', ' '); + return turndownService + .turndown(fillEmptyFootnoteRefs(html)) + .replaceAll('
', ' '); } function listParagraph(turndownService: _TurndownService) { @@ -203,6 +239,57 @@ function image(turndownService: _TurndownService) { }); } +/** + * Footnote reference (inline atom) -> pandoc/GFM marker `[^id]`. + * The visible number is derived (not stored), so the id is the stable anchor. + */ +function footnoteReference(turndownService: _TurndownService) { + turndownService.addRule('footnoteReference', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'SUP' && node.hasAttribute('data-footnote-ref') + ); + }, + replacement: function (_content: string, node: HTMLInputElement) { + const id = node.getAttribute('data-id') || ''; + return id ? `[^${id}]` : ''; + }, + }); +} + +/** + * Footnotes container -> the list of `[^id]: text` definitions at the end of + * the document (one per line). Each footnoteDefinition inside emits its own + * `[^id]: ...` line; turndown joins them with the surrounding block spacing. + */ +function footnotesList(turndownService: _TurndownService) { + turndownService.addRule('footnoteDefinition', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'DIV' && node.hasAttribute('data-footnote-def') + ); + }, + replacement: function (content: string, node: HTMLInputElement) { + const id = node.getAttribute('data-id') || ''; + // Collapse internal newlines so the definition stays a single MD line; + // continuation lines are a v2 refinement. + const text = content.replace(/\s*\n+\s*/g, ' ').trim(); + return id ? `\n[^${id}]: ${text}\n` : ''; + }, + }); + + turndownService.addRule('footnotesList', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'SECTION' && node.hasAttribute('data-footnotes') + ); + }, + replacement: function (content: string) { + return `\n\n${content.trim()}\n`; + }, + }); +} + function video(turndownService: _TurndownService) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { diff --git a/packages/editor-ext/tsconfig.json b/packages/editor-ext/tsconfig.json index 974fea06..062c97f5 100644 --- a/packages/editor-ext/tsconfig.json +++ b/packages/editor-ext/tsconfig.json @@ -19,5 +19,6 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false - } + }, + "exclude": ["**/*.test.ts", "vitest.config.ts", "dist"] } diff --git a/packages/editor-ext/vitest.config.ts b/packages/editor-ext/vitest.config.ts new file mode 100644 index 00000000..c13f7bd6 --- /dev/null +++ b/packages/editor-ext/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js index 7b47b9e9..d5e68a21 100644 --- a/packages/mcp/build/lib/collaboration.js +++ b/packages/mcp/build/lib/collaboration.js @@ -263,10 +263,75 @@ function bridgeTaskLists(html) { } return document.body.innerHTML; } +// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline +// marker becomes , and `[^id]: text` +// definition lines are collected into a single
. +const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; +const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/; +function escapeFootnoteAttr(value) { + return String(value).replace(/&/g, "&").replace(/"/g, """); +} +const footnoteRefMarkedExtension = { + name: "footnoteRef", + level: "inline", + start(src) { + return src.match(/\[\^/)?.index ?? -1; + }, + tokenizer(src) { + const match = FOOTNOTE_REF_RE.exec(src); + if (match && match.index === 0) { + return { type: "footnoteRef", raw: match[0], id: match[1] }; + } + return undefined; + }, + renderer(token) { + return ``; + }, +}; +marked.use({ extensions: [footnoteRefMarkedExtension] }); +/** + * Pull `[^id]: text` definition lines out of the body and render a single + *
for them (or "" when there are none). + */ +function extractFootnotes(markdown) { + const lines = markdown.split("\n"); + const bodyLines = []; + const defs = []; + // Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code + // block is preserved verbatim and not treated as a footnote definition. + let fence = null; + for (const line of lines) { + const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[2][0]; + if (fence === null) + fence = marker; + else if (marker === fence) + fence = null; + bodyLines.push(line); + continue; + } + const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null; + if (m) + defs.push({ id: m[1], text: m[2] }); + else + bodyLines.push(line); + } + if (defs.length === 0) + return { body: markdown, section: "" }; + const inner = defs + .map((d) => `

${marked.parseInline(d.text || "")}

`) + .join(""); + return { + body: bodyLines.join("\n"), + section: `
${inner}
`, + }; +} /** Convert markdown to a ProseMirror doc using the full Docmost schema. */ export async function markdownToProseMirror(markdownContent) { const withCallouts = await preprocessCallouts(markdownContent); - const html = await marked.parse(withCallouts); + const { body, section } = extractFootnotes(withCallouts); + const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); return generateJSON(bridged, docmostExtensions); } diff --git a/packages/mcp/build/lib/diff.js b/packages/mcp/build/lib/diff.js index f5e7ab44..516a3c81 100644 --- a/packages/mcp/build/lib/diff.js +++ b/packages/mcp/build/lib/diff.js @@ -79,10 +79,26 @@ function countUniqueLinks(doc) { visit(doc); return hrefs.size; } +/** Count footnoteReference nodes anywhere under a node (reading order). */ +function countFootnoteRefs(node) { + if (!node || typeof node !== "object") + return 0; + let n = node.type === "footnoteReference" ? 1 : 0; + if (Array.isArray(node.content)) { + for (const child of node.content) + n += countFootnoteRefs(child); + } + return n; +} /** - * Parse the ordered list of integers from `[N]` footnote markers found in the - * BODY only (every top-level block before the first "Примечания..." notes - * heading; if no such heading, the whole doc). Returned in reading order. + * Ordered list of footnote marker numbers found in the BODY only (every + * top-level block before the first "Примечания..." notes heading; if no such + * heading, the whole doc), in reading order. + * + * Supports BOTH representations: + * - real `footnoteReference` nodes (the current footnote feature) — numbered + * 1..n by reading position, since their visible number is derived; + * - legacy `[N]` text markers (older translated docs) — the literal N. */ function footnoteMarkers(doc, notesHeading) { const top = Array.isArray(doc?.content) ? doc.content : []; @@ -90,6 +106,15 @@ function footnoteMarkers(doc, notesHeading) { n.type === "heading" && plainText(n).trim() === notesHeading); const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; + // Real footnoteReference nodes take precedence: when present, number them by + // reading position (their displayed number is not stored). + let refCount = 0; + for (const block of bodyBlocks) + refCount += countFootnoteRefs(block); + if (refCount > 0) { + return Array.from({ length: refCount }, (_, i) => i + 1); + } + // Fallback: legacy `[N]` text markers. const markers = []; const re = /\[(\d+)\]/g; for (const block of bodyBlocks) { diff --git a/packages/mcp/build/lib/docmost-schema.js b/packages/mcp/build/lib/docmost-schema.js index 97cdcafd..e89ed5a0 100644 --- a/packages/mcp/build/lib/docmost-schema.js +++ b/packages/mcp/build/lib/docmost-schema.js @@ -342,6 +342,78 @@ const Mention = Node.create({ return ["span", { "data-type": "mention", ...HTMLAttributes }, 0]; }, }); +/** + * Footnote feature (mirror of packages/editor-ext/src/lib/footnote). Three + * nodes connected by `id`: + * - FootnoteReference: inline atom marker in the body (); + * - FootnotesList: a single bottom container (
); + * - FootnoteDefinition: one editable note keyed by id (
). + * The visible number is not stored; it is derived from reference order. + * + * priority 101 so this node's parse rule beats the Superscript mark's + * rule (otherwise an empty reference is parsed as an empty superscript + * mark and dropped). Keep in sync with editor-ext. + */ +const FootnoteReference = Node.create({ + name: "footnoteReference", + priority: 101, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el) => el.getAttribute("data-id"), + renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "sup[data-footnote-ref]", priority: 100 }]; + }, + renderHTML({ HTMLAttributes }) { + return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }]; + }, +}); +const FootnotesList = Node.create({ + name: "footnotesList", + group: "block", + content: "footnoteDefinition+", + isolating: true, + selectable: false, + defining: true, + parseHTML() { + return [{ tag: "section[data-footnotes]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0]; + }, +}); +const FootnoteDefinition = Node.create({ + name: "footnoteDefinition", + content: "paragraph+", + defining: true, + isolating: true, + selectable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el) => el.getAttribute("data-id"), + renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "div[data-footnote-def]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0]; + }, +}); /** Inline KaTeX expression. Carries the LaTeX source in `text`. */ const MathInline = Node.create({ name: "mathInline", @@ -978,6 +1050,9 @@ export const docmostExtensions = [ TableCell, TableHeader, Mention, + FootnoteReference, + FootnotesList, + FootnoteDefinition, MathInline, MathBlock, Details, diff --git a/packages/mcp/build/lib/markdown-converter.js b/packages/mcp/build/lib/markdown-converter.js index 477dee5d..d5d47400 100644 --- a/packages/mcp/build/lib/markdown-converter.js +++ b/packages/mcp/build/lib/markdown-converter.js @@ -388,6 +388,27 @@ export function convertProseMirrorToMarkdown(content) { // carry the real values), so escape it for the text context, not attrs. return `@${escapeHtmlText(mentionLabel)}`; } + case "footnoteReference": { + // Pandoc/GFM inline marker. The number is derived (not stored), so the + // id is the stable anchor. + const fnId = node.attrs?.id || ""; + return fnId ? `[^${fnId}]` : ""; + } + case "footnotesList": + // The container renders its definitions, each on its own `[^id]: ...` + // line. A blank line separates the body from the notes block. + return nodeContent.map(processNode).join("\n"); + case "footnoteDefinition": { + const defId = node.attrs?.id || ""; + // Collapse the definition's paragraphs into a single line; multi-line + // footnotes are a v2 refinement. + const defText = nodeContent + .map(processNode) + .join(" ") + .replace(/\s*\n+\s*/g, " ") + .trim(); + return defId ? `[^${defId}]: ${defText}` : ""; + } case "attachment": { // BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but // the schema stores name/url (plus mime/size/attachmentId). Emit the diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index 98079f72..2fc5d37b 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -223,6 +223,59 @@ export function noteItem(inlineNodes) { ], }; } +/** + * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id: + * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } + * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). + */ +export function footnoteDefinition(id, inlineNodes) { + const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; + return { + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", attrs: { id: freshId() }, content }], + }; +} +/** + * Replace every `[N]` body marker and `\u0000FN\u0000` comment placeholder in + * an inline content array with a real `footnoteReference` node, in reading + * order. `onMarker` is called for each replaced marker (with the original `[N]` + * number or the placeholder index) and returns the fresh footnote id to attach + * to the inserted node. Mutates `inline` in place. + */ +function replaceMarkersWithReferences(inline, onMarker) { + const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; + for (let i = 0; i < inline.length; i++) { + const n = inline[i]; + if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") { + continue; + } + if (!re.test(n.text)) + continue; + re.lastIndex = 0; + const marks = Array.isArray(n.marks) ? n.marks : []; + const parts = []; + let last = 0; + let m; + while ((m = re.exec(n.text)) !== null) { + if (m.index > last) { + parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] }); + } + const oldNum = m[1] != null ? Number(m[1]) : undefined; + const phIdx = m[2] != null ? Number(m[2]) : undefined; + const fnId = onMarker({ oldNum, phIdx }); + parts.push({ type: "footnoteReference", attrs: { id: fnId } }); + last = m.index + m[0].length; + } + if (last < n.text.length) { + parts.push({ ...n, text: n.text.slice(last), marks: [...marks] }); + } + // Drop any zero-length text runs the slicing may have produced. + const cleaned = parts.filter((p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0)); + inline.splice(i, 1, ...cleaned); + i += cleaned.length - 1; + } +} /** * Convert a comment's markdown (e.g. `**Lead.** body...`) into inline * ProseMirror nodes. @@ -321,85 +374,100 @@ export function commentsToFootnotes(doc, comments, opts = {}) { throw new Error("notes orderedList not found"); } const consumed = []; - const noteByPh = new Map(); + const noteInlineByPh = new Map(); (Array.isArray(comments) ? comments : []).forEach((c, i) => { if (!c || !c.selection) return; // Collision-proof sentinel delimited by NUL control chars, which never occur - // in real Docmost prose — so the renumber regex below cannot mistake any body - // text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is - // transient: the placeholder round-trips within this function (insertMarkerAfter - // inserts it, the renumber pass replaces it with "[N]"), so it never persists - // in a returned/pushed document. + // in real Docmost prose - so the marker regex cannot mistake any body text + // (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is + // transient: the placeholder is inserted here and replaced by a + // footnoteReference node below; it never persists in a returned document. const ph = `\u0000FN${i}\u0000`; - // insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh - // the `top` / `notesList` references that point into it. + // insertMarkerAfter returns a NEW cloned doc; reassign `working`. const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, { beforeBlock: notesIdx, }); if (!r.inserted) return; working = r.doc; - noteByPh.set(ph, noteItem(mdToInlineNodes(c.content))); + noteInlineByPh.set(ph, mdToInlineNodes(c.content)); consumed.push(c.id); }); // Re-resolve references into the (possibly re-cloned) working doc. const top2 = Array.isArray(working.content) ? working.content : []; - const notesList2 = top2 - .slice(notesIdx) - .find((n) => isObject(n) && n.type === "orderedList"); + const notesIdx2 = top2.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading); + const oldListIndex = top2.findIndex((n) => isObject(n) && n.type === "orderedList"); + const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null; if (!notesList2) { throw new Error("notes orderedList not found"); } - const oldNotes = Array.isArray(notesList2.content) + // Inline content of each existing note (listItem -> paragraph -> inline). + const oldNoteInline = (Array.isArray(notesList2.content) ? notesList2.content - : []; - const newNotes = []; - let seq = 0; - // Match either an existing "[N]" marker or a NUL-delimited "\u0000FN\u0000" - // placeholder, in reading order across the body (blocks before the notes heading). - const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; - // Same range regex setCalloutRange uses to detect the disclaimer callout's - // "[1]…[K]" range; used here to decide whether a top-level callout is the - // disclaimer (skip) or an ordinary callout (renumber normally). + : []).map((item) => { + const para = isObject(item) && Array.isArray(item.content) + ? item.content.find((c) => isObject(c) && c.type === "paragraph") + : null; + return para && Array.isArray(para.content) ? para.content : []; + }); + // Walk the body in reading order, turning each "[N]" / placeholder marker into + // a real footnoteReference node and collecting its definition inline content. + const definitions = []; const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/; - for (let i = 0; i < notesIdx; i++) { - // Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote - // marker and is synced separately by setCalloutRange. Renumbering it here - // would consume note slots and corrupt the sequence. Other top-level - // callouts may carry legitimate "[N]" body markers and are renumbered. + // Recursively visit inline arrays inside a block (paragraph, heading, callout + // child paragraphs, table cells, ...), preserving document reading order. + const visitInlineArrays = (container) => { + if (!isObject(container) || !Array.isArray(container.content)) + return; + const hasText = container.content.some((n) => isObject(n) && n.type === "text"); + if (hasText) { + replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => { + const fnId = freshId(); + if (oldNum != null) { + const inline = oldNoteInline[oldNum - 1]; + // Every existing body marker MUST map to a real note. An out-of-range + // marker means the document is internally inconsistent; fail loudly. + if (inline === undefined) { + throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`); + } + definitions.push(footnoteDefinition(fnId, inline)); + } + else { + const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || []; + definitions.push(footnoteDefinition(fnId, inline)); + } + return fnId; + }); + } + else { + for (const child of container.content) + visitInlineArrays(child); + } + }; + const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex; + for (let i = 0; i < notesBoundary; i++) { + // Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote + // marker and is synced separately by setCalloutRange. if (isObject(top2[i]) && top2[i].type === "callout" && disclaimerRangeRe.test(blockText(top2[i]))) { continue; } - walk(top2[i], (node) => { - if (node.type !== "text" || typeof node.text !== "string") - return; - node.text = node.text.replace(re, (_m, oldNum, phIdx) => { - if (oldNum != null) { - const note = oldNotes[Number(oldNum) - 1]; - // Every existing body marker MUST map to a real note. An out-of-range - // marker means the document is internally inconsistent; fail loudly - // rather than silently dropping the note and desyncing the callout. - if (note === undefined) { - throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`); - } - newNotes.push(note); - } - else { - newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`)); - } - return `[${++seq}]`; - }); - }); + visitInlineArrays(top2[i]); } - // Reorder the notes list IN PLACE on `working` first, THEN sync the callout - // range. setCalloutRange clones `working`, so the reordered notes (mutated - // before the clone) are carried into its result automatically. No null-filter - // here: marker count and note count must stay exactly equal (the out-of-range - // guard above guarantees no undefined entry is ever pushed). - notesList2.content = newNotes; - const synced = setCalloutRange(working, notesList2.content.length); + // Replace the old orderedList with a real footnotesList of the collected + // definitions (reading order). If there are no definitions, drop the list. + if (definitions.length > 0) { + top2[oldListIndex] = { + type: "footnotesList", + content: definitions, + }; + } + else { + top2.splice(oldListIndex, 1); + } + // Sync the disclaimer callout range to the new note count. + const synced = setCalloutRange(working, definitions.length); return { doc: synced.doc, consumed }; } diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index ca2114d9..0e6e80a3 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -296,12 +296,87 @@ function bridgeTaskLists(html: string): string { return document.body.innerHTML; } +// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline +// marker becomes , and `[^id]: text` +// definition lines are collected into a single
. +const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; +const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/; + +function escapeFootnoteAttr(value: string): string { + return String(value).replace(/&/g, "&").replace(/"/g, """); +} + +const footnoteRefMarkedExtension = { + name: "footnoteRef", + level: "inline" as const, + start(src: string) { + return src.match(/\[\^/)?.index ?? -1; + }, + tokenizer(src: string) { + const match = FOOTNOTE_REF_RE.exec(src); + if (match && match.index === 0) { + return { type: "footnoteRef", raw: match[0], id: match[1] }; + } + return undefined; + }, + renderer(token: any) { + return ``; + }, +}; + +marked.use({ extensions: [footnoteRefMarkedExtension] }); + +/** + * Pull `[^id]: text` definition lines out of the body and render a single + *
for them (or "" when there are none). + */ +function extractFootnotes(markdown: string): { + body: string; + section: string; +} { + const lines = markdown.split("\n"); + const bodyLines: string[] = []; + const defs: Array<{ id: string; text: string }> = []; + // Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code + // block is preserved verbatim and not treated as a footnote definition. + let fence: string | null = null; + for (const line of lines) { + const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[2][0]; + if (fence === null) fence = marker; + else if (marker === fence) fence = null; + bodyLines.push(line); + continue; + } + const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null; + if (m) defs.push({ id: m[1], text: m[2] }); + else bodyLines.push(line); + } + if (defs.length === 0) return { body: markdown, section: "" }; + const inner = defs + .map( + (d) => + `

${marked.parseInline(d.text || "")}

`, + ) + .join(""); + return { + body: bodyLines.join("\n"), + section: `
${inner}
`, + }; +} + /** Convert markdown to a ProseMirror doc using the full Docmost schema. */ export async function markdownToProseMirror( markdownContent: string, ): Promise { const withCallouts = await preprocessCallouts(markdownContent); - const html = await marked.parse(withCallouts); + const { body, section } = extractFootnotes(withCallouts); + const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); return generateJSON(bridged, docmostExtensions); } diff --git a/packages/mcp/src/lib/diff.ts b/packages/mcp/src/lib/diff.ts index befe047c..d0848997 100644 --- a/packages/mcp/src/lib/diff.ts +++ b/packages/mcp/src/lib/diff.ts @@ -101,10 +101,25 @@ function countUniqueLinks(doc: any): number { return hrefs.size; } +/** Count footnoteReference nodes anywhere under a node (reading order). */ +function countFootnoteRefs(node: any): number { + if (!node || typeof node !== "object") return 0; + let n = node.type === "footnoteReference" ? 1 : 0; + if (Array.isArray(node.content)) { + for (const child of node.content) n += countFootnoteRefs(child); + } + return n; +} + /** - * Parse the ordered list of integers from `[N]` footnote markers found in the - * BODY only (every top-level block before the first "Примечания..." notes - * heading; if no such heading, the whole doc). Returned in reading order. + * Ordered list of footnote marker numbers found in the BODY only (every + * top-level block before the first "Примечания..." notes heading; if no such + * heading, the whole doc), in reading order. + * + * Supports BOTH representations: + * - real `footnoteReference` nodes (the current footnote feature) — numbered + * 1..n by reading position, since their visible number is derived; + * - legacy `[N]` text markers (older translated docs) — the literal N. */ function footnoteMarkers(doc: any, notesHeading: string): number[] { const top: any[] = Array.isArray(doc?.content) ? doc.content : []; @@ -115,6 +130,16 @@ function footnoteMarkers(doc: any, notesHeading: string): number[] { plainText(n).trim() === notesHeading, ); const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; + + // Real footnoteReference nodes take precedence: when present, number them by + // reading position (their displayed number is not stored). + let refCount = 0; + for (const block of bodyBlocks) refCount += countFootnoteRefs(block); + if (refCount > 0) { + return Array.from({ length: refCount }, (_, i) => i + 1); + } + + // Fallback: legacy `[N]` text markers. const markers: number[] = []; const re = /\[(\d+)\]/g; for (const block of bodyBlocks) { diff --git a/packages/mcp/src/lib/docmost-schema.ts b/packages/mcp/src/lib/docmost-schema.ts index c45c275a..3d8d25d7 100644 --- a/packages/mcp/src/lib/docmost-schema.ts +++ b/packages/mcp/src/lib/docmost-schema.ts @@ -378,6 +378,83 @@ const Mention = Node.create({ }, }); +/** + * Footnote feature (mirror of packages/editor-ext/src/lib/footnote). Three + * nodes connected by `id`: + * - FootnoteReference: inline atom marker in the body (); + * - FootnotesList: a single bottom container (
); + * - FootnoteDefinition: one editable note keyed by id (
). + * The visible number is not stored; it is derived from reference order. + * + * priority 101 so this node's parse rule beats the Superscript mark's + * rule (otherwise an empty reference is parsed as an empty superscript + * mark and dropped). Keep in sync with editor-ext. + */ +const FootnoteReference = Node.create({ + name: "footnoteReference", + priority: 101, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-id"), + renderHTML: (attrs: Record) => + attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "sup[data-footnote-ref]", priority: 100 }]; + }, + renderHTML({ HTMLAttributes }) { + return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }]; + }, +}); + +const FootnotesList = Node.create({ + name: "footnotesList", + group: "block", + content: "footnoteDefinition+", + isolating: true, + selectable: false, + defining: true, + parseHTML() { + return [{ tag: "section[data-footnotes]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0]; + }, +}); + +const FootnoteDefinition = Node.create({ + name: "footnoteDefinition", + content: "paragraph+", + defining: true, + isolating: true, + selectable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-id"), + renderHTML: (attrs: Record) => + attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "div[data-footnote-def]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0]; + }, +}); + /** Inline KaTeX expression. Carries the LaTeX source in `text`. */ const MathInline = Node.create({ name: "mathInline", @@ -1069,6 +1146,9 @@ export const docmostExtensions = [ TableCell, TableHeader, Mention, + FootnoteReference, + FootnotesList, + FootnoteDefinition, MathInline, MathBlock, Details, diff --git a/packages/mcp/src/lib/markdown-converter.ts b/packages/mcp/src/lib/markdown-converter.ts index cbaa7042..4e35c995 100644 --- a/packages/mcp/src/lib/markdown-converter.ts +++ b/packages/mcp/src/lib/markdown-converter.ts @@ -430,6 +430,30 @@ export function convertProseMirrorToMarkdown(content: any): string { return `@${escapeHtmlText(mentionLabel)}`; } + case "footnoteReference": { + // Pandoc/GFM inline marker. The number is derived (not stored), so the + // id is the stable anchor. + const fnId = node.attrs?.id || ""; + return fnId ? `[^${fnId}]` : ""; + } + + case "footnotesList": + // The container renders its definitions, each on its own `[^id]: ...` + // line. A blank line separates the body from the notes block. + return nodeContent.map(processNode).join("\n"); + + case "footnoteDefinition": { + const defId = node.attrs?.id || ""; + // Collapse the definition's paragraphs into a single line; multi-line + // footnotes are a v2 refinement. + const defText = nodeContent + .map(processNode) + .join(" ") + .replace(/\s*\n+\s*/g, " ") + .trim(); + return defId ? `[^${defId}]: ${defText}` : ""; + } + case "attachment": { // BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but // the schema stores name/url (plus mime/size/attachmentId). Emit the diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index d8fba091..98269aff 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -264,6 +264,66 @@ export function noteItem(inlineNodes: any[]): any { }; } +/** + * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id: + * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } + * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). + */ +export function footnoteDefinition(id: string, inlineNodes: any[]): any { + const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; + return { + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", attrs: { id: freshId() }, content }], + }; +} + +/** + * Replace every `[N]` body marker and `\u0000FN\u0000` comment placeholder in + * an inline content array with a real `footnoteReference` node, in reading + * order. `onMarker` is called for each replaced marker (with the original `[N]` + * number or the placeholder index) and returns the fresh footnote id to attach + * to the inserted node. Mutates `inline` in place. + */ +function replaceMarkersWithReferences( + inline: any[], + onMarker: (info: { oldNum?: number; phIdx?: number }) => string, +): void { + const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; + for (let i = 0; i < inline.length; i++) { + const n = inline[i]; + if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") { + continue; + } + if (!re.test(n.text)) continue; + re.lastIndex = 0; + + const marks = Array.isArray(n.marks) ? n.marks : []; + const parts: any[] = []; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(n.text)) !== null) { + if (m.index > last) { + parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] }); + } + const oldNum = m[1] != null ? Number(m[1]) : undefined; + const phIdx = m[2] != null ? Number(m[2]) : undefined; + const fnId = onMarker({ oldNum, phIdx }); + parts.push({ type: "footnoteReference", attrs: { id: fnId } }); + last = m.index + m[0].length; + } + if (last < n.text.length) { + parts.push({ ...n, text: n.text.slice(last), marks: [...marks] }); + } + // Drop any zero-length text runs the slicing may have produced. + const cleaned = parts.filter( + (p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0), + ); + inline.splice(i, 1, ...cleaned); + i += cleaned.length - 1; + } +} + /** * Convert a comment's markdown (e.g. `**Lead.** body...`) into inline * ProseMirror nodes. @@ -388,54 +448,91 @@ export function commentsToFootnotes( } const consumed: string[] = []; - const noteByPh = new Map(); + const noteInlineByPh = new Map(); (Array.isArray(comments) ? comments : []).forEach((c, i) => { if (!c || !c.selection) return; // Collision-proof sentinel delimited by NUL control chars, which never occur - // in real Docmost prose — so the renumber regex below cannot mistake any body - // text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is - // transient: the placeholder round-trips within this function (insertMarkerAfter - // inserts it, the renumber pass replaces it with "[N]"), so it never persists - // in a returned/pushed document. + // in real Docmost prose - so the marker regex cannot mistake any body text + // (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is + // transient: the placeholder is inserted here and replaced by a + // footnoteReference node below; it never persists in a returned document. const ph = `\u0000FN${i}\u0000`; - // insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh - // the `top` / `notesList` references that point into it. + // insertMarkerAfter returns a NEW cloned doc; reassign `working`. const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, { beforeBlock: notesIdx, }); if (!r.inserted) return; working = r.doc; - noteByPh.set(ph, noteItem(mdToInlineNodes(c.content))); + noteInlineByPh.set(ph, mdToInlineNodes(c.content)); consumed.push(c.id); }); // Re-resolve references into the (possibly re-cloned) working doc. const top2: any[] = Array.isArray(working.content) ? working.content : []; - const notesList2 = top2 - .slice(notesIdx) - .find((n) => isObject(n) && n.type === "orderedList"); + const notesIdx2 = top2.findIndex( + (n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading, + ); + const oldListIndex = top2.findIndex( + (n) => isObject(n) && n.type === "orderedList", + ); + const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null; if (!notesList2) { throw new Error("notes orderedList not found"); } - const oldNotes: any[] = Array.isArray(notesList2.content) + // Inline content of each existing note (listItem -> paragraph -> inline). + const oldNoteInline = (Array.isArray(notesList2.content) ? notesList2.content - : []; - const newNotes: any[] = []; - let seq = 0; - // Match either an existing "[N]" marker or a NUL-delimited "\u0000FN\u0000" - // placeholder, in reading order across the body (blocks before the notes heading). - const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; - // Same range regex setCalloutRange uses to detect the disclaimer callout's - // "[1]…[K]" range; used here to decide whether a top-level callout is the - // disclaimer (skip) or an ordinary callout (renumber normally). + : [] + ).map((item: any) => { + const para = + isObject(item) && Array.isArray(item.content) + ? item.content.find((c: any) => isObject(c) && c.type === "paragraph") + : null; + return para && Array.isArray(para.content) ? para.content : []; + }); + + // Walk the body in reading order, turning each "[N]" / placeholder marker into + // a real footnoteReference node and collecting its definition inline content. + const definitions: any[] = []; const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/; - for (let i = 0; i < notesIdx; i++) { - // Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote - // marker and is synced separately by setCalloutRange. Renumbering it here - // would consume note slots and corrupt the sequence. Other top-level - // callouts may carry legitimate "[N]" body markers and are renumbered. + + // Recursively visit inline arrays inside a block (paragraph, heading, callout + // child paragraphs, table cells, ...), preserving document reading order. + const visitInlineArrays = (container: any): void => { + if (!isObject(container) || !Array.isArray(container.content)) return; + const hasText = container.content.some( + (n: any) => isObject(n) && n.type === "text", + ); + if (hasText) { + replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => { + const fnId = freshId(); + if (oldNum != null) { + const inline = oldNoteInline[oldNum - 1]; + // Every existing body marker MUST map to a real note. An out-of-range + // marker means the document is internally inconsistent; fail loudly. + if (inline === undefined) { + throw new Error( + `footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`, + ); + } + definitions.push(footnoteDefinition(fnId, inline)); + } else { + const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || []; + definitions.push(footnoteDefinition(fnId, inline)); + } + return fnId; + }); + } else { + for (const child of container.content) visitInlineArrays(child); + } + }; + + const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex; + for (let i = 0; i < notesBoundary; i++) { + // Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote + // marker and is synced separately by setCalloutRange. if ( isObject(top2[i]) && top2[i].type === "callout" && @@ -443,35 +540,22 @@ export function commentsToFootnotes( ) { continue; } - walk(top2[i], (node) => { - if (node.type !== "text" || typeof node.text !== "string") return; - node.text = node.text.replace(re, (_m: string, oldNum: string, phIdx: string) => { - if (oldNum != null) { - const note = oldNotes[Number(oldNum) - 1]; - // Every existing body marker MUST map to a real note. An out-of-range - // marker means the document is internally inconsistent; fail loudly - // rather than silently dropping the note and desyncing the callout. - if (note === undefined) { - throw new Error( - `footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`, - ); - } - newNotes.push(note); - } else { - newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`)); - } - return `[${++seq}]`; - }); - }); + visitInlineArrays(top2[i]); } - // Reorder the notes list IN PLACE on `working` first, THEN sync the callout - // range. setCalloutRange clones `working`, so the reordered notes (mutated - // before the clone) are carried into its result automatically. No null-filter - // here: marker count and note count must stay exactly equal (the out-of-range - // guard above guarantees no undefined entry is ever pushed). - notesList2.content = newNotes; - const synced = setCalloutRange(working, notesList2.content.length); + // Replace the old orderedList with a real footnotesList of the collected + // definitions (reading order). If there are no definitions, drop the list. + if (definitions.length > 0) { + top2[oldListIndex] = { + type: "footnotesList", + content: definitions, + }; + } else { + top2.splice(oldListIndex, 1); + } + + // Sync the disclaimer callout range to the new note count. + const synced = setCalloutRange(working, definitions.length); return { doc: synced.doc, consumed }; } diff --git a/packages/mcp/test/unit/footnotes.test.mjs b/packages/mcp/test/unit/footnotes.test.mjs new file mode 100644 index 00000000..4b1ee6ab --- /dev/null +++ b/packages/mcp/test/unit/footnotes.test.mjs @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js"; +import { markdownToProseMirror } from "../../build/lib/collaboration.js"; + +/** Recursively collect every node of `type`. */ +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) { + for (const c of node.content) findAll(c, type, acc); + } + return acc; +} + +const footnoteDoc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Water" }, + { type: "footnoteReference", attrs: { id: "fn1" } }, + { type: "text", text: " and clay" }, + { type: "footnoteReference", attrs: { id: "fn2" } }, + { type: "text", text: "." }, + ], + }, + { + type: "footnotesList", + content: [ + { + type: "footnoteDefinition", + attrs: { id: "fn1" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "First note." }] }, + ], + }, + { + type: "footnoteDefinition", + attrs: { id: "fn2" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Second note." }] }, + ], + }, + ], + }, + ], +}; + +test("JSON -> Markdown emits pandoc footnote syntax", () => { + const md = convertProseMirrorToMarkdown(footnoteDoc); + assert.match(md, /\[\^fn1\]/); + assert.match(md, /\[\^fn2\]/); + assert.match(md, /\[\^fn1\]: First note\./); + assert.match(md, /\[\^fn2\]: Second note\./); +}); + +test("Markdown -> JSON rebuilds footnote nodes", async () => { + const md = convertProseMirrorToMarkdown(footnoteDoc); + const json = await markdownToProseMirror(md); + + const refs = findAll(json, "footnoteReference"); + const list = findAll(json, "footnotesList"); + const defs = findAll(json, "footnoteDefinition"); + + assert.equal(refs.length, 2); + assert.deepEqual( + refs.map((r) => r.attrs.id), + ["fn1", "fn2"], + ); + assert.equal(list.length, 1); + assert.equal(defs.length, 2); + assert.deepEqual( + defs.map((d) => d.attrs.id), + ["fn1", "fn2"], + ); +}); + +test("JSON -> MD -> JSON preserves footnote ids and text", async () => { + const md = convertProseMirrorToMarkdown(footnoteDoc); + const json = await markdownToProseMirror(md); + const md2 = convertProseMirrorToMarkdown(json); + + // The second markdown serialization carries the same markers + definitions. + assert.match(md2, /\[\^fn1\]/); + assert.match(md2, /\[\^fn2\]/); + assert.match(md2, /\[\^fn1\]: First note\./); + assert.match(md2, /\[\^fn2\]: Second note\./); +}); + +test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => { + // Markdown that DOCUMENTS footnote syntax inside a code fence. The example + // definition line must be preserved verbatim inside the code block and not + // pulled out into a real footnotesList / footnoteDefinition. + const md = [ + "Intro text.", + "", + "```markdown", + "Body[^demo]", + "", + "[^demo]: example definition", + "```", + "", + "Outro.", + ].join("\n"); + + const json = await markdownToProseMirror(md); + + // No real footnote nodes were extracted from the code block. + assert.equal(findAll(json, "footnotesList").length, 0); + assert.equal(findAll(json, "footnoteDefinition").length, 0); + + // The example definition line survives somewhere in the code block text. + const codeBlocks = findAll(json, "codeBlock"); + assert.ok(codeBlocks.length >= 1, "code block present"); + const codeText = JSON.stringify(json); + assert.match(codeText, /\[\^demo\]: example definition/); +}); diff --git a/packages/mcp/test/unit/transforms.test.mjs b/packages/mcp/test/unit/transforms.test.mjs index 3f66593c..f7999113 100644 --- a/packages/mcp/test/unit/transforms.test.mjs +++ b/packages/mcp/test/unit/transforms.test.mjs @@ -34,6 +34,18 @@ const li = (text) => ({ const doc = (...children) => ({ type: "doc", content: children }); const snapshot = (v) => JSON.parse(JSON.stringify(v)); +// Collect every footnoteReference id under a node, in reading order. +const collectRefIds = (node, acc = []) => { + if (!node || typeof node !== "object") return acc; + if (node.type === "footnoteReference") acc.push(node.attrs?.id); + if (Array.isArray(node.content)) { + for (const c of node.content) collectRefIds(c, acc); + } + return acc; +}; +// Plain text of a footnoteDefinition. +const defText = (def) => blockText(def); + // --------------------------------------------------------------------------- // blockText / walk / getList // --------------------------------------------------------------------------- @@ -173,21 +185,30 @@ test("commentsToFootnotes anchors comments and renumbers by position", () => { const { doc: out, consumed } = commentsToFootnotes(d, comments); assert.deepEqual(consumed.sort(), ["cA", "cB"]); - // Markers in reading order: p1 "apple"->[1], p2 existing->[2], p3 "banana"->[3] - assert.match(blockText(out.content[1]), /\[1\]/); - assert.match(blockText(out.content[2]), /\[2\]/); - assert.match(blockText(out.content[3]), /\[3\]/); + // Real footnoteReference nodes were inserted at p1 (apple), p2 (existing), + // p3 (banana), in reading order — the old `[N]` text markers are gone. + const refIds = collectRefIds(out); + assert.equal(refIds.length, 3); + // Body paragraphs p1..p3 no longer carry literal [N] text markers. + assert.doesNotMatch(blockText(out.content[1]), /\[\d+\]/); + assert.doesNotMatch(blockText(out.content[2]), /\[\d+\]/); + assert.doesNotMatch(blockText(out.content[3]), /\[\d+\]/); - // No stray placeholders remain. - const allText = blockText(out); - assert.doesNotMatch(allText, / F\d+ /); + // No stray NUL placeholders remain. + assert.doesNotMatch(blockText(out), /\u0000/); - // Notes list reordered to [apple, existing, banana] (reading order). - const list = out.content.find((n) => n.type === "orderedList"); + // The bottom footnotesList holds the definitions in reading order, each keyed + // by the matching reference id. + const list = out.content.find((n) => n.type === "footnotesList"); + assert.ok(list, "footnotesList present"); assert.equal(list.content.length, 3); - assert.equal(blockText(list.content[0]), "apple note"); - assert.equal(blockText(list.content[1]), "existing note one"); - assert.equal(blockText(list.content[2]), "banana note"); + assert.deepEqual( + list.content.map((d) => d.attrs.id), + refIds, + ); + assert.equal(defText(list.content[0]), "apple note"); + assert.equal(defText(list.content[1]), "existing note one"); + assert.equal(defText(list.content[2]), "banana note"); // Callout range synced to 3 notes. assert.match(blockText(out.content[0]), /\[1\]…\[3\]/); @@ -224,15 +245,16 @@ test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched", // The literal "F1"/"FN2"/"F12" prose is preserved verbatim (no bogus // footnotes, no eaten spaces around them). assert.match(bodyText, /Press F1 for help, model FN2 and F12 for tools/); - // Exactly one real footnote marker was produced, at the anchored word. - const markerCount = (bodyText.match(/\[\d+\]/g) || []).length; - assert.equal(markerCount, 1); - assert.match(bodyText, /apple \[1\]/); + // Exactly one real footnoteReference node was produced, at the anchored word. + const refIds = collectRefIds(out); + assert.equal(refIds.length, 1); // Exactly one note in the list — "F1"/"FN2"/"F12" did not spawn extra notes. - const list = out.content.find((n) => n.type === "orderedList"); + const list = out.content.find((n) => n.type === "footnotesList"); + assert.ok(list, "footnotesList present"); assert.equal(list.content.length, 1); - assert.equal(blockText(list.content[0]), "apple note"); + assert.equal(list.content[0].attrs.id, refIds[0]); + assert.equal(defText(list.content[0]), "apple note"); // No stray placeholder sentinel remains anywhere: the NUL-delimited sentinel // is fully consumed by the renumber pass, so no raw NUL control char persists @@ -287,17 +309,25 @@ test("commentsToFootnotes renumbers body callouts but skips the disclaimer range assert.deepEqual(consumed, []); // The disclaimer's "[1]…[K]" range is NOT treated as body markers: it stays - // a range and is synced to the note count (2), not renumbered into [1],[2]. + // a range and is synced to the note count (2), not turned into references. assert.match(blockText(out.content[0]), /\[1\]…\[2\]/); - // The body callout's [1] is renumbered as a real reading-order marker. - assert.match(blockText(out.content[1]), /noted \[1\] above/); - // The following paragraph's [2] keeps reading order. - assert.match(blockText(out.content[2]), /with \[2\] too/); + // The body callout's [1] and the paragraph's [2] became footnoteReference + // nodes in reading order (the literal text markers are gone). + const refIds = collectRefIds(out); + assert.equal(refIds.length, 2); + assert.match(blockText(out.content[1]), /noted +above/); // [1] -> node, no text + assert.match(blockText(out.content[2]), /with +too/); // [2] -> node, no text - // Notes list still has the two original notes in order. - const list = out.content.find((n) => n.type === "orderedList"); + // The footnotesList holds the two original notes in reading order, keyed to + // the new reference ids. + const list = out.content.find((n) => n.type === "footnotesList"); + assert.ok(list, "footnotesList present"); assert.equal(list.content.length, 2); - assert.equal(blockText(list.content[0]), "first note"); - assert.equal(blockText(list.content[1]), "second note"); + assert.deepEqual( + list.content.map((d) => d.attrs.id), + refIds, + ); + assert.equal(defText(list.content[0]), "first note"); + assert.equal(defText(list.content[1]), "second note"); });