d7d8db2102
Every image now serializes as ``; non-default layout/identity attrs
that markdown cannot express ride along in an attached `<!--img {…}-->` comment
on the same line, replacing the prior "image-with-attrs -> raw <img>" split for
the top-level path:
 <!--img {"width":"420","align":"left","attachmentId":"…"}-->
Keys (emitted only when non-default, stable order): width, height, align, size,
aspectRatio, attachmentId, caption, title. Numeric sizing attrs are stringified
in the payload (the import side reads DOM attributes back as strings), so a
numeric `width:420` round-trips byte-stably instead of churning `420 -> "420"`.
attachedCommentFor defuses any `--` in a value (e.g. a caption containing the
comment-closing `-->`) so the payload can never close the comment early.
Align default unified to "center" (#293 canon #4): editor-ext declares
image.align default "center" while this package's schema declared null — keeping
null would make the clean `` form dead code (every editor image is
"center"). Now the schema default is "center" (docmost-schema image align, with
explicit parseHTML/renderHTML), canonicalize KNOWN_DEFAULTS drops align=="center"
for image, and the serializer omits align when it is null OR "center". A null
align collapses to "center" on re-import (a null align is not a distinct editor
state) — stable, no ping-pong. Only left/right emit a comment.
Import: applyCommentDirectives gains an `img` handler that targets the comment's
previousElementSibling <img> and writes each decoded key to the DOM attribute
the schema reads (align, width, height, data-size, data-aspect-ratio,
data-attachment-id, data-caption, title), then removes the comment. Attached
only: a standalone `<!--img-->` with no adjacent image is inert. Fail-open on
malformed JSON / unknown keys.
Raw-HTML path unchanged in spirit: images inside columns/cells keep the
`<img …>` form (comments are dropped by the DOM parse stage); imageToHtml now
omits a redundant align="center" to match the unified default.
Tests: new image-comment.test.ts (21 cases incl. caption == `-->`, numeric-size
byte-stability, image-in-column <img> form, fail-open). Goldens updated
deliberately: markdown-roundtrip-spoiler-caption (captioned image -> comment
form), markdown-converter-gaps spec 14/15 (title now round-trips via comment;
column image drops redundant align), canonicalize-extra (center+null dropped,
left kept).
package vitest: 498 passed | 1 expected-fail; tsc clean. git-sync (rebuilt
build): 268 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
/**
|
|
* Semantic canonicalization of ProseMirror/TipTap documents for the round-trip
|
|
* idempotency check (SPEC §11, "Task #0", option (b): compare a CANONICALIZED
|
|
* form rather than raw bytes).
|
|
*
|
|
* `markdownToProseMirror` reconstructs schema DEFAULT attributes (e.g.
|
|
* `indent: null` where the source omitted it) and regenerates per-block ids on
|
|
* every import. A raw deep-equal of the source doc against the re-imported doc
|
|
* therefore diverges even when the two are semantically identical. This module
|
|
* normalizes a document so that two semantically-equal docs compare deep-equal
|
|
* regardless of block ids and absent-vs-explicit-default-null attributes.
|
|
*
|
|
* It is a self-contained module with no external dependencies.
|
|
*/
|
|
|
|
/**
|
|
* Known NON-NULL schema defaults that `markdownToProseMirror` materializes on
|
|
* import, keyed by node/mark type → { attr: defaultValue }.
|
|
*
|
|
* Why this exists: `canonicalizeAttrs` already treats an absent attr as
|
|
* equivalent to an explicit `null`/`undefined`. But several Docmost schema
|
|
* attributes default to a NON-null value, so import fills them in even when the
|
|
* source omitted them — making "attr absent" diverge from "attr at its default
|
|
* value" under a raw deep-equal. To keep "absent ≡ explicit-default", we ALSO
|
|
* drop any attr whose value equals its known schema default. A non-default
|
|
* value (e.g. `orderedList.start: 5`) is NOT a default, so it is KEPT.
|
|
*
|
|
* Every entry below was read from `packages/docmost-client/src/lib/
|
|
* docmost-schema.ts` (the line refs are the exact `default:` declarations) and
|
|
* confirmed to be materialized by an export→import→export round-trip:
|
|
* - mark `link` target / rel — DocmostAttributes + StarterKit link.
|
|
* StarterKit's link extension defaults `target: "_blank"` and
|
|
* `rel: "noopener noreferrer nofollow"`; both materialize on import
|
|
* (empirically confirmed) even when the source had only `href`.
|
|
* - mark `comment` resolved — docmost-schema.ts L213-214 (`default: false`).
|
|
* - node `orderedList` start — provided by StarterKit's orderedList
|
|
* (`default: 1`); materializes on import (empirically confirmed).
|
|
* - node `drawio`/`excalidraw`/`video`/`youtube`/`embed` align — the diagram
|
|
* attribute set and the media nodes declare `align: { default: "center" }`
|
|
* (docmost-schema.ts L745-750 diagramAttributes; L564 video; L626 youtube;
|
|
* L667 embed). The diagram `align` is the one the round-trip materializes
|
|
* (docmost-schema.ts L745); the media/embed entries normalize the SAME
|
|
* `align` default for consistency. Note: this only normalizes `align` —
|
|
* full canonical stability of `embed` is separately limited by the
|
|
* converter coercing numeric `width`/`height` to strings, which is outside
|
|
* canonicalize's scope.
|
|
*
|
|
* NOTE: `image` align now defaults to `"center"` — unified with editor-ext
|
|
* (#293 canon #4). It is listed below so a canonical image drops `align` when
|
|
* it equals "center" (absent ≡ default), exactly like the diagram/media nodes.
|
|
* A null align is likewise dropped by the null-drop rule and re-imports as the
|
|
* "center" default, so bare `` images stay canonically clean.
|
|
*/
|
|
const KNOWN_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
// mark types
|
|
link: {
|
|
target: "_blank",
|
|
rel: "noopener noreferrer nofollow",
|
|
},
|
|
comment: {
|
|
resolved: false,
|
|
},
|
|
// node types
|
|
orderedList: {
|
|
start: 1,
|
|
},
|
|
image: {
|
|
align: "center",
|
|
},
|
|
drawio: {
|
|
align: "center",
|
|
},
|
|
excalidraw: {
|
|
align: "center",
|
|
},
|
|
video: {
|
|
align: "center",
|
|
},
|
|
youtube: {
|
|
align: "center",
|
|
},
|
|
embed: {
|
|
align: "center",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Prune an `attrs` object in place on a fresh copy: drop keys whose value is
|
|
* `null` or `undefined` (an absent attribute and an explicit default of `null`
|
|
* are semantically equivalent here). Optionally also drop a node-level `id`
|
|
* (block ids are regenerated on import, SPEC §11). ALSO drop any attr whose
|
|
* value equals the node/mark `type`'s known NON-null schema default
|
|
* (`KNOWN_DEFAULTS`), so "attr absent" ≡ "attr at its default value" — without
|
|
* this, the import-materialized `link.target`/`comment.resolved`/
|
|
* `orderedList.start`/diagram `align` defaults would be a phantom diff. Every
|
|
* non-default attribute value is KEPT (level, language, src, href, commentId,
|
|
* width, a non-default `start`/`align`, ...).
|
|
*
|
|
* Returns the pruned attrs object, or `undefined` if nothing meaningful is
|
|
* left (so the caller can drop the `attrs` key entirely: `{attrs:{}}` ≡ no
|
|
* attrs).
|
|
*/
|
|
function canonicalizeAttrs(
|
|
attrs: Record<string, unknown>,
|
|
dropId: boolean,
|
|
type: string | undefined,
|
|
): Record<string, unknown> | undefined {
|
|
const defaults = type ? KNOWN_DEFAULTS[type] : undefined;
|
|
const out: Record<string, unknown> = {};
|
|
// Stable key order so a JSON.stringify of the canonical form is comparable
|
|
// regardless of the input's key order.
|
|
for (const key of Object.keys(attrs).sort()) {
|
|
// Block ids are regenerated on import; drop them on NODE attrs only.
|
|
if (dropId && key === "id") continue;
|
|
const value = attrs[key];
|
|
// Absent ≡ explicit-default-null/undefined.
|
|
if (value === null || value === undefined) continue;
|
|
// Absent ≡ explicit known non-null default (e.g. link.target="_blank").
|
|
// A non-default value (e.g. orderedList.start=5) does NOT match, so it is
|
|
// kept. The `comment` mark's `commentId` is never a default, so it always
|
|
// survives (SPEC §3); only its `resolved: false` default is normalized away.
|
|
if (defaults && key in defaults && value === defaults[key]) continue;
|
|
out[key] = value;
|
|
}
|
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
}
|
|
|
|
/**
|
|
* Return a DEEP COPY of a ProseMirror node tree, canonicalized so that two
|
|
* semantically-equal documents compare deep-equal. Rules (applied recursively
|
|
* to the node, its `content`, and its `marks`):
|
|
*
|
|
* 1. Remove node-level `attrs.id` (regenerated on import). Mark attrs are NOT
|
|
* touched for `id` (marks carry no block id; only their meaningful attrs).
|
|
* 2. In any `attrs` object (node OR mark) drop keys whose value is `null`/
|
|
* `undefined` (absent ≡ explicit default null) OR equals that node/mark
|
|
* type's known non-null schema default (absent ≡ explicit default).
|
|
* Keep every non-default value. The type is passed into the attrs
|
|
* normalizer so it can look up `KNOWN_DEFAULTS`.
|
|
* 3. If an `attrs` object becomes empty after pruning, drop the `attrs` key.
|
|
* 4. Preserve `marks` (including the `comment` mark and its `commentId` — a
|
|
* meaningful anchor per SPEC §3; never strip it).
|
|
* 5. Preserve `text`, `type`, and `content` order exactly.
|
|
* 6. Never mutate the input.
|
|
*/
|
|
export function canonicalizeContent(node: any): any {
|
|
if (Array.isArray(node)) {
|
|
return node.map((child) => canonicalizeContent(child));
|
|
}
|
|
if (node === null || typeof node !== "object") {
|
|
// Primitive leaf (string/number/boolean/null): returned as-is.
|
|
return node;
|
|
}
|
|
|
|
// A node is a mark when it has a `type` but never carries block `content`
|
|
// and lives inside a `marks` array. We cannot tell from the node alone, so
|
|
// we distinguish at the recursion site: node `attrs` drop `id`, mark `attrs`
|
|
// do not. This is handled by passing a `dropId` flag down for the `attrs`
|
|
// key specifically (nodes) vs the `marks[].attrs` path (marks).
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of Object.keys(node)) {
|
|
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
|
|
// Node-level attrs: drop the block id, null/undefined attrs, and any
|
|
// attr at this node type's known non-null schema default.
|
|
const canon = canonicalizeAttrs(
|
|
node.attrs as Record<string, unknown>,
|
|
true,
|
|
typeof node.type === "string" ? node.type : undefined,
|
|
);
|
|
if (canon !== undefined) out.attrs = canon;
|
|
// else: drop the `attrs` key entirely (rule 3).
|
|
} else if (key === "marks" && Array.isArray(node.marks)) {
|
|
// Marks: keep them all (incl. comment); canonicalize their attrs but do
|
|
// NOT drop `id` (a mark's `id` would be a meaningful attr, not a block
|
|
// id). An empty marks array is dropped so `marks:[]` ≡ no marks.
|
|
const marks = (node.marks as any[]).map((mark) => canonicalizeMark(mark));
|
|
if (marks.length > 0) out.marks = marks;
|
|
} else {
|
|
out[key] = canonicalizeContent(node[key]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Canonicalize a single mark: keep `type`, prune its `attrs` (null/undefined
|
|
* AND known non-null defaults dropped, empty attrs removed) but NEVER drop a
|
|
* mark's attribute as a "block id" — marks have no block id, only meaningful
|
|
* attrs (href, commentId, color, level, ...). Meaningful NON-default attrs
|
|
* survive (the `comment` mark's `commentId` is never a default, so it always
|
|
* survives — SPEC §3); only known defaults like `link.target="_blank"`,
|
|
* `link.rel="noopener…"` and `comment.resolved=false` are normalized away.
|
|
*/
|
|
function canonicalizeMark(mark: any): any {
|
|
if (mark === null || typeof mark !== "object") return mark;
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of Object.keys(mark)) {
|
|
if (key === "attrs" && mark.attrs && typeof mark.attrs === "object") {
|
|
const canon = canonicalizeAttrs(
|
|
mark.attrs as Record<string, unknown>,
|
|
false,
|
|
typeof mark.type === "string" ? mark.type : undefined,
|
|
);
|
|
if (canon !== undefined) out.attrs = canon;
|
|
} else {
|
|
out[key] = canonicalizeContent(mark[key]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Deep structural equality of two values that is key-order-insensitive.
|
|
* Used to compare canonical forms. (`canonicalizeContent` already emits
|
|
* `attrs` in a stable key order, but the top-level node keys preserve input
|
|
* order, so we compare structurally rather than by string.)
|
|
*/
|
|
function deepEqual(a: any, b: any): boolean {
|
|
if (a === b) return true;
|
|
if (typeof a !== typeof b) return false;
|
|
if (a === null || b === null) return a === b;
|
|
if (typeof a !== "object") return false;
|
|
|
|
const aIsArr = Array.isArray(a);
|
|
const bIsArr = Array.isArray(b);
|
|
if (aIsArr !== bIsArr) return false;
|
|
|
|
if (aIsArr) {
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i++) {
|
|
if (!deepEqual(a[i], b[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const aKeys = Object.keys(a);
|
|
const bKeys = Object.keys(b);
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
for (const k of aKeys) {
|
|
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
|
if (!deepEqual(a[k], b[k])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* True when two ProseMirror documents are semantically equal: equal after
|
|
* canonicalization (block ids stripped, absent-vs-default-null normalized).
|
|
*/
|
|
export function docsCanonicallyEqual(a: any, b: any): boolean {
|
|
return deepEqual(canonicalizeContent(a), canonicalizeContent(b));
|
|
}
|