Must-fix: - insertInlineFootnote could glue a footnoteReference inside an EXISTING definition (nested footnotesList, or a bare footnoteDefinition with no list wrapper), which canonicalize then dropped as an orphan — silently losing the definition's prose. Now: (a) the body/notes boundary is computed from the first top-level block that IS or CONTAINS (recursively) a footnotesList/ footnoteDefinition, not just a top-level list; and (b) the insertNodesAfterAnchor core skips footnotesList/footnoteDefinition subtrees entirely (skipSubtreeTypes), so an anchor whose only match is inside a definition -> inserted:false (clean abort, no write). Added tests: nested-definition, bare-definition, and body-before-nested-list-still-inserts. - editor-ext footnote-canonicalize header listed `markdownToProseMirror` among the canonicalizing MCP paths; it is the NON-canonicalizing primitive. Replaced with `markdownToProseMirrorCanonical` (+ note that the plain primitive is for comment bodies) and added copy_page_content. - Client paste: canonicalizePastedFootnotes now skips a definitions-ONLY paste (no footnoteReference anywhere) — canonicalizing it would strip the reference-less list and yield an EMPTY paste. Added a test. Suggestions: - docmost_transform now runs validateDocStructure/validateDocUrls on the RAW transform output BEFORE canonicalizeFootnotes (mirrors updatePageJson), so a too-deep doc gives the intended max-depth error instead of a stack overflow. - docmost_transform tool description now states the RESULT is footnote-canonical (dryRun diff may show tidy-ups; idempotent after first run). - insertFootnote: dropped the dead `result ? … : undefined` ternaries and the `as any` casts (result is always set by the time we return; the not-found path throws and aborts mutatePage). `const r = result!;`. Tests / architecture: - Added a LIVE-plugin golden case: the real footnoteSyncPlugin leaves a list with non-empty content after it in place, and canonicalize agrees (placement parity is now a driven property, not a hand-set expected). - Added generateFootnoteId uuidv7 shape + uniqueness test. - Item 9: added the ENFORCEMENT-RULE comments at the server parseProsemirrorContent and the MCP canonicalizer header (any NEW full-doc persist path MUST canonicalize; fragments/append/prepend and comment bodies MUST NOT). Kept per-call-site over a brittle grep CI test (the replace-vs-fragment + comment-vs-page nuance makes a single wrapper unsafe). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { Editor } from "@tiptap/core";
|
|
import { Document } from "@tiptap/extension-document";
|
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
import { Text } from "@tiptap/extension-text";
|
|
import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model";
|
|
import {
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
FOOTNOTE_REFERENCE_NAME,
|
|
FOOTNOTE_DEFINITION_NAME,
|
|
FOOTNOTES_LIST_NAME,
|
|
} from "@docmost/editor-ext";
|
|
import { canonicalizePastedFootnotes } from "./markdown-clipboard";
|
|
|
|
/**
|
|
* A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is
|
|
* applied with a manual transaction (handlePaste returns true), so it bypasses
|
|
* the editor's footnoteSyncPlugin — which never reorders an existing list. These
|
|
* tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted
|
|
* out-of-order markdown footnote block come out canonical (issue #228).
|
|
*/
|
|
|
|
const extensions = [
|
|
Document,
|
|
Paragraph,
|
|
Text,
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
];
|
|
|
|
function makeSchema() {
|
|
const editor = new Editor({ extensions, content: { type: "doc", content: [] } });
|
|
const { schema } = editor;
|
|
return { editor, schema };
|
|
}
|
|
|
|
/** List footnote def ids of the (single) footnotesList in a slice, in order. */
|
|
function listIds(slice: Slice): string[] {
|
|
const out: string[] = [];
|
|
slice.content.forEach((node: PMNode) => {
|
|
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
|
node.content.forEach((def: PMNode) => {
|
|
if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id);
|
|
});
|
|
}
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function hasList(slice: Slice): boolean {
|
|
let found = false;
|
|
slice.content.forEach((n: PMNode) => {
|
|
if (n.type.name === FOOTNOTES_LIST_NAME) found = true;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
describe("canonicalizePastedFootnotes", () => {
|
|
it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => {
|
|
const { editor, schema } = makeSchema();
|
|
// Body references c, a, b (and again a => reuse); definitions a, b, c, z
|
|
// (z is an orphan) — the exact shape a markdown paste produces.
|
|
const slice = new Slice(
|
|
Fragment.fromArray([
|
|
schema.nodes.paragraph.create(null, [
|
|
schema.text("body "),
|
|
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }),
|
|
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
|
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
|
|
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
|
]),
|
|
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("note A")]),
|
|
]),
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
|
]),
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("note C")]),
|
|
]),
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("orphan")]),
|
|
]),
|
|
]),
|
|
]),
|
|
0,
|
|
0,
|
|
);
|
|
|
|
const out = canonicalizePastedFootnotes(slice, schema);
|
|
// Reference order, orphan z dropped, reused a appears once.
|
|
expect(listIds(out)).toEqual(["c", "a", "b"]);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => {
|
|
// A paste that reuses an id defined in the TARGET doc must NOT gain a
|
|
// synthesized empty definition here — it carries no footnotesList of its own.
|
|
const { editor, schema } = makeSchema();
|
|
const slice = new Slice(
|
|
Fragment.from(
|
|
schema.nodes.paragraph.create(null, [
|
|
schema.text("see "),
|
|
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
|
]),
|
|
),
|
|
0,
|
|
0,
|
|
);
|
|
const out = canonicalizePastedFootnotes(slice, schema);
|
|
expect(hasList(out)).toBe(false);
|
|
expect(out).toBe(slice); // returned unchanged (same reference)
|
|
editor.destroy();
|
|
});
|
|
|
|
it("leaves a definitions-ONLY paste untouched (no references -> no empty paste)", () => {
|
|
// A whole-block paste of ONLY definitions (a footnotesList with no matching
|
|
// footnoteReference anywhere in the selection). Canonicalizing it would strip
|
|
// the reference-less list -> an EMPTY paste, losing the pasted text. The hook
|
|
// must leave such a block untouched.
|
|
const { editor, schema } = makeSchema();
|
|
const slice = new Slice(
|
|
Fragment.fromArray([
|
|
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("note A")]),
|
|
]),
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
|
]),
|
|
]),
|
|
]),
|
|
0,
|
|
0,
|
|
);
|
|
const out = canonicalizePastedFootnotes(slice, schema);
|
|
expect(out).toBe(slice); // returned unchanged (same reference, content kept)
|
|
expect(listIds(out)).toEqual(["a", "b"]);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("leaves an open (partial) slice untouched even if it carries a list", () => {
|
|
// An open slice (openStart/openEnd > 0) is a partial selection, not a
|
|
// standalone block, so it is returned as-is BEFORE any footnote handling.
|
|
const { editor, schema } = makeSchema();
|
|
const slice = new Slice(
|
|
Fragment.fromArray([
|
|
schema.nodes.paragraph.create(null, [
|
|
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
|
]),
|
|
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
|
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
|
schema.nodes.paragraph.create(null, [schema.text("A")]),
|
|
]),
|
|
]),
|
|
]),
|
|
1,
|
|
1,
|
|
);
|
|
const out = canonicalizePastedFootnotes(slice, schema);
|
|
expect(out).toBe(slice);
|
|
editor.destroy();
|
|
});
|
|
});
|