From e0d0ddf66081e8ff3b9488e31dc5a882bd885555 Mon Sep 17 00:00:00 2001
From: Andrii Orlov <120495135+andrii-harbour@users.noreply.github.com>
Date: Thu, 2 Jul 2026 20:07:11 +0200
Subject: [PATCH 1/6] feat(document-api): tracked-change actions, comment
export
Adds side-targeted tracked-change decisions, tracked paragraph-property changes, tracked list attach support, comment export fixes, and list numbering metadata for the Document API.
The follow-up fixes cover replacement survivor decisions, pPrChange review/export behavior, Word-safe revision ids, no-user tracked guards, and synthetic tracked-change rows in both document-api and SuperDoc store comment export paths.
Verified by CI and targeted local/runtime checks.
Ported-From-Source-Repo: superdoc/orbit
Ported-From-Source-Commit: d743124ff44d3c515f2834d30d049391139db44f
Ported-Public-Prefix: superdoc/public
---
.../reference/_generated-manifest.json | 2 +-
.../document-api/reference/blocks/list.mdx | 59 +++++
.../reference/capabilities/get.mdx | 2 +-
apps/docs/document-api/reference/index.mdx | 2 +-
.../document-api/reference/lists/attach.mdx | 6 +-
.../document-api/reference/lists/index.mdx | 2 +-
.../reference/track-changes/decide.mdx | 14 ++
.../src/contract/operation-definitions.ts | 5 +-
packages/document-api/src/contract/schemas.ts | 32 +++
.../src/track-changes/track-changes.test.ts | 36 +++
.../src/track-changes/track-changes.ts | 58 ++++-
.../document-api/src/types/blocks.types.ts | 14 ++
.../dom/src/table/border-utils.test.ts | 9 +-
.../painters/dom/src/table/border-utils.ts | 5 +-
.../v1/core/commands/changeListLevel.js | 24 +-
.../v1/core/super-converter/SuperConverter.js | 30 ++-
.../helpers/generate-paragraph-properties.js | 17 +-
.../generate-paragraph-properties.test.js | 16 +-
.../w/pPrChange/pPrChange-translator.js | 58 +++++
.../w/pPrChange/pPrChange-translator.test.js | 21 ++
.../contract-conformance.test.ts | 22 +-
...ment-on-tracked-export.integration.test.ts | 128 ++++++++++
.../comment-reply-export.integration.test.ts | 88 +++++++
.../helpers/list-item-resolver.ts | 45 +++-
.../helpers/list-sequence-helpers.ts | 16 +-
.../helpers/tracked-change-resolver.ts | 30 +++
...mbering-tracked-export.integration.test.ts | 179 ++++++++++++++
...mbering-tracked-review.integration.test.ts | 160 +++++++++++++
.../plan-engine/blocks-wrappers.ts | 41 +++-
.../plan-engine/comments-wrappers.ts | 11 +-
.../plan-engine/executor.ts | 51 +++-
.../plan-engine/lists-wrappers.ts | 11 +-
.../plan-engine/register-executors.ts | 4 +-
.../plan-engine/track-changes-wrappers.ts | 30 ++-
.../ppr-change-export.integration.test.ts | 127 ++++++++++
.../reject-targeted-del.integration.test.ts | 127 ++++++++++
.../tables-adapter.convenience.test.ts | 26 +++
.../document-api-adapters/tables-adapter.ts | 22 +-
.../review-model/decision-engine.js | 221 +++++++++++++++++-
.../review-model/decision-engine.test.js | 42 ++++
.../review-model/review-graph.js | 155 +++++++++++-
.../review-model/review-graph.test.js | 16 +-
.../review-model/structural-decisions.test.js | 82 +++++++
.../review-model/test-fixtures.js | 15 +-
.../extensions/track-changes/track-changes.js | 8 +-
.../trackChangesHelpers/pprChanges.js | 83 +++++++
.../types/track-changes-commands.ts | 8 +-
.../docxbench-comment-on-insertion-001.docx | Bin 0 -> 2652 bytes
.../data/docxbench-comment-reply-001.docx | Bin 0 -> 3252 bytes
.../data/docxbench-format-paragraph-001.docx | Bin 0 -> 2226 bytes
.../tests/data/docxbench-numbering-001.docx | Bin 0 -> 2672 bytes
.../docxbench-resolve-reject-del-001.docx | Bin 0 -> 2685 bytes
.../superdoc/src/stores/comments-store.js | 10 +
.../src/stores/comments-store.test.js | 40 ++++
54 files changed, 2129 insertions(+), 81 deletions(-)
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/comment-on-tracked-export.integration.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/comment-reply-export.integration.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/numbering-tracked-export.integration.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/numbering-tracked-review.integration.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/ppr-change-export.integration.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/reject-targeted-del.integration.test.ts
create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/pprChanges.js
create mode 100644 packages/super-editor/src/editors/v1/tests/data/docxbench-comment-on-insertion-001.docx
create mode 100644 packages/super-editor/src/editors/v1/tests/data/docxbench-comment-reply-001.docx
create mode 100644 packages/super-editor/src/editors/v1/tests/data/docxbench-format-paragraph-001.docx
create mode 100644 packages/super-editor/src/editors/v1/tests/data/docxbench-numbering-001.docx
create mode 100644 packages/super-editor/src/editors/v1/tests/data/docxbench-resolve-reject-del-001.docx
diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index 1b8bb06f03..8954d6acd0 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -1090,5 +1090,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "b7a0382edb33e88b28f1af627a89d6a0b441890883e30993390a76c756214e4c"
+ "sourceHash": "06cbdc50cf2e7489d18e54cfee0d40297e08522eb553485e10dbdafba568b81b"
}
diff --git a/apps/docs/document-api/reference/blocks/list.mdx b/apps/docs/document-api/reference/blocks/list.mdx
index 0b97e4e74e..501986ae10 100644
--- a/apps/docs/document-api/reference/blocks/list.mdx
+++ b/apps/docs/document-api/reference/blocks/list.mdx
@@ -163,6 +163,25 @@ Returns a BlocksListResult with total block count, an ordered array of block ent
"description": "Heading level (1-6).",
"type": "number"
},
+ "indent": {
+ "additionalProperties": false,
+ "description": "Direct paragraph indentation in twips (only non-zero fields present).",
+ "properties": {
+ "firstLine": {
+ "type": "number"
+ },
+ "hanging": {
+ "type": "number"
+ },
+ "left": {
+ "type": "number"
+ },
+ "right": {
+ "type": "number"
+ }
+ },
+ "type": "object"
+ },
"isEmpty": {
"type": "boolean"
},
@@ -183,6 +202,46 @@ Returns a BlocksListResult with total block count, an ordered array of block ent
"sdt"
]
},
+ "numbering": {
+ "additionalProperties": false,
+ "description": "Computed numbering rendering (marker/path/kind) for numbered list items and numbered headings — e.g. the rendered clause label \"2.3.\". Absent for non-numbered blocks.",
+ "properties": {
+ "kind": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "marker": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "path": {
+ "oneOf": [
+ {
+ "items": {
+ "type": "number"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "type": "object"
+ },
"ordinal": {
"type": "number"
},
diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx
index e0da7062f3..d985856c62 100644
--- a/apps/docs/document-api/reference/capabilities/get.mdx
+++ b/apps/docs/document-api/reference/capabilities/get.mdx
@@ -3934,7 +3934,7 @@ _No fields._
"lists.attach": {
"available": true,
"dryRun": true,
- "tracked": false
+ "tracked": true
},
"lists.canContinuePrevious": {
"available": true,
diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx
index 921329b718..3d1cc11d89 100644
--- a/apps/docs/document-api/reference/index.mdx
+++ b/apps/docs/document-api/reference/index.mdx
@@ -193,7 +193,7 @@ The tables below are grouped by namespace.
| lists.get | editor.doc.lists.get(...) | Retrieve a specific list node by target. |
| lists.insert | editor.doc.lists.insert(...) | Insert a new list item before or after an existing list item. The new item inherits the target list context. |
| lists.create | editor.doc.lists.create(...) | Create a new list from one or more paragraphs. Supports optional preset or style for new sequences. When sequence.mode is "continuePrevious", preset and style are not allowed: the new items inherit formatting from the previous sequence. |
-| lists.attach | editor.doc.lists.attach(...) | Convert non-list paragraphs to list items under an existing list sequence. |
+| lists.attach | editor.doc.lists.attach(...) | Convert non-list paragraphs to list items under an existing list sequence. With changeMode:"tracked" the former (unnumbered) paragraph properties are recorded as a w:pPrChange so a reviewer can accept/reject the numbering. |
| lists.detach | editor.doc.lists.detach(...) | Remove numbering properties from list items, converting them to plain paragraphs. |
| lists.delete | editor.doc.lists.delete(...) | Delete the entire list that contains the targeted list item. Removes ALL items in the same numbered sequence (the contiguous run of list items sharing the target's numbering) AND their text content from the document. Pass any single list item from the list as `target`; the op walks adjacent siblings to find the full list. Use this for "remove the list" / "delete this list" intents and for the cleanup step of a list-to-table conversion. |
| lists.indent | editor.doc.lists.indent(...) | Increase the indentation level of a list item. |
diff --git a/apps/docs/document-api/reference/lists/attach.mdx b/apps/docs/document-api/reference/lists/attach.mdx
index ccce577eb7..cb06aee472 100644
--- a/apps/docs/document-api/reference/lists/attach.mdx
+++ b/apps/docs/document-api/reference/lists/attach.mdx
@@ -1,20 +1,20 @@
---
title: lists.attach
sidebarTitle: lists.attach
-description: Convert non-list paragraphs to list items under an existing list sequence.
+description: "Convert non-list paragraphs to list items under an existing list sequence. With changeMode:\"tracked\" the former (unnumbered) paragraph properties are recorded as a w:pPrChange so a reviewer can accept/reject the numbering."
---
{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
## Summary
-Convert non-list paragraphs to list items under an existing list sequence.
+Convert non-list paragraphs to list items under an existing list sequence. With changeMode:"tracked" the former (unnumbered) paragraph properties are recorded as a w:pPrChange so a reviewer can accept/reject the numbering.
- Operation ID: `lists.attach`
- API member path: `editor.doc.lists.attach(...)`
- Mutates document: `yes`
- Idempotency: `conditional`
-- Supports tracked mode: `no`
+- Supports tracked mode: `yes`
- Supports dry run: `yes`
- Deterministic target resolution: `yes`
diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx
index 04fff4d247..63726badae 100644
--- a/apps/docs/document-api/reference/lists/index.mdx
+++ b/apps/docs/document-api/reference/lists/index.mdx
@@ -16,7 +16,7 @@ List inspection and list mutations.
| lists.get | `lists.get` | No | `idempotent` | No | No |
| lists.insert | `lists.insert` | Yes | `non-idempotent` | Yes | Yes |
| lists.create | `lists.create` | Yes | `non-idempotent` | No | Yes |
-| lists.attach | `lists.attach` | Yes | `conditional` | No | Yes |
+| lists.attach | `lists.attach` | Yes | `conditional` | Yes | Yes |
| lists.detach | `lists.detach` | Yes | `conditional` | No | Yes |
| lists.delete | `lists.delete` | Yes | `conditional` | No | Yes |
| lists.indent | `lists.indent` | Yes | `conditional` | Yes | Yes |
diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx
index 8bddb4c2b8..ae88f4c8b0 100644
--- a/apps/docs/document-api/reference/track-changes/decide.mdx
+++ b/apps/docs/document-api/reference/track-changes/decide.mdx
@@ -146,6 +146,13 @@ Returns a Receipt. `removed` lists retired tracked-change ids; `inserted` lists
"destination"
]
},
+ "side": {
+ "description": "Optional replacement side. When the id resolves to a paired replacement, decides only the 'inserted' or 'deleted' half, leaving the other half as a standalone pending change.",
+ "enum": [
+ "inserted",
+ "deleted"
+ ]
+ },
"story": {
"$ref": "#/$defs/StoryLocator"
}
@@ -339,6 +346,13 @@ Returns a Receipt. `removed` lists retired tracked-change ids; `inserted` lists
"destination"
]
},
+ "side": {
+ "description": "Optional replacement side. When the id resolves to a paired replacement, decides only the 'inserted' or 'deleted' half.",
+ "enum": [
+ "inserted",
+ "deleted"
+ ]
+ },
"story": {
"$ref": "#/$defs/StoryLocator"
}
diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts
index d5f5be9de2..a3b29d5ca5 100644
--- a/packages/document-api/src/contract/operation-definitions.ts
+++ b/packages/document-api/src/contract/operation-definitions.ts
@@ -1842,13 +1842,14 @@ export const OPERATION_DEFINITIONS = {
},
'lists.attach': {
memberPath: 'lists.attach',
- description: 'Convert non-list paragraphs to list items under an existing list sequence.',
+ description:
+ 'Convert non-list paragraphs to list items under an existing list sequence. With changeMode:"tracked" the former (unnumbered) paragraph properties are recorded as a w:pPrChange so a reviewer can accept/reject the numbering.',
expectedResult: 'Returns a ListsMutateItemResult confirming attachment.',
requiresDocumentContext: true,
metadata: mutationOperation({
idempotency: 'conditional',
supportsDryRun: true,
- supportsTrackedMode: false,
+ supportsTrackedMode: true,
possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'],
throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'],
}),
diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts
index e9ff31f731..5e0c0832fe 100644
--- a/packages/document-api/src/contract/schemas.ts
+++ b/packages/document-api/src/contract/schemas.ts
@@ -3490,6 +3490,28 @@ const operationSchemas: Record = {
},
additionalProperties: false,
},
+ numbering: {
+ type: 'object',
+ description:
+ 'Computed numbering rendering (marker/path/kind) for numbered list items and numbered headings — e.g. the rendered clause label "2.3.". Absent for non-numbered blocks.',
+ properties: {
+ marker: { oneOf: [{ type: 'string' }, { type: 'null' }] },
+ path: { oneOf: [{ type: 'array', items: { type: 'number' } }, { type: 'null' }] },
+ kind: { oneOf: [{ type: 'string' }, { type: 'null' }] },
+ },
+ additionalProperties: false,
+ },
+ indent: {
+ type: 'object',
+ description: 'Direct paragraph indentation in twips (only non-zero fields present).',
+ properties: {
+ left: { type: 'number' },
+ right: { type: 'number' },
+ firstLine: { type: 'number' },
+ hanging: { type: 'number' },
+ },
+ additionalProperties: false,
+ },
ref: {
type: 'string',
description:
@@ -5377,6 +5399,11 @@ const operationSchemas: Record = {
description:
"Optional move pairing assertion. 'pair' requires the resolved tracked change to be a paired move; 'source' / 'destination' further narrow to a specific half. When the assertion does not hold the decide adapter fails closed.",
},
+ side: {
+ enum: ['inserted', 'deleted'],
+ description:
+ "Optional replacement side. When the id resolves to a paired replacement, decides only the 'inserted' or 'deleted' half, leaving the other half as a standalone pending change.",
+ },
},
['kind', 'id'],
),
@@ -5433,6 +5460,11 @@ const operationSchemas: Record = {
description:
"Optional move pairing assertion. 'pair' requires the resolved tracked change to be a paired move; 'source' / 'destination' further narrow to a specific half. When the assertion does not hold the decide adapter fails closed.",
},
+ side: {
+ enum: ['inserted', 'deleted'],
+ description:
+ "Optional replacement side. When the id resolves to a paired replacement, decides only the 'inserted' or 'deleted' half.",
+ },
},
['id'],
),
diff --git a/packages/document-api/src/track-changes/track-changes.test.ts b/packages/document-api/src/track-changes/track-changes.test.ts
index 691aa297a7..e5c81256a2 100644
--- a/packages/document-api/src/track-changes/track-changes.test.ts
+++ b/packages/document-api/src/track-changes/track-changes.test.ts
@@ -64,6 +64,42 @@ describe('executeTrackChangesDecide validation', () => {
expect((caught as Error | undefined)?.message).toContain('exactly one selector');
});
+ it('rejects id-target side aliases "insert"/"delete" (canonical inserted/deleted only)', () => {
+ // The published id-target side schema is strictly ['inserted','deleted'];
+ // the runtime must not accept the looser aliases the schema forbids.
+ for (const side of ['insert', 'delete']) {
+ expect(() =>
+ executeTrackChangesDecide(stubAdapter(), {
+ decision: 'reject',
+ target: { kind: 'id', id: 'tc1', side } as any,
+ }),
+ ).toThrow(/must be "inserted" or "deleted"/);
+ }
+ });
+
+ it('forwards canonical id-target side "deleted" to the adapter', () => {
+ const adapter = { ...stubAdapter(), decide: mock(() => ({ success: true })) };
+ const result = executeTrackChangesDecide(adapter, {
+ decision: 'reject',
+ target: { kind: 'id', id: 'tc1', side: 'deleted' },
+ });
+ expect(result.success).toBe(true);
+ expect((adapter.decide as any).mock.calls[0][0].target.side).toBe('deleted');
+ });
+
+ it('still accepts range-target side aliases (range validation is unchanged)', () => {
+ const adapter = { ...stubAdapter(), decideRange: mock(() => ({ success: true })) };
+ const result = executeTrackChangesDecide(adapter, {
+ decision: 'reject',
+ target: {
+ kind: 'range',
+ range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] },
+ side: 'delete',
+ } as any,
+ });
+ expect(result.success).toBe(true);
+ });
+
it('routes canonical range targets to decideRange', () => {
const adapter = {
...stubAdapter(),
diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts
index 8bd0aab279..66e89f80b0 100644
--- a/packages/document-api/src/track-changes/track-changes.ts
+++ b/packages/document-api/src/track-changes/track-changes.ts
@@ -14,11 +14,23 @@ export interface TrackChangesAcceptInput {
id: string;
/** Story containing the tracked change. Omit for body (backward compatible). */
story?: StoryLocator;
+ /**
+ * Optional replacement side. When the id resolves to a paired replacement,
+ * `'inserted'` / `'deleted'` decides only that half, leaving the other half
+ * as a standalone pending change.
+ */
+ side?: 'inserted' | 'deleted';
}
export interface TrackChangesRejectInput {
id: string;
/** Story containing the tracked change. Omit for body (backward compatible). */
story?: StoryLocator;
+ /**
+ * Optional replacement side. When the id resolves to a paired replacement,
+ * `'inserted'` / `'deleted'` decides only that half, leaving the other half
+ * as a standalone pending change.
+ */
+ side?: 'inserted' | 'deleted';
}
export interface TrackChangesAcceptAllInput {
story?: StoryLocator | 'all';
@@ -43,6 +55,14 @@ export interface TrackChangesRangeInput {
// `{ kind: 'id' }` / `{ kind: 'all' }`.
// ---------------------------------------------------------------------------
export type ReviewDecideTargetSide = 'insert' | 'inserted' | 'delete' | 'deleted' | 'source' | 'destination';
+/**
+ * Replacement side for an `id` decide target: which half of a paired
+ * replacement to resolve. Only `'inserted'` / `'deleted'` are valid here (a
+ * paired replacement has no move roles), matching the published schema and the
+ * runtime validator. The broader {@link ReviewDecideTargetSide} — which also
+ * carries range/move-only values — applies to `range` targets.
+ */
+export type ReplacementSide = 'inserted' | 'deleted';
/**
* Tracked-move pairing assertion for a decide target. Callers that resolved an
* ambiguous shape (e.g. surface guesses a paragraph delete+insert might be a
@@ -80,13 +100,19 @@ export type ReviewDecideLogicalRangeTarget = ReviewDecideRangeTargetOptions & {
};
/** Semantic target for {@link ReviewDecideInput}. */
export type ReviewDecideTarget =
- | { kind: 'id'; id: string; story?: StoryLocator; moveRole?: ReviewDecideTargetMoveRole }
+ | {
+ kind: 'id';
+ id: string;
+ story?: StoryLocator;
+ moveRole?: ReviewDecideTargetMoveRole;
+ side?: ReplacementSide;
+ }
| ReviewDecideTextRangeTarget
| ReviewDecideLogicalRangeTarget
| { kind: 'all'; story?: StoryLocator | 'all' };
/** Legacy compatibility shapes accepted by the validator. */
export type LegacyReviewDecideTarget =
- | { id: string; story?: StoryLocator; moveRole?: ReviewDecideTargetMoveRole }
+ | { id: string; story?: StoryLocator; moveRole?: ReviewDecideTargetMoveRole; side?: ReplacementSide }
| { scope: 'all'; story?: StoryLocator | 'all' };
export type ReviewDecisionTarget = ReviewDecideTarget | LegacyReviewDecideTarget;
export interface ReviewDecideInput {
@@ -227,10 +253,11 @@ export function executeTrackChangesDecide(
return adapter.rejectAll(input, revisionOptions);
}
const { id, story } = canonical.target;
+ const side = (canonical.target as { side?: 'inserted' | 'deleted' }).side;
if (canonical.decision === 'accept') {
- return adapter.accept({ id, ...(story ? { story } : {}) }, revisionOptions);
+ return adapter.accept({ id, ...(story ? { story } : {}), ...(side ? { side } : {}) }, revisionOptions);
}
- return adapter.reject({ id, ...(story ? { story } : {}) }, revisionOptions);
+ return adapter.reject({ id, ...(story ? { story } : {}), ...(side ? { side } : {}) }, revisionOptions);
}
function isValidLegacyPartialIdRangeTarget(input: ReviewDecideInput): boolean {
if (typeof input !== 'object' || input == null) return false;
@@ -296,11 +323,13 @@ function normalizeReviewDecideTarget(target: Record): ReviewDec
}
const story = readOptionalStory(target, 'target.story', false);
const moveRole = readOptionalMoveRole(target);
+ const side = readOptionalReplacementSide(target);
return {
kind: 'id',
id,
...(story ? { story } : {}),
...(moveRole ? { moveRole } : {}),
+ ...(side ? { side } : {}),
};
}
if (kind === 'range') {
@@ -394,11 +423,13 @@ function normalizeReviewDecideTarget(target: Record): ReviewDec
if (typeof target.id === 'string' && target.id.length > 0) {
const story = readOptionalStory(target, 'target.story', false);
const moveRole = readOptionalMoveRole(target);
+ const side = readOptionalReplacementSide(target);
return {
kind: 'id',
id: target.id,
...(story ? { story } : {}),
...(moveRole ? { moveRole } : {}),
+ ...(side ? { side } : {}),
};
}
}
@@ -596,6 +627,25 @@ function readOptionalMoveRole(target: Record): ReviewDecideTarg
}
return moveRole;
}
+/**
+ * Read + normalize an id-target replacement side to the canonical
+ * `'inserted'` / `'deleted'` the kernel expects. Only those two values are
+ * accepted — matching the published `trackChanges.decide` id-target schema
+ * (`enum: ['inserted','deleted']`). `source` / `destination` (move roles) are
+ * not valid for an id-side decision and fail closed.
+ */
+function readOptionalReplacementSide(target: Record): ReplacementSide | undefined {
+ if (!('side' in target)) return undefined;
+ const side = target.side;
+ if (side === undefined || side === null) return undefined;
+ if (side === 'inserted') return 'inserted';
+ if (side === 'deleted') return 'deleted';
+ throw new DocumentApiValidationError(
+ 'INVALID_TARGET',
+ 'trackChanges.decide id target.side must be "inserted" or "deleted" when provided.',
+ { field: 'target.side', value: side },
+ );
+}
function validateRangeTargetSide(side: unknown): asserts side is ReviewDecideTargetSide {
if (
side === 'insert' ||
diff --git a/packages/document-api/src/types/blocks.types.ts b/packages/document-api/src/types/blocks.types.ts
index b028a90bb6..a35e3a3cef 100644
--- a/packages/document-api/src/types/blocks.types.ts
+++ b/packages/document-api/src/types/blocks.types.ts
@@ -27,8 +27,22 @@ export interface BlockListEntry {
color?: string;
/** Paragraph alignment. */
alignment?: string;
+ /** Direct paragraph indentation (twips), when set on the block. */
+ indent?: { left?: number; right?: number; firstLine?: number; hanging?: number };
/** Heading level (1-6). Only for headings. */
headingLevel?: number;
+ /**
+ * Computed numbering for blocks that participate in a numbering scheme.
+ * Present for numbered list items AND numbered headings/paragraphs (legal
+ * clause numbering like "2.3." usually lives on heading-styled paragraphs,
+ * not list nodes). `marker` is the rendered label (e.g. "2.3."), `path` the
+ * numeric path (e.g. [2, 3]), `kind` the numbering type (decimal, bullet…).
+ */
+ numbering?: {
+ marker: string | null;
+ path: number[] | null;
+ kind: string | null;
+ } | null;
/**
* Numbering reference (`numId` + `level`) for numbered blocks, sourced from the
* block's direct numbering properties (`w:numPr`). Present for numbered
diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts
index 439efc032c..e04f9caba5 100644
--- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts
+++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts
@@ -97,17 +97,18 @@ describe('applyBorder', () => {
expect(element.style.borderTop).toMatch(/2px dashed (#00FF00|rgb\(0,\s*255,\s*0\))/i);
});
- it('should handle thick border with width multiplier', () => {
+ it('should handle thick border at the authored width', () => {
const border: BorderSpec = { style: 'thick', width: 1, color: '#000000' };
applyBorder(element, 'Top', border);
- // Thick borders use max(width * 2, 3)
- expect(element.style.borderTop).toMatch(/3px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
+ // SD-3028: thick paints at the authored w:sz width (no 2x), min 1px — matches
+ // getBorderBandWidthPx in contracts/border-band.ts and border-band.test.ts.
+ expect(element.style.borderTop).toMatch(/1px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
});
it('should handle thick border with larger width', () => {
const border: BorderSpec = { style: 'thick', width: 3, color: '#000000' };
applyBorder(element, 'Top', border);
- expect(element.style.borderTop).toMatch(/6px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
+ expect(element.style.borderTop).toMatch(/3px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
});
it('should set border to none for none style', () => {
diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts
index 2296a29af7..961caeb028 100644
--- a/packages/layout-engine/painters/dom/src/table/border-utils.ts
+++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts
@@ -125,8 +125,9 @@ export const applyBorder = (
const safeColor = isValidHexColor(color) ? color : '#000000';
// Band width comes from the shared contracts helper so the painted width and the
// measuring engine's row-height reservation can never disagree. Word semantics:
- // thick = 2x (min 3px); double = 3x the per-rule w:sz (min 3px so CSS renders both
- // rules); everything else = authored width. `widthOverridePx` carries the
+ // thick = authored w:sz width (SD-3028: no 2x; min 1px); double = 3x the per-rule
+ // w:sz (min 3px so CSS renders both rules); everything else = authored width.
+ // `widthOverridePx` carries the
// straddled half-band for interior compound edges (Word centers those bands on
// the gridline, half in each adjacent cell). (SD-3308)
const actualWidth = widthOverridePx ?? getBorderBandWidthPx(border);
diff --git a/packages/super-editor/src/editors/v1/core/commands/changeListLevel.js b/packages/super-editor/src/editors/v1/core/commands/changeListLevel.js
index 6ce195810f..17e5f2923c 100644
--- a/packages/super-editor/src/editors/v1/core/commands/changeListLevel.js
+++ b/packages/super-editor/src/editors/v1/core/commands/changeListLevel.js
@@ -1,3 +1,4 @@
+import { v4 as uuidv4 } from 'uuid';
import { findParentNode } from '@helpers/index.js';
import { isList } from '@core/commands/list-helpers';
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
@@ -111,12 +112,31 @@ export const changeListLevel = (delta, editor, tr) => {
* @param {import('../Editor').Editor} editor The editor that supplies numbering and style resolution helpers.
* @param {import('prosemirror-state').Transaction} tr The transaction receiving the updated node markup.
*/
-export function updateNumberingProperties(newNumberingProperties, paragraphNode, pos, editor, tr) {
+export function updateNumberingProperties(newNumberingProperties, paragraphNode, pos, editor, tr, options = {}) {
+ const formerProperties = { ...(paragraphNode.attrs.paragraphProperties || {}) };
const newProperties = {
- ...(paragraphNode.attrs.paragraphProperties || {}),
+ ...formerProperties,
numberingProperties: newNumberingProperties ? { ...newNumberingProperties } : null,
};
+ // Tracked numbering change: record the FORMER paragraph properties (the
+ // unnumbered state) as a w:pPrChange so a reviewer sees a numbering revision
+ // and accept/reject toggles it. Only when ADDING numbering and no pPrChange
+ // is already recorded (never overwrite an existing reviewer's pPrChange).
+ if (options.trackChange && newNumberingProperties && !formerProperties.change) {
+ // Snapshot the former properties without any prior change record.
+ const former = { ...formerProperties };
+ delete former.change;
+ const user = editor?.options?.user || {};
+ newProperties.change = {
+ id: uuidv4(),
+ author: user.name || '',
+ authorEmail: user.email || '',
+ date: new Date().toISOString(),
+ paragraphProperties: former,
+ };
+ }
+
// Only strip the default ListParagraph styleId when removing list formatting.
// Keeping it on style swaps preserves the style's contextualSpacing, which Word
// relies on to collapse the inter-paragraph gap between consecutive list items.
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js
index 247b9a3700..740d1605aa 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js
@@ -1245,8 +1245,34 @@ class SuperConverter {
// Reset export warnings for this export cycle
this.exportWarnings = [];
- // Filter out synthetic tracked change comments - they shouldn't be exported to comments.xml
- const exportableComments = comments.filter((c) => !c.trackedChange);
+ // Filter out SYNTHETIC tracked-change projection rows - they shouldn't be
+ // exported to comments.xml. `comments.list()` returns one such row per
+ // tracked change (so the sidebar can show revisions next to comments); its
+ // identity IS the tracked change (commentId/id === trackedChangeLink.
+ // trackedChangeId) and it carries only the change excerpt as `text`, no real
+ // comment body. Those must never become a . A GENUINE user comment
+ // ANCHORED ON a tracked change also carries trackedChange:true + a
+ // trackedChangeLink, but keeps its OWN distinct id, so the identity check
+ // spares it. (`!c.trackedChange` alone silently dropped real
+ // comments-on-revisions — comment-on-insertion/deletion.)
+ const isSyntheticTrackedChangeRow = (c) => {
+ const linkId = c.trackedChangeLink?.trackedChangeId;
+ if (!c.trackedChange || !linkId) return false;
+ const identity = c.commentId ?? c.id;
+ return identity != null && String(identity) === String(linkId);
+ };
+ // A tracked-change row with no body at all is also synthetic noise; keep the
+ // body check so bodiless rows drop even if they somehow lack a link.
+ const hasCommentBody = (c) =>
+ Boolean(
+ (typeof c.commentText === 'string' && c.commentText.length > 0) ||
+ c.commentJSON ||
+ (Array.isArray(c.elements) && c.elements.length) ||
+ (typeof c.text === 'string' && c.text.length > 0),
+ );
+ const exportableComments = comments.filter(
+ (c) => !isSyntheticTrackedChangeRow(c) && !(c.trackedChange && !hasCommentBody(c)),
+ );
const commentsWithParaIds = exportableComments.map((c) => prepareCommentParaIds(c));
const commentDefinitions = commentsWithParaIds.map((c, index) =>
getCommentDefinition(c, index, commentsWithParaIds, editor),
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js
index 5f7f8bff61..76a7289178 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js
@@ -95,6 +95,15 @@ export function generateParagraphProperties(params) {
const paragraphProperties = carbonCopy(attrs.paragraphProperties || {});
+ // Final-doc export flattens tracked changes to the accepted result (like the
+ // ins/del translators, which strip their wrappers when params.isFinalDoc). A
+ // tracked paragraph-property revision (w:pPrChange) must NOT appear in a
+ // "final" DOCX — drop the change record so only the accepted current
+ // numbering/alignment (already carried on paragraphProperties) is exported.
+ if (params?.isFinalDoc && paragraphProperties.change) {
+ delete paragraphProperties.change;
+ }
+
// Only include w:rPr in pPr when the paragraph had inline rPr on import; filter to inline keys and drop if empty.
const inlineKeys = paragraphProperties.runPropertiesInlineKeys;
delete paragraphProperties.runPropertiesInlineKeys;
@@ -122,7 +131,13 @@ export function generateParagraphProperties(params) {
partPath: resolveExportPartPath(params),
}
: null;
- let pPr = wPPrNodeTranslator.decode({ node: { ...node, attrs: { paragraphProperties } } });
+ let pPr = wPPrNodeTranslator.decode({
+ node: { ...node, attrs: { paragraphProperties } },
+ // Thread the Word revision-id allocator + part path so a tracked w:pPrChange
+ // mints a doc-unique w:id via the allocator (not a collision-prone hash).
+ converter: params?.converter,
+ currentPartPath: resolveExportPartPath(params),
+ });
if (!params?.isFinalDoc && paragraphSplitTrackFormatMark) {
const insertionElement = createParagraphSplitInsertionElement(
paragraphSplitTrackFormatMark,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js
index 2870c1f11f..48539480b1 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js
@@ -47,12 +47,16 @@ describe('generateParagraphProperties', () => {
expect(result).toBe(pPrNode);
expect(wPPrNodeTranslator.decode).toHaveBeenCalledTimes(1);
- expect(wPPrNodeTranslator.decode).toHaveBeenCalledWith({
- node: {
- ...node,
- attrs: { paragraphProperties: { spacing: { line: 240 } } },
- },
- });
+ // objectContaining: the pPr decode also receives the Word-id allocator
+ // threading (converter + currentPartPath); this test only cares about node.
+ expect(wPPrNodeTranslator.decode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ node: {
+ ...node,
+ attrs: { paragraphProperties: { spacing: { line: 240 } } },
+ },
+ }),
+ );
});
it('appends sectPr to decoded paragraph properties', () => {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js
index dc716b0a10..1a2ffa6342 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js
@@ -3,6 +3,59 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js';
import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js';
import { basePropertyTranslators } from '../pPr/pPr-base-translators.js';
+/**
+ * OOXML `w:id` on `w:pPrChange` must be a decimal integer (CT_TrackChange).
+ * Imported pPrChanges already carry a decimal id; API-created ones use a
+ * `uuidv4`, which Word repairs/drops. Resolve a Word-safe decimal that stays
+ * unique across the whole document.
+ *
+ * Preferred path: the converter's Word revision-id allocator (the same
+ * doc-wide reservation the ins/del/row exporters use) — guarantees uniqueness
+ * across ALL tracked changes, so two pPrChanges (or a pPrChange and an ins/del)
+ * can never collide. An imported decimal is passed as `sourceId` so the
+ * allocator reserves and preserves it.
+ *
+ * Fallback (no allocator installed, e.g. isolated unit tests): a stable FNV-1a
+ * hash of the UUID in a high decimal range. Collision-prone in theory, but only
+ * reached when the allocator is absent.
+ *
+ * @param {import('@translator').SCDecoderConfig} params
+ * @param {{ id?: unknown }} change
+ * @returns {string}
+ */
+function resolvePprChangeWordId(params, change) {
+ const idStr = String(change?.id ?? '');
+ const allocator =
+ /** @type {{ converter?: { wordIdAllocator?: { allocate: (o: object) => string | number } | null }, currentPartPath?: string }} */ (
+ params
+ )?.converter?.wordIdAllocator;
+ if (allocator) {
+ const partPath = /** @type {{ currentPartPath?: string }} */ (params)?.currentPartPath || 'word/document.xml';
+ // Imported ids are decimal → reserve/keep them; UUIDs mint a fresh unique id.
+ const sourceId = /^\d+$/.test(idStr) ? idStr : undefined;
+ return String(allocator.allocate({ partPath, sourceId, logicalId: idStr }));
+ }
+ return toDecimalWordId(change?.id);
+}
+
+/**
+ * FNV-1a → `1,000,000..1,000,999,999` (below 2^31). Fallback only; see
+ * resolvePprChangeWordId. Imported decimals are kept as-is.
+ *
+ * @param {unknown} id
+ * @returns {string}
+ */
+function toDecimalWordId(id) {
+ const str = String(id);
+ if (/^\d+$/.test(str)) return str;
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < str.length; i++) {
+ hash ^= str.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return String(1_000_000 + ((hash >>> 0) % 1_000_000_000));
+}
+
const pPrTranslator = NodeTranslator.from(
createNestedPropertiesTranslator('w:pPr', 'paragraphProperties', basePropertyTranslators),
);
@@ -54,6 +107,11 @@ export const translator = NodeTranslator.from({
const decodedAttrs = this.decodeAttributes({
node: { ...params.node, attrs: change },
});
+ // Ensure `w:id` is a Word-safe, doc-unique decimal (change.id is a uuidv4
+ // for API-created pPrChanges); prefers the revision-id allocator.
+ if (decodedAttrs['w:id'] != null) {
+ decodedAttrs['w:id'] = resolvePprChangeWordId(params, change);
+ }
const hasParagraphProperties = Object.prototype.hasOwnProperty.call(change, 'paragraphProperties');
const paragraphProperties = hasParagraphProperties ? change.paragraphProperties : undefined;
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js
index 2626cf472f..3fbbf11e12 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js
@@ -11,6 +11,27 @@ import { describe, it, expect } from 'vitest';
import { translator } from './pPrChange-translator.js';
import { NodeTranslator } from '@translator';
+describe('w:pPrChange w:id is Word-safe on export', () => {
+ const decodeWid = (id) =>
+ translator.decode({ node: { attrs: { change: { id, author: 'A', date: '2026-01-01T00:00:00Z' } } } }).attributes[
+ 'w:id'
+ ];
+
+ it('preserves an already-decimal change id (imported pPrChange round-trip)', () => {
+ expect(decodeWid('42')).toBe('42');
+ });
+
+ it('converts a uuidv4 change id to a stable decimal in the allocator-clear range', () => {
+ const wid = decodeWid('f43e484b-398b-4e25-968c-48f67c1f02e0');
+ expect(wid).toMatch(/^\d+$/);
+ const n = Number(wid);
+ expect(n).toBeGreaterThanOrEqual(1_000_000);
+ expect(n).toBeLessThanOrEqual(1_000_999_999);
+ // Deterministic — the same UUID always exports the same w:id.
+ expect(decodeWid('f43e484b-398b-4e25-968c-48f67c1f02e0')).toBe(wid);
+ });
+});
+
describe('w:pPrChange translator', () => {
describe('config', () => {
it('should have correct properties', () => {
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts
index c7f22ff75f..3cb578b430 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts
@@ -5433,18 +5433,16 @@ const mutationVectors: Partial> = {
},
'lists.attach': {
throwCase: () => {
- const editor = makeListEditor([
- makeListParagraph({ id: 'p-1' }),
- makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' }),
- ]);
- return listsAttachWrapper(
- editor,
- {
- target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' },
- attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' },
- },
- { changeMode: 'tracked' },
- );
+ // Pre-apply throw is a missing target (TARGET_NOT_FOUND), which is
+ // mode-independent. Use DIRECT mode to isolate it: tracked mode is
+ // supported for lists.attach (it records the former paragraph properties
+ // as a w:pPrChange), but the tracked path requires a configured user, so
+ // on this no-user conformance editor the capability guard would fire first.
+ const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]);
+ return listsAttachWrapper(editor, {
+ target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' },
+ attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' },
+ });
},
failureCase: () => {
const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]);
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/comment-on-tracked-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/comment-on-tracked-export.integration.test.ts
new file mode 100644
index 0000000000..c53f4caa47
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/comment-on-tracked-export.integration.test.ts
@@ -0,0 +1,128 @@
+/* @vitest-environment jsdom */
+
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
+import DocxZipper from '@core/DocxZipper.js';
+import type { Editor } from '../core/Editor.js';
+import { createCommentsWrapper } from './plan-engine/comments-wrappers.js';
+import { trackChangesListWrapper } from './plan-engine/track-changes-wrappers.js';
+import { clearExecutorRegistry } from './plan-engine/executor-registry.js';
+import { registerBuiltInExecutors } from './plan-engine/register-executors.js';
+
+type LoadedDocData = Awaited>;
+
+async function exportCommentsXml(editor: Editor): Promise {
+ const zipper = new DocxZipper();
+ const buf = await editor.exportDocx();
+ const files = await zipper.getDocxData(buf, true);
+ const byName: Record = {};
+ for (const f of files as Array<{ name: string; content: string }>) byName[f.name] = f.content;
+ return byName['word/comments.xml'] || '';
+}
+const countComments = (xml: string): number => (xml.match(/]/g) || []).length;
+// A leaked sidebar-only tracked-change row exports as a with no
+// real body text. Count those so tests can assert none slip through.
+const countEmptyComments = (xml: string): number => {
+ const blocks = xml.match(//g) || [];
+ return blocks.filter((b) => {
+ const texts = [...b.matchAll(/]*>([^<]*)<\/w:t>/g)].map((m) => m[1]);
+ return !texts.some((t) => t.trim().length > 0);
+ }).length;
+};
+
+describe('comment on tracked change export (comment-on-insertion-001 repro)', () => {
+ let docData: LoadedDocData;
+ let editor: Editor | undefined;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('docxbench-comment-on-insertion-001.docx');
+ clearExecutorRegistry();
+ registerBuiltInExecutors();
+ });
+
+ afterEach(() => {
+ editor?.destroy();
+ editor = undefined;
+ });
+
+ it('anchors a comment on a pending tracked insertion and exports it', async () => {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ }));
+
+ // Discover the logical tracked-change id the document-api exposes (what the
+ // model gets from doc.trackChanges.list and passes to comments.create).
+ const listed: any = trackChangesListWrapper(editor!, {} as any);
+ const items: any[] = listed?.items ?? listed?.changes ?? (Array.isArray(listed) ? listed : []);
+ const ins = items.find((c) => (c.author || '').includes('J. Prior')) ?? items[0];
+ const trackedChangeId = ins?.id ?? ins?.entityId;
+ expect(trackedChangeId, 'tracked change id resolved').toBeTruthy();
+
+ const comments = createCommentsWrapper(editor!);
+ comments.add({
+ text: 'Confirm this delivery window is acceptable.',
+ target: { trackedChangeId, side: 'after' },
+ } as any);
+
+ const xml = await exportCommentsXml(editor!);
+
+ // Exact count: exactly the pre-existing comment + the
+ // one we added — no sidebar-only tracked-change row leaks in as an empty
+ // comment. Asserting `>=` would let such a leak pass unnoticed.
+ expect(countComments(xml)).toBe(2);
+ expect(countEmptyComments(xml), 'no empty (sidebar-only) comment exported').toBe(0);
+ expect(xml).toContain('Confirm this delivery window is acceptable.');
+ });
+
+ // Regression: comments.list() returns a synthetic projection row per tracked
+ // change (so the sidebar can render revisions beside comments). Feeding those
+ // rows straight into exportDocx({ comments }) — a real caller pattern — must
+ // NOT turn each tracked change into a spurious . The synthetic row
+ // carries the change excerpt as `text`, so the old body-presence filter let it
+ // through; the identity check (id === trackedChangeLink.trackedChangeId) drops
+ // it while sparing genuine comments anchored on a tracked change.
+ it('does not export comments.list() synthetic tracked-change rows via exportDocx({ comments })', async () => {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ user: { name: 'Agent', email: 'agent@example.com' },
+ } as any));
+
+ // A SESSION tracked change (no wordRevisionIds) is what surfaces as a
+ // synthetic sidebar row in comments.list() — imported revisions are skipped.
+ (editor as any).commands.enableTrackChanges();
+ (editor as any).commands.command(({ tr, dispatch }: any) => {
+ tr.insertText('SESSION-INSERT', 1);
+ if (dispatch) dispatch(tr);
+ return true;
+ });
+
+ const items: any[] = createCommentsWrapper(editor!).list({} as any)?.items ?? [];
+ // Confirm the list actually contains a synthetic row (the leak source): a
+ // tracked-change row whose id equals the change it links to.
+ const synthetic = items.find(
+ (c) => c.trackedChange && c.trackedChangeLink?.trackedChangeId === (c.commentId ?? c.id),
+ );
+ expect(synthetic, 'comments.list surfaces a synthetic tracked-change row').toBeTruthy();
+
+ const zipper = new DocxZipper();
+ const buf = await editor!.exportDocx({ comments: items } as any);
+ const files = await zipper.getDocxData(buf, true);
+ const byName: Record = {};
+ for (const f of files as Array<{ name: string; content: string }>) byName[f.name] = f.content;
+ const xml = byName['word/comments.xml'] || '';
+
+ // Only the pre-existing imported comment survives; the synthetic row (author
+ // "Agent", carrying custom:trackedChange) must not become a .
+ expect(countComments(xml)).toBe(1);
+ expect(xml).not.toContain('w:author="Agent"');
+ expect(xml).not.toContain('custom:trackedChange="true"');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/comment-reply-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/comment-reply-export.integration.test.ts
new file mode 100644
index 0000000000..ee9b75e831
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/comment-reply-export.integration.test.ts
@@ -0,0 +1,88 @@
+/* @vitest-environment jsdom */
+
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
+import DocxZipper from '@core/DocxZipper.js';
+import type { Editor } from '../core/Editor.js';
+import { createCommentsWrapper } from './plan-engine/comments-wrappers.js';
+import { clearExecutorRegistry } from './plan-engine/executor-registry.js';
+import { registerBuiltInExecutors } from './plan-engine/register-executors.js';
+
+type LoadedDocData = Awaited>;
+
+async function exportCommentsXml(editor: Editor): Promise {
+ const zipper = new DocxZipper();
+ const buf = await editor.exportDocx();
+ const files = await zipper.getDocxData(buf, true);
+ const byName: Record = {};
+ for (const f of files as Array<{ name: string; content: string }>) byName[f.name] = f.content;
+ return byName['word/comments.xml'] || '';
+}
+
+const countComments = (xml: string): number => (xml.match(/]/g) || []).length;
+// A leaked sidebar-only tracked-change row exports as a with no
+// real body text. Count those so tests can assert none slip through.
+const countEmptyComments = (xml: string): number => {
+ const blocks = xml.match(//g) || [];
+ return blocks.filter((b) => {
+ const texts = [...b.matchAll(/]*>([^<]*)<\/w:t>/g)].map((m) => m[1]);
+ return !texts.some((t) => t.trim().length > 0);
+ }).length;
+};
+
+describe('comment reply export (DOCXBench comment-reply-001 repro)', () => {
+ let docData: LoadedDocData;
+ let editor: Editor | undefined;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('docxbench-comment-reply-001.docx');
+ clearExecutorRegistry();
+ registerBuiltInExecutors();
+ });
+
+ afterEach(() => {
+ editor?.destroy();
+ editor = undefined;
+ });
+
+ it('writes an API-created threaded reply into comments.xml', async () => {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ }));
+
+ const comments = createCommentsWrapper(editor!);
+
+ // Does a reply bump the document revision? (The CLI host only persists when
+ // revisionBefore !== revisionAfter — see execute-code.ts mutated check.)
+ const docApi: any = (editor as any).doc;
+ const revBefore = docApi?.info?.({})?.revision;
+
+ const list: any = comments.list();
+ const items: any[] = list?.items ?? list?.comments ?? (Array.isArray(list) ? list : []);
+ const parentId: string | undefined = items[0]?.commentId ?? items[0]?.id ?? items[0]?.importedId;
+ // Sanity: the fixture ships exactly one root comment by K. Reviewer.
+ expect(parentId).toBeTruthy();
+
+ comments.reply({
+ parentCommentId: parentId!,
+ text: 'Confirmed, thirty days is correct.',
+ } as any);
+ const revAfter = docApi?.info?.({})?.revision;
+
+ // A persisted reply must bump the document revision — the CLI host only
+ // writes the doc back when revisionBefore !== revisionAfter.
+ expect(revAfter, 'reply bumps the document revision').not.toBe(revBefore);
+
+ const xml = await exportCommentsXml(editor!);
+
+ // Exact count: exactly the original comment + the reply,
+ // and no sidebar-only tracked-change row leaks in as an empty comment.
+ expect(countComments(xml)).toBe(2);
+ expect(countEmptyComments(xml), 'no empty (sidebar-only) comment exported').toBe(0);
+ expect(xml).toContain('Confirmed, thirty days is correct.');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-item-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-item-resolver.ts
index 95c722a084..e1840b3803 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-item-resolver.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-item-resolver.ts
@@ -1,4 +1,7 @@
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore — JS module without type declarations
+import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js';
import type { Editor } from '../../core/Editor.js';
import { getListOrdinalFromPath, getListRendering } from '@superdoc/common/list-rendering';
import type {
@@ -83,7 +86,27 @@ export function projectListItemCandidate(editor: Editor, candidate: BlockCandida
listRendering?: unknown;
};
- const { numId, level } = getNumberingProperties(candidate.node);
+ let { numId, level } = getNumberingProperties(candidate.node);
+ if (numId == null) {
+ // Style-linked numbering: the node renders a marker but carries no numPr
+ // on its attrs — the effective numbering lives in the resolved property
+ // chain (same resolver the numbering plugin uses). Without this fallback,
+ // lists.attach/setType reject blocks that visibly ARE numbered clauses.
+ try {
+ const resolved = calculateResolvedParagraphProperties(
+ editor,
+ candidate.node,
+ editor.state.doc.resolve(candidate.pos),
+ ) as { numberingProperties?: { numId?: unknown; ilvl?: unknown } | null } | null;
+ const effective = resolved?.numberingProperties;
+ if (effective) {
+ numId = toFiniteNumber(effective.numId);
+ level = toFiniteNumber(effective.ilvl) ?? level ?? 0;
+ }
+ } catch {
+ // best-effort: fall through with attrs-only numbering
+ }
+ }
const listRendering = getListRendering(attrs.listRendering);
const path = listRendering?.path;
const ordinal = getListOrdinalFromPath(path);
@@ -224,12 +247,30 @@ export function listListItems(editor: Editor, query?: ListsListQuery): ListsList
});
}
+function hasNumberingMetadata(candidate: BlockCandidate): boolean {
+ const { numId } = getNumberingProperties(candidate.node);
+ if (numId != null) return true;
+ const attrs = (candidate.node.attrs ?? {}) as { listRendering?: unknown };
+ return getListRendering(attrs.listRendering) != null;
+}
+
export function resolveListItem(editor: Editor, address: ListItemAddress): ListItemProjection {
const index = getBlockIndex(editor);
- const matches = index.candidates.filter(
+ let matches = index.candidates.filter(
(candidate) => candidate.nodeType === 'listItem' && candidate.nodeId === address.nodeId,
);
+ if (matches.length === 0) {
+ // Numbered paragraphs/headings ARE list participants in Word terms (the
+ // editor's own list toolbar treats them as such): legal clause numbering
+ // ("2.3.") lives on heading-styled paragraphs that block classification
+ // maps to 'heading', making them unreachable for lists.attach/setType by
+ // nodeId. Fall back to any block carrying numbering metadata.
+ matches = index.candidates.filter(
+ (candidate) => candidate.nodeId === address.nodeId && hasNumberingMetadata(candidate),
+ );
+ }
+
if (matches.length === 0) {
throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'List item target was not found.', {
target: address,
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-sequence-helpers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-sequence-helpers.ts
index 5b6502c254..01710c0f0c 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-sequence-helpers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/list-sequence-helpers.ts
@@ -20,13 +20,16 @@ import { DocumentApiAdapterError } from '../errors.js';
/**
* Resolve a paragraph block address to its BlockCandidate.
- * Searches for both 'paragraph' and 'listItem' node types since a paragraph
- * with numbering properties is classified as 'listItem' in the block index.
+ * Searches 'paragraph', 'listItem', AND 'heading' node types: a paragraph
+ * with numbering properties is classified as 'listItem', and a paragraph
+ * with a heading style is classified as 'heading' — both are paragraph
+ * nodes in the document model (legal clauses are typically heading-styled,
+ * and converting them to a list must be able to address them).
*/
export function resolveBlock(editor: Editor, nodeId: string): BlockCandidate {
const index = getBlockIndex(editor);
const matches = index.candidates.filter(
- (c) => c.nodeId === nodeId && (c.nodeType === 'paragraph' || c.nodeType === 'listItem'),
+ (c) => c.nodeId === nodeId && (c.nodeType === 'paragraph' || c.nodeType === 'listItem' || c.nodeType === 'heading'),
);
if (matches.length === 0) {
@@ -58,8 +61,13 @@ export function resolveBlocksInRange(editor: Editor, fromId: string, toId: strin
}
const index = getBlockIndex(editor);
+ // Heading-classified blocks are paragraph nodes too (see resolveBlock) —
+ // a clause range being converted to a list is typically heading-styled.
return index.candidates.filter(
- (c) => (c.nodeType === 'paragraph' || c.nodeType === 'listItem') && c.pos >= from.pos && c.pos <= to.pos,
+ (c) =>
+ (c.nodeType === 'paragraph' || c.nodeType === 'listItem' || c.nodeType === 'heading') &&
+ c.pos >= from.pos &&
+ c.pos <= to.pos,
);
}
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts
index 7779802b10..efbd85ad33 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts
@@ -14,6 +14,7 @@ import {
} from '../../extensions/track-changes/constants.js';
import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js';
import { enumerateStructuralRowChanges } from '../../extensions/track-changes/trackChangesHelpers/structuralRowChanges.js';
+import { enumeratePprChanges } from '../../extensions/track-changes/trackChangesHelpers/pprChanges.js';
import {
projectInternalTrackChangeType,
type InternalTrackChangeSubtype,
@@ -579,6 +580,35 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] {
});
}
+ // Paragraph-property revisions (w:pPrChange: tracked numbering / alignment)
+ // also live on node attrs, not marks, so they are enumerated separately and
+ // appended as their own decidable formatting changes. The record id
+ // (`paragraphProperties.change.id`) is stored on the node, so it is stable
+ // within the session and doubles as the public id and the command id the
+ // review graph keys by.
+ const pprChanges = enumeratePprChanges(editor.state);
+ for (const ppr of pprChanges) {
+ const excerpt = normalizeExcerpt(editor.state.doc.textBetween(ppr.from, ppr.to, ' ', ''));
+ grouped.push({
+ rawId: ppr.id,
+ commandRawId: ppr.id,
+ id: ppr.id,
+ from: ppr.from,
+ to: ppr.to,
+ hasInsert: false,
+ hasDelete: false,
+ hasFormat: true,
+ attrs: {
+ id: ppr.id,
+ author: ppr.author || undefined,
+ authorEmail: ppr.authorEmail || undefined,
+ authorImage: ppr.authorImage || undefined,
+ date: ppr.date || undefined,
+ },
+ excerpt,
+ });
+ }
+
grouped.sort((a, b) => {
if (a.from !== b.from) return a.from - b.from;
return a.id.localeCompare(b.id);
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/numbering-tracked-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/numbering-tracked-export.integration.test.ts
new file mode 100644
index 0000000000..7a1f652ac4
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/numbering-tracked-export.integration.test.ts
@@ -0,0 +1,179 @@
+/* @vitest-environment jsdom */
+
+import { beforeAll, describe, expect, it } from 'vitest';
+import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
+import DocxZipper from '@core/DocxZipper.js';
+import type { Editor } from '../core/Editor.js';
+import { listsAttachWrapper } from './plan-engine/lists-wrappers.js';
+import { updateNumberingProperties } from '@core/commands/changeListLevel.js';
+import { clearExecutorRegistry } from './plan-engine/executor-registry.js';
+import { registerBuiltInExecutors } from './plan-engine/register-executors.js';
+
+type LoadedDocData = Awaited>;
+
+async function exportDocumentXml(editor: Editor, opts?: { isFinalDoc?: boolean }): Promise {
+ const zipper = new DocxZipper();
+ const buf = await editor.exportDocx(opts);
+ const files = await zipper.getDocxData(buf, true);
+ const byName: Record = {};
+ for (const f of files as Array<{ name: string; content: string }>) byName[f.name] = f.content;
+ return byName['word/document.xml'] || '';
+}
+
+function blockIdByText(editor: Editor, needle: string): string | null {
+ let id: string | null = null;
+ (editor as any).state.doc.descendants((node: any) => {
+ if (id || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes(needle)) {
+ id = node.attrs.sdBlockId ?? null;
+ }
+ return true;
+ });
+ return id;
+}
+
+describe('tracked numbering (numbering-001 repro)', () => {
+ let docData: LoadedDocData;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('docxbench-numbering-001.docx');
+ clearExecutorRegistry();
+ registerBuiltInExecutors();
+ });
+
+ it('attaching numbering in tracked mode emits numPr + w:pPrChange', async () => {
+ const { editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ user: { name: 'Agent', email: 'agent@example.com' },
+ } as any);
+
+ const nodes: any[] = [];
+ (editor as any).state.doc.descendants((node: any, pos: number) => {
+ if (node.isTextblock) {
+ nodes.push({
+ pos,
+ type: node.type.name,
+ id: node.attrs.sdBlockId,
+ numId: node.attrs.numberingProperties?.numId ?? node.attrs.paragraphProperties?.numberingProperties?.numId,
+ text: (node.textContent || '').slice(0, 30),
+ });
+ }
+ return true;
+ });
+ // eslint-disable-next-line no-console
+ console.log('[numbering] nodes:', JSON.stringify(nodes));
+
+ // Apply tracked numbering directly through the unit the attach wrapper uses.
+ const tr = (editor as any).state.tr;
+ let applied = false;
+ (editor as any).state.doc.descendants((node: any, pos: number) => {
+ if (applied || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes('Third obligation')) {
+ updateNumberingProperties({ numId: 1, ilvl: 0 }, node, pos, editor as any, tr, { trackChange: true });
+ applied = true;
+ }
+ return true;
+ });
+ expect(applied, 'numbering applied to Third obligation').toBe(true);
+ (editor as any).view.dispatch(tr);
+
+ const xml = await exportDocumentXml(editor);
+ const pPrChangeCount = (xml.match(/]*\bw:id="([^"]*)"/)?.[1];
+ expect(pPrChangeId, 'w:pPrChange carries a w:id').toBeTruthy();
+ expect(pPrChangeId, `w:pPrChange w:id must be decimal, got "${pPrChangeId}"`).toMatch(/^\d+$/);
+ editor.destroy();
+ });
+
+ // pPrChange w:ids must be unique across the document.
+ // The old per-uuid hash had no reservation, so two changes could collide.
+ // Routing through the shared Word revision-id allocator guarantees uniqueness.
+ it('multiple tracked pPrChanges get unique, allocator-minted w:ids', async () => {
+ const { editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ user: { name: 'Agent', email: 'agent@example.com' },
+ } as any);
+
+ // Attach tracked numbering to TWO unnumbered paragraphs → two API-created
+ // pPrChanges, each with its own uuid.
+ for (const needle of ['Third obligation', 'These terms are final']) {
+ const tr = (editor as any).state.tr;
+ let applied = false;
+ (editor as any).state.doc.descendants((node: any, pos: number) => {
+ if (applied || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes(needle)) {
+ updateNumberingProperties({ numId: 1, ilvl: 0 }, node, pos, editor as any, tr, { trackChange: true });
+ applied = true;
+ }
+ return true;
+ });
+ expect(applied, `numbering applied to "${needle}"`).toBe(true);
+ (editor as any).view.dispatch(tr);
+ }
+
+ const xml = await exportDocumentXml(editor);
+ const ids = [...xml.matchAll(/]*\bw:id="([^"]*)"/g)].map((m) => m[1]);
+ expect(ids.length, 'two tracked pPrChanges exported').toBe(2);
+ ids.forEach((id) => expect(id, `w:id must be decimal, got "${id}"`).toMatch(/^\d+$/));
+ expect(new Set(ids).size, `w:pPrChange ids must be unique, got ${JSON.stringify(ids)}`).toBe(ids.length);
+ editor.destroy();
+ });
+
+ // A FINAL-doc export flattens tracked
+ // changes to the accepted result. A tracked numbering change must export as
+ // the accepted numbering (w:numPr) with NO pending w:pPrChange — same as the
+ // ins/del translators strip their wrappers when isFinalDoc.
+ it('final-doc export flattens a tracked pPrChange (numbering kept, no w:pPrChange)', async () => {
+ const { editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ user: { name: 'Agent', email: 'agent@example.com' },
+ } as any);
+
+ const tr = (editor as any).state.tr;
+ let applied = false;
+ (editor as any).state.doc.descendants((node: any, pos: number) => {
+ if (applied || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes('Third obligation')) {
+ updateNumberingProperties({ numId: 1, ilvl: 0 }, node, pos, editor as any, tr, { trackChange: true });
+ applied = true;
+ }
+ return true;
+ });
+ expect(applied, 'tracked numbering applied').toBe(true);
+ (editor as any).view.dispatch(tr);
+
+ // Regular export keeps the pending revision...
+ const draftXml = await exportDocumentXml(editor);
+ expect(
+ (draftXml.match(/>;
+
+function makeEditor(docData: LoadedDocData): Editor {
+ const { editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ user: { name: 'Agent', email: 'agent@example.com' },
+ } as any);
+ return editor;
+}
+
+function listChanges(editor: Editor): any[] {
+ const listed: any = trackChangesListWrapper(editor, { in: 'all', limit: 250, offset: 0 } as any);
+ return listed?.items ?? listed?.changes ?? (Array.isArray(listed) ? listed : []);
+}
+
+function idOf(change: any): string {
+ return change.id ?? change.entityId ?? change.address?.entityId;
+}
+
+/** Apply tracked numbering to the block containing `needle`; returns applied. */
+function applyTrackedNumbering(editor: Editor, needle: string): boolean {
+ const tr = (editor as any).state.tr;
+ let applied = false;
+ (editor as any).state.doc.descendants((node: any, pos: number) => {
+ if (applied || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes(needle)) {
+ updateNumberingProperties({ numId: 1, ilvl: 0 }, node, pos, editor as any, tr, { trackChange: true });
+ applied = true;
+ }
+ return true;
+ });
+ if (applied) (editor as any).view.dispatch(tr);
+ return applied;
+}
+
+function hasNumbering(editor: Editor, needle: string): boolean {
+ let found = false;
+ (editor as any).state.doc.descendants((node: any) => {
+ if (found || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes(needle)) {
+ const np = node.attrs.numberingProperties ?? node.attrs.paragraphProperties?.numberingProperties;
+ found = Boolean(np && np.numId);
+ }
+ return true;
+ });
+ return found;
+}
+
+const TARGET = 'Third obligation';
+
+describe('tracked numbering is reviewable via the Document API', () => {
+ let docData: LoadedDocData;
+ let editor: Editor | undefined;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('docxbench-numbering-001.docx');
+ clearExecutorRegistry();
+ registerBuiltInExecutors();
+ });
+
+ afterEach(() => {
+ editor?.destroy();
+ editor = undefined;
+ });
+
+ it('surfaces a tracked pPrChange in trackChanges.list', () => {
+ editor = makeEditor(docData);
+ const beforeIds = new Set(listChanges(editor).map(idOf));
+
+ expect(applyTrackedNumbering(editor, TARGET), 'numbering applied').toBe(true);
+ expect(hasNumbering(editor, TARGET), 'numbering present after attach').toBe(true);
+
+ const after = listChanges(editor);
+ const fresh = after.filter((c) => !beforeIds.has(idOf(c)));
+ // eslint-disable-next-line no-console
+ console.log(
+ '[ppr-review] listed:',
+ JSON.stringify(after.map((c) => ({ id: idOf(c), type: c.type, hasFormat: c.hasFormat }))),
+ );
+ expect(fresh.length, 'the tracked numbering change is now listed').toBe(1);
+ expect(fresh[0].type, 'listed as a formatting change').toBe('format');
+ });
+
+ it('reject restores the unnumbered state and clears the change', () => {
+ editor = makeEditor(docData);
+ const beforeIds = new Set(listChanges(editor).map(idOf));
+ applyTrackedNumbering(editor, TARGET);
+ const pprId = idOf(listChanges(editor).find((c) => !beforeIds.has(idOf(c))));
+
+ const receipt: any = trackChangesRejectWrapper(editor, { id: pprId } as any);
+ expect(receipt?.success, 'reject succeeds').toBe(true);
+ expect(hasNumbering(editor, TARGET), 'numbering removed on reject').toBe(false);
+ expect(
+ listChanges(editor).some((c) => idOf(c) === pprId),
+ 'change gone from list',
+ ).toBe(false);
+ });
+
+ it('accept keeps the numbering and clears the change', () => {
+ editor = makeEditor(docData);
+ const beforeIds = new Set(listChanges(editor).map(idOf));
+ applyTrackedNumbering(editor, TARGET);
+ const pprId = idOf(listChanges(editor).find((c) => !beforeIds.has(idOf(c))));
+
+ const receipt: any = trackChangesAcceptWrapper(editor, { id: pprId } as any);
+ expect(receipt?.success, 'accept succeeds').toBe(true);
+ expect(hasNumbering(editor, TARGET), 'numbering kept on accept').toBe(true);
+ expect(
+ listChanges(editor).some((c) => idOf(c) === pprId),
+ 'change gone from list',
+ ).toBe(false);
+ });
+
+ // A tracked pPrChange carries author/date revision
+ // metadata. The tracked lists.attach path must fail early when no user is
+ // configured — same as the ins/del and lists.insert tracked paths — instead
+ // of silently stamping a blank author. (Guard runs before target resolution.)
+ it('tracked lists.attach with no configured user fails early (no blank-author pPrChange)', () => {
+ editor = makeEditor(docData);
+ (editor as any).options.user = null; // simulate an editor with no configured user
+ expect(() =>
+ listsAttachWrapper(
+ editor as any,
+ {
+ target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' },
+ attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'l1' },
+ level: 0,
+ } as any,
+ { changeMode: 'tracked' } as any,
+ ),
+ ).toThrow(/requires a user/i);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts
index 0c9cd9a00c..94e38f8390 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts
@@ -171,12 +171,31 @@ function extractBlockFormatting(
underline?: boolean;
color?: string;
alignment?: string;
+ indent?: { left?: number; right?: number; firstLine?: number; hanging?: number };
headingLevel?: number;
} {
const pProps = (node.attrs as Record).paragraphProperties as
- | { styleId?: string; justification?: string }
+ | {
+ styleId?: string;
+ justification?: string;
+ indent?: { left?: number; right?: number; firstLine?: number; hanging?: number };
+ }
| undefined;
const styleId = pProps?.styleId ?? null;
+ // Direct paragraph indentation (twips), so a contextual-formatting match can
+ // align an inserted paragraph with its neighbors instead of leaving it flush.
+ const rawIndent = pProps?.indent;
+ // Only NON-ZERO fields: a 0 carries no indent intent, and emitting both
+ // firstLine:0 and hanging:0 trips setIndentation's mutual-exclusivity guard
+ // (firstLine and hanging cannot coexist).
+ const indent =
+ rawIndent && typeof rawIndent === 'object'
+ ? (Object.fromEntries(
+ (['left', 'right', 'firstLine', 'hanging'] as const)
+ .filter((k) => typeof rawIndent[k] === 'number' && (rawIndent[k] as number) !== 0)
+ .map((k) => [k, rawIndent[k]]),
+ ) as { left?: number; right?: number; firstLine?: number; hanging?: number })
+ : undefined;
let fontFamily: string | undefined;
let fontSize: number | undefined;
@@ -229,6 +248,7 @@ function extractBlockFormatting(
...(underline ? { underline } : {}),
...(color ? { color } : {}),
...(pProps?.justification ? { alignment: pProps.justification } : {}),
+ ...(indent && Object.keys(indent).length > 0 ? { indent } : {}),
...(headingLevel ? { headingLevel } : {}),
};
}
@@ -358,6 +378,16 @@ export function blocksListWrapper(editor: Editor, input?: BlocksListInput): Bloc
})
: undefined;
+ // Computed numbering rendering (markerText/path) is maintained on the
+ // node by the numbering plugin. Surfacing it here lets agents see legal
+ // clause numbers ("2.3.") that live on numbered headings/paragraphs and
+ // are otherwise invisible to every read operation.
+ const listRendering = (
+ candidate.node.attrs as {
+ listRendering?: { markerText?: string; path?: number[]; numberingType?: string } | null;
+ }
+ )?.listRendering;
+
return {
ordinal: offset + i,
nodeId: candidate.nodeId,
@@ -366,6 +396,15 @@ export function blocksListWrapper(editor: Editor, input?: BlocksListInput): Bloc
...(fullText !== undefined ? { text: fullText } : {}),
isEmpty: textLength === 0,
...extractBlockFormatting(candidate.node, styleCtx),
+ ...(listRendering
+ ? {
+ numbering: {
+ marker: listRendering.markerText ?? null,
+ path: listRendering.path ?? null,
+ kind: listRendering.numberingType ?? null,
+ },
+ }
+ : {}),
...(numbering ? { paragraphNumbering: numbering } : {}),
...(ref ? { ref } : {}),
};
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts
index 340791eeb1..940030b299 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts
@@ -41,7 +41,7 @@ import { v4 as uuidv4 } from 'uuid';
import { DocumentApiAdapterError } from '../errors.js';
import { requireEditorCommand } from '../helpers/mutation-helpers.js';
import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js';
-import { checkRevision, getRevision } from './revision-tracker.js';
+import { checkRevision, getRevision, incrementRevision } from './revision-tracker.js';
import { resolveTextTarget, paginate, validatePaginationInput } from '../helpers/adapter-utils.js';
import { executeDomainCommand } from './plan-wrappers.js';
import { getCachedProjectedTrackedChangeSnapshot, projectSnapshots } from './track-changes-wrappers.js';
@@ -1271,6 +1271,15 @@ function replyToCommentHandler(
emitCommentLifecycleUpdate(editor, 'update', trackedPayload);
}
+ // A threaded reply lives only in the comment entity store — the underlying
+ // ProseMirror transaction is not `docChanged`, so the transaction-driven
+ // revision tracker never advances. A reply IS caller-visible exported
+ // content (it adds a w:comment to comments.xml + threading in
+ // commentsExtended.xml), so bump the revision explicitly. Without this the
+ // CLI host's `mutated = revisionBefore !== revisionAfter` gate reads false
+ // and the working document is never re-exported, silently dropping the reply.
+ incrementRevision(editor);
+
return { success: true, id: replyId, inserted: [toCommentAddress(replyId)] };
}
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts
index 8356f0cb69..02d88d2aec 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts
@@ -54,6 +54,7 @@ import { resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js';
import { normalizeReplacementText } from './replacement-normalizer.js';
import { getWordChanges } from './word-diff.js';
import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js';
+import { v4 as uuidv4 } from 'uuid';
import { Fragment, Slice } from 'prosemirror-model';
import type { Mark as ProseMirrorMark, MarkType, Node as ProseMirrorNode, NodeType } from 'prosemirror-model';
import type { Transaction } from 'prosemirror-state';
@@ -1249,10 +1250,44 @@ export function executeTextDelete(
return { changed: true };
}
+/**
+ * Wrap a next-state paragraphProperties object with a tracked `change`
+ * (w:pPrChange) recording the FORMER paragraph properties, so a reviewer sees
+ * a paragraph-format revision and accept/reject toggles it. No-op when not in
+ * tracked mode, when nothing changed, or when a pPrChange is already recorded
+ * (we never overwrite an existing reviewer's pPrChange).
+ *
+ * The former state is the existing paragraphProperties minus its own nested
+ * `change` — exactly what Word records inside ``.
+ */
+function withTrackedParagraphPropertyChange(
+ editor: Editor,
+ existing: Record | undefined,
+ nextParagraphProperties: Record,
+ changeMode: 'direct' | 'tracked' | undefined,
+): Record {
+ if (changeMode !== 'tracked') return nextParagraphProperties;
+ if (existing?.change) return nextParagraphProperties;
+
+ const { change: _existingChange, ...formerProperties } = existing ?? {};
+ const user = (editor?.options as { user?: { name?: string; email?: string } } | undefined)?.user ?? {};
+ return {
+ ...nextParagraphProperties,
+ change: {
+ id: uuidv4(),
+ author: user.name || '',
+ authorEmail: user.email || '',
+ date: new Date().toISOString(),
+ paragraphProperties: formerProperties,
+ },
+ };
+}
+
/**
* Applies alignment to the paragraph node(s) that contain the given range.
* Uses the same mechanism as paragraphsSetAlignmentWrapper: updates
- * paragraphProperties.justification via tr.setNodeMarkup.
+ * paragraphProperties.justification via tr.setNodeMarkup. In tracked mode the
+ * former paragraph properties are recorded as a w:pPrChange.
*/
function applyAlignmentToRange(
editor: Editor,
@@ -1260,6 +1295,7 @@ function applyAlignmentToRange(
absFrom: number,
absTo: number,
alignment: string,
+ changeMode?: 'direct' | 'tracked',
): boolean {
if (!alignment) return false;
@@ -1278,7 +1314,12 @@ function applyAlignmentToRange(
if (currentJustification === justification) return;
- const updated = { ...(existing ?? {}), justification };
+ const updated = withTrackedParagraphPropertyChange(
+ editor,
+ existing,
+ { ...(existing ?? {}), justification },
+ changeMode,
+ );
tr.setNodeMarkup(pos, undefined, { ...node.attrs, paragraphProperties: updated });
changed = true;
});
@@ -1315,6 +1356,7 @@ export function executeStyleApply(
target: CompiledRangeTarget,
step: StyleApplyStep,
mapping: Mapping,
+ changeMode?: 'direct' | 'tracked',
): { changed: boolean } {
let absFrom = mapping.map(target.absFrom);
let absTo = mapping.map(target.absTo);
@@ -1333,7 +1375,7 @@ export function executeStyleApply(
}
if (step.args.alignment) {
- changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment) || changed;
+ changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment, changeMode) || changed;
}
return { changed };
@@ -1481,6 +1523,7 @@ export function executeSpanStyleApply(
target: CompiledSpanTarget,
step: StyleApplyStep,
mapping: Mapping,
+ changeMode?: 'direct' | 'tracked',
): { changed: boolean } {
validateMappedSpanContiguity(target, mapping, step.id);
@@ -1503,7 +1546,7 @@ export function executeSpanStyleApply(
}
if (step.args.alignment) {
- changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment) || changed;
+ changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment, changeMode) || changed;
}
return { changed };
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts
index 78df79a6ac..ae8f69ae40 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts
@@ -684,7 +684,14 @@ export function listsAttachWrapper(
input: ListsAttachInput,
options?: MutationOptions,
): ListsMutateItemResult {
- rejectTrackedMode('lists.attach', options);
+ // Tracked mode is supported for attach: the former (unnumbered) paragraph
+ // properties are recorded as a w:pPrChange so accept/reject toggles the
+ // numbering. (Other list ops still reject tracked mode.)
+ const trackChange = (options?.changeMode ?? 'direct') === 'tracked';
+ // A tracked pPrChange carries author/date revision metadata; require a
+ // configured user so we never stamp a blank author (mirrors the ins/del and
+ // lists.insert tracked paths — never allow a tracked change with no author).
+ if (trackChange) ensureTrackedCapability(editor, { operation: 'lists.attach' });
const attachTo = resolveListItem(editor, input.attachTo);
if (attachTo.numId == null) {
@@ -720,7 +727,7 @@ export function listsAttachWrapper(
() => {
const { tr } = editor.state;
for (const block of targets) {
- updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr);
+ updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr, { trackChange });
}
dispatchEditorTransaction(editor, tr);
clearIndexCache(editor);
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/register-executors.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/register-executors.ts
index a416429c63..3e792a728e 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/register-executors.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/register-executors.ts
@@ -401,8 +401,8 @@ export function registerBuiltInExecutors(): void {
ctx,
targets,
step,
- (e, tr, t, s, m) => executeStyleApply(e, tr, t, s as StyleApplyStep, m),
- (e, tr, t, s, m) => executeSpanStyleApply(e, tr, t, s as StyleApplyStep, m),
+ (e, tr, t, s, m) => executeStyleApply(e, tr, t, s as StyleApplyStep, m, ctx.changeMode),
+ (e, tr, t, s, m) => executeSpanStyleApply(e, tr, t, s as StyleApplyStep, m, ctx.changeMode),
);
},
});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts
index ed1fd06af1..f26d684e8c 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts
@@ -574,6 +574,7 @@ function decideSingle(
id: string,
story: StoryLocator | undefined,
options: RevisionGuardOptions | undefined,
+ side?: 'inserted' | 'deleted',
): Receipt {
const resolved = resolveTrackedChangeInStory(hostEditor, {
kind: 'entity',
@@ -587,7 +588,12 @@ function decideSingle(
}
const commandName = decision === 'accept' ? 'acceptTrackedChangeById' : 'rejectTrackedChangeById';
- const command = (resolved.editor.commands as Record boolean) | undefined>)[commandName];
+ const command = (
+ resolved.editor.commands as Record<
+ string,
+ ((rawId: string, options?: { side?: 'inserted' | 'deleted' }) => boolean) | undefined
+ >
+ )[commandName];
if (typeof command !== 'function') {
throw new DocumentApiAdapterError(
'CAPABILITY_UNAVAILABLE',
@@ -599,7 +605,9 @@ function decideSingle(
checkRevision(hostEditor, options?.expectedRevision);
const commandRawId = resolved.change.commandRawId ?? resolved.change.rawId;
- const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(commandRawId)));
+ const receipt = executeDomainCommand(resolved.editor, () =>
+ Boolean(command(commandRawId, side ? { side } : undefined)),
+ );
if (receipt.steps[0]?.effect !== 'changed') {
return decisionFailureReceipt(
@@ -627,7 +635,14 @@ export function trackChangesAcceptWrapper(
input: TrackChangesAcceptInput,
options?: RevisionGuardOptions,
): Receipt {
- return decideSingle(editor, 'accept', input.id, input.story, options);
+ return decideSingle(
+ editor,
+ 'accept',
+ input.id,
+ input.story,
+ options,
+ (input as { side?: 'inserted' | 'deleted' }).side,
+ );
}
export function trackChangesRejectWrapper(
@@ -635,7 +650,14 @@ export function trackChangesRejectWrapper(
input: TrackChangesRejectInput,
options?: RevisionGuardOptions,
): Receipt {
- return decideSingle(editor, 'reject', input.id, input.story, options);
+ return decideSingle(
+ editor,
+ 'reject',
+ input.id,
+ input.story,
+ options,
+ (input as { side?: 'inserted' | 'deleted' }).side,
+ );
}
function decideAll(
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/ppr-change-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/ppr-change-export.integration.test.ts
new file mode 100644
index 0000000000..7fbb448c62
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/ppr-change-export.integration.test.ts
@@ -0,0 +1,127 @@
+/* @vitest-environment jsdom */
+
+import { beforeAll, describe, expect, it } from 'vitest';
+import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
+import DocxZipper from '@core/DocxZipper.js';
+import type { Editor } from '../core/Editor.js';
+import { executePlan } from './plan-engine/executor.js';
+import { clearExecutorRegistry } from './plan-engine/executor-registry.js';
+import { registerBuiltInExecutors } from './plan-engine/register-executors.js';
+
+type LoadedDocData = Awaited>;
+
+async function exportDocumentXml(editor: Editor): Promise {
+ const zipper = new DocxZipper();
+ const buf = await editor.exportDocx();
+ const files = await zipper.getDocxData(buf, true);
+ const byName: Record = {};
+ for (const f of files as Array<{ name: string; content: string }>) byName[f.name] = f.content;
+ return byName['word/document.xml'] || '';
+}
+
+describe('pPrChange export path (proves the representation)', () => {
+ let docData: LoadedDocData;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('docxbench-format-paragraph-001.docx');
+ clearExecutorRegistry();
+ registerBuiltInExecutors();
+ });
+
+ it('apply path: format.apply alignment in tracked mode emits w:jc + w:pPrChange', async () => {
+ const { editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ user: { name: 'Agent', email: 'agent@example.com' },
+ } as any);
+
+ // Find the target paragraph's sdBlockId/nodeId.
+ let nodeId: string | null = null;
+ (editor as any).state.doc.descendants((node: any) => {
+ if (nodeId || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes('shall be centered')) {
+ nodeId = node.attrs.sdBlockId ?? null;
+ }
+ return true;
+ });
+ expect(nodeId, 'target nodeId resolved').toBeTruthy();
+
+ executePlan(
+ editor as any,
+ {
+ changeMode: 'tracked',
+ steps: [
+ {
+ id: 's1',
+ op: 'format.apply',
+ where: { by: 'block', nodeType: 'paragraph', nodeId },
+ args: { alignment: 'center', scope: 'block' },
+ },
+ ],
+ } as any,
+ );
+
+ const xml = await exportDocumentXml(editor);
+ // eslint-disable-next-line no-console
+ console.log(
+ '[ppr-apply] has w:jc center =',
+ / {
+ const { editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ });
+
+ // Find the target paragraph and stamp justification + a pPrChange record.
+ const tr = (editor as any).state.tr;
+ let found = false;
+ (editor as any).state.doc.descendants((node: any, pos: number) => {
+ if (found || !node.isTextblock) return true;
+ if (typeof node.textContent === 'string' && node.textContent.includes('shall be centered')) {
+ const existing = (node.attrs.paragraphProperties ?? {}) as Record;
+ const updated = {
+ ...existing,
+ justification: 'center',
+ change: {
+ id: '900',
+ author: 'Agent',
+ date: '2026-06-25T00:00:00Z',
+ paragraphProperties: {}, // former state: no explicit justification (left)
+ },
+ };
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, paragraphProperties: updated });
+ found = true;
+ }
+ return true;
+ });
+ expect(found, 'target paragraph found').toBe(true);
+ (editor as any).view.dispatch(tr);
+
+ const xml = await exportDocumentXml(editor);
+ // eslint-disable-next-line no-console
+ console.log(
+ '[ppr-export] has w:jc center =',
+ />;
+
+function listChanges(editor: Editor): any[] {
+ const listed: any = trackChangesListWrapper(editor, { in: 'all', limit: 250, offset: 0 } as any);
+ return listed?.items ?? listed?.changes ?? (Array.isArray(listed) ? listed : []);
+}
+
+describe('targeted reject of one deletion (resolve-reject-del-001 repro)', () => {
+ let docData: LoadedDocData;
+ let editor: Editor | undefined;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('docxbench-resolve-reject-del-001.docx');
+ clearExecutorRegistry();
+ registerBuiltInExecutors();
+ });
+
+ afterEach(() => {
+ editor?.destroy();
+ editor = undefined;
+ });
+
+ it('rejects only the deleted side of a paired replacement, leaving the insertion', async () => {
+ // Default (paired) mode: the adjacent w:del + w:ins import as one replacement.
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ }));
+
+ const before = listChanges(editor!);
+ // eslint-disable-next-line no-console
+ console.log(
+ '[repro] before:',
+ JSON.stringify(
+ before.map((c) => ({
+ id: c.id ?? c.entityId,
+ type: c.type,
+ author: c.author,
+ ins: c.insertedText,
+ del: c.deletedText,
+ })),
+ ),
+ );
+
+ const replacement = before.find((c) => c.type === 'replacement') ?? before[0];
+ expect(replacement, 'a replacement exists').toBeTruthy();
+ const replacementId = replacement?.id ?? replacement?.entityId;
+
+ // Reject ONLY the deleted side: restore " to the dock", keep the insertion tracked.
+ const receipt: any = trackChangesRejectWrapper(editor!, { id: replacementId, side: 'deleted' } as any);
+ // eslint-disable-next-line no-console
+ console.log('[repro] reject receipt:', JSON.stringify(receipt));
+
+ const after = listChanges(editor!);
+ // eslint-disable-next-line no-console
+ console.log(
+ '[repro] after:',
+ JSON.stringify(
+ after.map((c) => ({ id: c.id ?? c.entityId, type: c.type, author: c.author, excerpt: c.excerpt })),
+ ),
+ );
+
+ const text = (editor as any).state.doc.textContent as string;
+ // eslint-disable-next-line no-console
+ console.log('[repro] text:', JSON.stringify(text));
+
+ // The deletion is gone; an insertion remains tracked.
+ expect(
+ after.some((c) => c.type === 'delete'),
+ 'deletion removed',
+ ).toBe(false);
+ expect(
+ after.some((c) => c.type === 'insert' || c.type === 'replacement'),
+ 'insertion preserved',
+ ).toBe(true);
+ // The deleted text is restored as live content.
+ expect(text).toContain(' to the dock');
+ const priorAfter = after.filter((c) => (c.author || '').includes('J. Prior'));
+ expect(priorAfter.length, 'exactly one J. Prior change remains').toBe(1);
+ });
+
+ // After one side of a replacement is resolved, the
+ // surviving side must resolve normally — even if it still carries the
+ // "replacement" label. A one-sided replacement must NOT fail with
+ // "missing inserted or deleted side".
+ it('the surviving side of a replacement resolves normally after one side is rejected', async () => {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ }));
+
+ const rep = listChanges(editor!).find((c) => c.type === 'replacement');
+ expect(rep, 'a replacement exists').toBeTruthy();
+ trackChangesRejectWrapper(editor!, { id: rep!.id ?? rep!.entityId, side: 'deleted' } as any);
+
+ const survivor = listChanges(editor!)[0];
+ expect(survivor, 'a survivor remains').toBeTruthy();
+ const survivorId = survivor.id ?? survivor.entityId;
+ // eslint-disable-next-line no-console
+ console.log('[survivor] type=', survivor.type, 'id=', survivorId);
+
+ const receipt: any = trackChangesAcceptWrapper(editor!, { id: survivorId } as any);
+ // eslint-disable-next-line no-console
+ console.log('[survivor] accept receipt:', JSON.stringify(receipt));
+ expect(receipt?.success, 'the surviving half accepts without a missing-side error').toBe(true);
+ expect(listChanges(editor!).length, 'no tracked changes remain').toBe(0);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts
index 5406a5b224..f339d83838 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts
@@ -13,6 +13,7 @@ import {
tablesClearBorderAdapter,
tablesApplyBorderPresetAdapter,
tablesSetShadingAdapter,
+ tablesClearShadingAdapter,
tablesInsertRowAdapter,
tablesInsertColumnAdapter,
tablesGetCellsAdapter,
@@ -599,6 +600,31 @@ describe('SD-2129: table convenience operations', () => {
const cellResult = tablesSetShadingAdapter(ed, { nodeId: firstCellId, color: '#FFF' }, DIRECT);
expect(cellResult.success).toBe(true);
});
+
+ it('clearShading on a cell also clears the `background` attr', () => {
+ // `background` is the render/export source of truth for cell fill; a
+ // cell-scoped clear must drop it or the cell stays shaded while the
+ // receipt reports success.
+ const firstCellBackground = (): unknown => {
+ let bg: unknown;
+ (ed.state.doc as any).descendants((node: any) => {
+ if (bg === undefined && node.type?.name === 'tableCell') bg = node.attrs?.background ?? null;
+ return bg === undefined;
+ });
+ return bg;
+ };
+
+ const ed = createEditor();
+ const tableId = createTableAndGetId(ed);
+ const cells = tablesGetCellsAdapter(ed, { nodeId: tableId });
+ const firstCellId = cells.cells[0]!.nodeId;
+
+ expect(tablesSetShadingAdapter(ed, { nodeId: firstCellId, color: '#FF0000' }, DIRECT).success).toBe(true);
+ expect(firstCellBackground()).toBeTruthy();
+
+ expect(tablesClearShadingAdapter(ed, { nodeId: firstCellId }, DIRECT).success).toBe(true);
+ expect(firstCellBackground()).toBeFalsy();
+ });
});
describe('SD-2540 insert ergonomics', () => {
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts
index 875b76c7f5..37e647d71a 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts
@@ -3654,7 +3654,16 @@ export function tablesSetShadingAdapter(
currentProps.shading = { fill: normalizedColor, val: 'clear', color: 'auto' };
const syncAttrs = resolved.scope === 'table' ? syncExtractedTableAttrs(currentProps) : {};
- tr.setNodeMarkup(resolved.pos, null, { ...currentAttrs, [propsKey]: currentProps, ...syncAttrs });
+ const nextAttrs: Record = { ...currentAttrs, [propsKey]: currentProps, ...syncAttrs };
+ if (resolved.scope === 'cell') {
+ // The cell renderer and the DOCX exporter read `background`, not
+ // tableCellProperties.shading — without this the receipt reports
+ // success while the document shows (and saves) nothing. Mirrors
+ // applyShadingToCells, which the table-level path already uses.
+ if (normalizedColor === 'auto') delete nextAttrs.background;
+ else nextAttrs.background = { color: normalizedColor };
+ }
+ tr.setNodeMarkup(resolved.pos, null, nextAttrs);
if (resolved.scope === 'table') {
applyShadingToCells(tr, resolved.node, resolved.pos + 1, normalizedColor);
@@ -3700,7 +3709,16 @@ export function tablesClearShadingAdapter(
delete currentProps.shading;
const syncAttrs = resolved.scope === 'table' ? syncExtractedTableAttrs(currentProps) : {};
- tr.setNodeMarkup(resolved.pos, null, { ...currentAttrs, [propsKey]: currentProps, ...syncAttrs });
+ const nextAttrs: Record = { ...currentAttrs, [propsKey]: currentProps, ...syncAttrs };
+ if (resolved.scope === 'cell') {
+ // The cell renderer and DOCX exporter read `background`, not
+ // tableCellProperties.shading (mirrors tablesSetShadingAdapter). A
+ // cell-scoped clear must drop `background` too, or the cell stays shaded
+ // on screen and on export while the receipt reports success. The
+ // table-scoped path already clears it per-cell below.
+ delete nextAttrs.background;
+ }
+ tr.setNodeMarkup(resolved.pos, null, nextAttrs);
if (resolved.scope === 'table') {
const tableNode = resolved.node;
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js
index 62de7b6871..9595a8950c 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js
@@ -165,6 +165,18 @@ export const decideTrackedChanges = ({ state, editor, decision, target, replacem
* - `{ from, to }` → `{ kind: 'range', from, to }`
* - canonical `{ kind: 'id'|'range'|'all' }` is passed through.
*/
+/**
+ * Normalize a replacement-side selector to a canonical {@link SegmentSide}
+ * value, or null when absent/unrecognized.
+ *
+ * @param {unknown} value
+ * @returns {'inserted' | 'deleted' | null}
+ */
+const normalizeReplacementSide = (value) => {
+ if (value === SegmentSide.Inserted || value === SegmentSide.Deleted) return value;
+ return null;
+};
+
const normalizeDecisionTarget = (target) => {
if (!target || typeof target !== 'object') {
return { ok: false, failure: failure('INVALID_TARGET', 'decision target must be an object.') };
@@ -174,7 +186,14 @@ const normalizeDecisionTarget = (target) => {
if (typeof t.id !== 'string' || !t.id) {
return { ok: false, failure: failure('INVALID_TARGET', 'target.kind = "id" requires a non-empty id.') };
}
- return { ok: true, value: { kind: 'id', id: t.id } };
+ const side = normalizeReplacementSide(t.side);
+ if (t.side != null && !side) {
+ return {
+ ok: false,
+ failure: failure('INVALID_TARGET', 'target.side must be "inserted" or "deleted" when provided.'),
+ };
+ }
+ return { ok: true, value: { kind: 'id', id: t.id, ...(side ? { side } : {}) } };
}
if (t.kind === 'range') {
const from = Number(t.from);
@@ -273,6 +292,7 @@ const resolveTargetToSelections = ({ graph, normalized }) => {
change,
coverage: 'full',
ranges: change.segments.map((s) => ({ from: s.from, to: s.to })),
+ ...(normalized.side ? { side: normalized.side } : {}),
},
],
};
@@ -567,11 +587,65 @@ const buildMutationPlan = ({ state, graph, selections, decision }) => {
// through the dedicated child planner here (instead of the normal path
// below) keeps `scope:'all'` from double-planning the same change. Recorded
// as an affected child in the side-effect pass below.
- if (isInsideStayingTable(change)) {
+ // A pPr change is EXCLUDED: it has no inline mark (its type is Formatting but
+ // there is nothing to toggle), so the mark-based child planner would no-op
+ // on accept / throw on reject. It falls through to the normal path below and
+ // is resolved by id via planPprDecision — same guard as the side-effect
+ // sweep.
+ if (!change.pprChange && isInsideStayingTable(change)) {
const failureResult = planContainedInlineChild(change);
if (failureResult) return { ok: false, failure: failureResult };
continue;
}
+ // Side-targeted replacement decision: resolve only the inserted OR deleted
+ // half of a replacement, leaving the other half as a standalone pending
+ // change. The shared change id is intentionally NOT retired — on the next
+ // graph rebuild the surviving side's marks project as a standalone
+ // insertion/deletion.
+ if (change.type === CanonicalChangeType.Replacement && selection.side) {
+ touched.add(change.id);
+ const sideResult = planReplacementSideDecision({
+ ops,
+ change,
+ decision,
+ side: selection.side,
+ removedRanges,
+ retired,
+ resolvedRanges,
+ });
+ if (!sideResult.ok) return { ok: false, failure: sideResult.failure };
+ continue;
+ }
+ // A `side` selector only means something on a paired replacement (choose the
+ // inserted or deleted half). Reaching here with a side set means the change
+ // is NOT a paired replacement — most commonly the targeted half was already
+ // resolved and only the other side survives as a standalone change. Fail
+ // closed unless the surviving standalone side is exactly the one requested;
+ // otherwise the generic planner below would silently resolve the OTHER side,
+ // acting against the caller's intent. Only `id` targets set selection.side
+ // (range/all never do), so this cannot narrow a range decision.
+ if (selection.side) {
+ const standaloneSide =
+ change.type === CanonicalChangeType.Insertion
+ ? 'inserted'
+ : change.type === CanonicalChangeType.Deletion
+ ? 'deleted'
+ : null;
+ if (standaloneSide !== selection.side) {
+ return {
+ ok: false,
+ failure: failure(
+ 'INVALID_TARGET',
+ `target.side "${selection.side}" does not apply: this change is not a paired replacement` +
+ `${standaloneSide ? ` (its only side is "${standaloneSide}")` : ''}. ` +
+ 'The targeted side may have already been resolved.',
+ { details: { changeId: change.id, requestedSide: selection.side, currentSide: standaloneSide } },
+ ),
+ };
+ }
+ // standaloneSide === selection.side → fall through to the standalone
+ // insertion/deletion planner below, which resolves it correctly.
+ }
const isFull = selection.coverage === 'full';
if (!isFull) {
if (change.type === CanonicalChangeType.Structural) {
@@ -609,7 +683,10 @@ const buildMutationPlan = ({ state, graph, selections, decision }) => {
}
}
touched.add(change.id);
- if (isFull) {
+ // A pPr change flips a node attr and changes no text content, so its
+ // whole-block segment must NOT enter resolvedRanges — otherwise
+ // planCommentEffects would detach unrelated comments anchored in that block.
+ if (isFull && !change.pprChange) {
for (const segment of change.segments) {
resolvedRanges.push({
from: segment.from,
@@ -619,7 +696,13 @@ const buildMutationPlan = ({ state, graph, selections, decision }) => {
}
}
- if (change.type === CanonicalChangeType.Structural) {
+ if (change.pprChange) {
+ // Paragraph-property change (w:pPrChange). Routed before the type-based
+ // branches: it is typed Formatting but has no mark, so the mark-based
+ // formatting planner cannot resolve it.
+ const pprResult = planPprDecision({ ops, change, decision, retired });
+ if (!pprResult.ok) return { ok: false, failure: pprResult.failure };
+ } else if (change.type === CanonicalChangeType.Structural) {
const structuralResult = planStructuralDecision({ ops, change, decision, removedRanges, retired });
if (!structuralResult.ok) return { ok: false, failure: structuralResult.failure };
} else if (
@@ -679,6 +762,19 @@ const buildMutationPlan = ({ state, graph, selections, decision }) => {
seenStaying.add(change);
if (decidedObjects.has(change) || cascadedInsideStayingTable.has(change.id)) continue;
if (!isInsideStayingTable(change)) continue;
+ // A pPr change has no inline mark, so it must NOT go through the mark-based
+ // contained-child planner (that would deref a null mark). But accepting a
+ // table accepts its contents, so a contained pPr revision IS resolved here
+ // — by id via planPprDecision — rather than left pending. This covers
+ // accept-table-BY-ID (the pPr is not in the selection, only the sweep sees
+ // it); accept-all resolves it in the main loop.
+ if (change.pprChange) {
+ cascadedInsideStayingTable.add(change.id);
+ touched.add(change.id);
+ const pprResult = planPprDecision({ ops, change, decision, retired });
+ if (!pprResult.ok) return { ok: false, failure: pprResult.failure };
+ continue;
+ }
const failureResult = planContainedInlineChild(change);
if (failureResult) return { ok: false, failure: failureResult };
}
@@ -892,6 +988,35 @@ const planStructuralDecision = ({ ops, change, decision, removedRanges, retired
return { ok: true };
};
+/**
+ * Plan an accept/reject for a paragraph-property change (w:pPrChange: tracked
+ * numbering / alignment). There is no mark — the record lives on
+ * `node.attrs.paragraphProperties.change`. Both decisions keep the paragraph:
+ * accept → drop the `change` record, keep the new properties (numbering stays)
+ * reject → restore the former properties recorded on the change (numbering gone)
+ * Applied via `setNodeMarkup` in the mark pass (position-stable, same node size).
+ *
+ * @param {{ ops: any[], change: any, decision: 'accept'|'reject', retired: Set }} input
+ */
+const planPprDecision = ({ ops, change, decision, retired }) => {
+ const ppr = change.pprChange;
+ if (!ppr) {
+ return {
+ ok: false,
+ failure: failure('CAPABILITY_UNAVAILABLE', `change "${change.id}" is not a paragraph-property change.`),
+ };
+ }
+ ops.push({
+ kind: 'resolvePprChange',
+ from: ppr.from,
+ changeId: change.id,
+ decision,
+ formerProperties: ppr.formerProperties,
+ });
+ retired.add(change.id);
+ return { ok: true };
+};
+
const planReplacementDecision = ({ ops, graph, change, decision, removedRanges, retired }) => {
const inserted = change.insertedSegments;
const deleted = change.deletedSegments;
@@ -959,6 +1084,59 @@ const planReplacementDecision = ({ ops, graph, change, decision, removedRanges,
return { ok: true };
};
+/**
+ * Resolve ONE side of a replacement, treating each half as its own pending
+ * change. The other half is left untouched (it survives as a standalone
+ * insertion or deletion after the graph rebuilds), so the change id is NOT
+ * retired here.
+ *
+ * - deleted side, accept → remove the deleted content (deletion takes effect)
+ * - deleted side, reject → keep the text, drop the delete mark (deletion undone)
+ * - inserted side, accept → keep the text, drop the insert mark (insertion kept)
+ * - inserted side, reject → remove the inserted content (insertion undone)
+ *
+ * @param {{ ops: any[], change: any, decision: 'accept'|'reject', side: 'inserted'|'deleted', removedRanges: any[], retired: Set, resolvedRanges: any[] }} input
+ */
+const planReplacementSideDecision = ({ ops, change, decision, side, removedRanges, resolvedRanges }) => {
+ const inserted = change.insertedSegments;
+ const deleted = change.deletedSegments;
+ if (!inserted.length || !deleted.length) {
+ return {
+ ok: false,
+ failure: failure('PRECONDITION_FAILED', `replacement "${change.id}" missing inserted or deleted side.`),
+ };
+ }
+
+ const segments = side === SegmentSide.Inserted ? inserted : deleted;
+ for (const seg of segments) {
+ resolvedRanges.push({ from: seg.from, to: seg.to, cause: `${decision}-replacement-${side}:${change.id}` });
+ }
+
+ const removeContent = (seg, cause) => {
+ ops.push({ kind: 'removeContent', from: seg.from, to: seg.to, changeId: change.id, side });
+ removedRanges.push({ from: seg.from, to: seg.to, cause: `${cause}:${change.id}` });
+ };
+ const dropMark = (seg) => pushRemoveMarkOpsForSegment({ ops, segment: seg, changeId: change.id, side });
+
+ if (side === SegmentSide.Deleted) {
+ if (decision === 'accept') {
+ for (const seg of deleted) removeContent(seg, 'accept-replacement-deleted-side');
+ } else {
+ for (const seg of deleted) dropMark(seg);
+ }
+ } else {
+ if (decision === 'accept') {
+ for (const seg of inserted) dropMark(seg);
+ } else {
+ for (const seg of inserted) removeContent(seg, 'reject-replacement-inserted-side');
+ }
+ }
+
+ // Intentionally do NOT retire change.id: the untouched side remains pending
+ // and re-projects as a standalone change on the next rebuild.
+ return { ok: true };
+};
+
const getParentRestoreContext = ({ graph, change }) => {
const explicit = getExplicitParentRestoreContext({ graph, change });
if (explicit) return explicit;
@@ -1315,6 +1493,41 @@ const applyPlan = ({ state, plan }) => {
}
continue;
}
+ if (op.kind === 'resolvePprChange') {
+ // Paragraph-property accept/reject. setNodeMarkup is position-stable, so
+ // it is safe in the mark pass; map through the accumulated mapping in
+ // case an earlier op shifted positions.
+ const mappedFrom = tr.mapping.map(op.from, 1);
+ const node = tr.doc.nodeAt(mappedFrom);
+ const pp = node?.attrs?.paragraphProperties;
+ if (node && pp && pp.change) {
+ if (op.decision === 'accept') {
+ // Keep the new properties; drop only the change record. Top-level
+ // numberingProperties / listRendering already reflect the accepted
+ // state, so they are left untouched.
+ const kept = { ...pp };
+ delete kept.change;
+ tr.setNodeMarkup(mappedFrom, undefined, { ...node.attrs, paragraphProperties: kept });
+ } else {
+ // Reject: restore the former paragraph properties. The attach path
+ // mirrors numbering onto the TOP-LEVEL numberingProperties (and the
+ // numbering plugin computes listRendering), and the block index /
+ // list rendering read those as a fallback — so sync them to the
+ // restored state here too. Otherwise a rejected block keeps behaving
+ // and rendering as a numbered list item in any path that does not
+ // re-run the numbering plugin.
+ const former = { ...(op.formerProperties || {}) };
+ const nextAttrs = {
+ ...node.attrs,
+ paragraphProperties: former,
+ numberingProperties: former.numberingProperties ?? null,
+ };
+ if (!former.numberingProperties) nextAttrs.listRendering = null;
+ tr.setNodeMarkup(mappedFrom, undefined, nextAttrs);
+ }
+ }
+ continue;
+ }
}
for (const op of contentOps) {
tr.step(new ReplaceStep(op.from, op.to, Slice.empty));
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js
index 018a195122..9399615724 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js
@@ -58,6 +58,48 @@ const formatAttrsWithSnapshots = (id, user = SAME_USER, before = [], after = [])
after,
});
+describe('id-target side selector on a non-paired change', () => {
+ // 'ins-1' is a STANDALONE insertion (no paired deletion) — the shape you get
+ // after the deleted half of a replacement was already resolved.
+ const standaloneInsertionState = () => {
+ const schema = createReviewGraphTestSchema();
+ const { state } = stateFromTrackedSpans({
+ schema,
+ spans: [
+ { text: 'before ' },
+ { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-1') }] },
+ { text: ' after' },
+ ],
+ });
+ return state;
+ };
+
+ it('fails closed when side targets the half that is not present', () => {
+ const result = decideTrackedChanges({
+ state: standaloneInsertionState(),
+ editor: editorFor(SAME_USER),
+ decision: 'reject',
+ target: { kind: 'id', id: 'ins-1', side: 'deleted' },
+ });
+ // Must NOT silently resolve the surviving insertion when 'deleted' was asked.
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('INVALID_TARGET');
+ });
+
+ it('resolves normally when side matches the surviving standalone side', () => {
+ const state = standaloneInsertionState();
+ const result = decideTrackedChanges({
+ state,
+ editor: editorFor(SAME_USER),
+ decision: 'reject',
+ target: { kind: 'id', id: 'ins-1', side: 'inserted' },
+ });
+ expect(result.ok).toBe(true);
+ const next = state.apply(result.tr);
+ expect(next.doc.textContent).toBe('before after');
+ });
+});
+
describe('decideTrackedChanges overlap behavior', () => {
it('accept insertion by id keeps content and removes the trackInsert mark', () => {
const schema = createReviewGraphTestSchema();
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js
index 5a4d409573..cbec8f5370 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js
@@ -28,6 +28,7 @@ import {
} from './mark-metadata.js';
import { BODY_STORY, buildStoryKey } from './story-locator.js';
import { enumerateStructuralRowChanges } from '../trackChangesHelpers/structuralRowChanges.js';
+import { enumeratePprChanges } from '../trackChangesHelpers/pprChanges.js';
// ---------------------------------------------------------------------------
// Types
@@ -56,6 +57,7 @@ import { enumerateStructuralRowChanges } from '../trackChangesHelpers/structural
* @property {string} parentSide
* @property {'parent'|'child'|'standalone'} overlapRole
* @property {boolean} [structural] True for a whole-table structural segment (no inline mark).
+ * @property {boolean} [pprChange] True for a paragraph-property (w:pPrChange) segment (no inline mark).
* @property {Array} [nodePath] optional diagnostics nodePath.
*/
@@ -134,9 +136,11 @@ import { enumerateStructuralRowChanges } from '../trackChangesHelpers/structural
export const buildReviewGraph = ({ state, story = BODY_STORY, replacementsMode = 'paired' }) => {
const spans = enumerateTrackedMarkSpans(state);
const structuralChanges = enumerateStructuralRowChanges(state);
+ const pprChanges = enumeratePprChanges(state);
return buildGraphFromSpans({
spans,
structuralChanges,
+ pprChanges,
doc: state?.doc ?? null,
story,
replacementsMode,
@@ -199,7 +203,7 @@ export const invalidateReviewGraphCache = (editor) => {
// Internal builder
// ---------------------------------------------------------------------------
-const buildGraphFromSpans = ({ spans, structuralChanges = [], doc, story, replacementsMode }) => {
+const buildGraphFromSpans = ({ spans, structuralChanges = [], pprChanges = [], doc, story, replacementsMode }) => {
/** @type {Array<{ attrs: import('./mark-metadata.js').NormalizedTrackedAttrs, span: import('./segment-index.js').TrackedMarkSpan }>} */
const normalized = spans.map((span) => ({
attrs: readTrackedAttrs(span.mark, span.mark.type.name),
@@ -308,6 +312,30 @@ const buildGraphFromSpans = ({ spans, structuralChanges = [], doc, story, replac
appendToMap(byRevisionGroupId, logical.revisionGroupId, logical.id);
}
+ // 6c. Paragraph-property changes (w:pPrChange: tracked numbering / alignment).
+ // Like structural row changes these live on node attrs
+ // (`paragraphProperties.change`), not marks, so they are projected here
+ // into a decidable formatting change. Keyed by block position so two
+ // paragraphs never collide, with the record id as the public alias.
+ //
+ // Deliberately NON-POSITIONAL: the synthetic segment spans the whole
+ // block, so adding it to `mergedSegments` (→ graph.segments) or
+ // `bySegmentId` would let text-range decides, overlap queries, and
+ // comment-detach ranges collateral-hit it (it is not real text content).
+ // pPr changes are resolved only via the `changes` map (id/all →
+ // planPprDecision), so the segment stays on the logical change alone.
+ for (const ppr of pprChanges) {
+ const logical = buildPprLogicalChange({ ppr, doc, story });
+ if (!logical) continue;
+ const internalKey = `pprchange:${ppr.from}`;
+ if (changes.has(internalKey)) continue;
+ changes.set(internalKey, logical);
+ if (logical.id && logical.id !== internalKey && !changes.has(logical.id)) {
+ changes.set(logical.id, logical);
+ }
+ appendToMap(byRevisionGroupId, logical.revisionGroupId, logical.id);
+ }
+
// 7. Flat ordered segment list (kept in document order for range queries).
const segments = mergedSegments.slice().sort((a, b) => a.from - b.from || a.to - b.to);
@@ -472,6 +500,17 @@ const buildLogicalChange = ({ changeId, segments, doc, story, replacementsMode }
type = '';
}
+ // A replacement needs BOTH sides. Editor-created replacements persist an
+ // explicit changeType:"replacement" (+ replacementGroupId) on each half, so
+ // after one side is resolved (side-targeted accept/reject) the survivor still
+ // reports type "replacement" with an empty side. Downgrade it to the plain
+ // insertion/deletion it now is — otherwise the decision engine fails with
+ // "replacement missing inserted or deleted side" on accept/reject-all.
+ if (type === CanonicalChangeType.Replacement && !(inserted.length && deleted.length)) {
+ if (inserted.length) type = CanonicalChangeType.Insertion;
+ else if (deleted.length) type = CanonicalChangeType.Deletion;
+ }
+
const subtype = subtypeFromChangeType(type) ?? '';
const primary = segments[0]?.attrs ?? null;
@@ -659,6 +698,120 @@ const buildStructuralLogicalChange = ({ structural, doc, story }) => {
return logical;
};
+/**
+ * Project a paragraph-property change (w:pPrChange: tracked numbering /
+ * alignment) into a decidable formatting change. Mirrors
+ * {@link buildStructuralLogicalChange}: the change lives on node attrs, not a
+ * mark, so it carries a synthetic segment covering the block and a
+ * non-enumerable `pprChange` payload the decision engine reads to accept (drop
+ * the record, keep the new properties) or reject (restore the former ones).
+ *
+ * @param {{
+ * ppr: import('../trackChangesHelpers/pprChanges.js').PprChange,
+ * doc: import('prosemirror-model').Node | null,
+ * story: import('./story-locator.js').StoryLocator,
+ * }} input
+ * @returns {LogicalTrackedChange | null}
+ */
+const buildPprLogicalChange = ({ ppr, doc, story }) => {
+ const from = ppr.from;
+ const to = ppr.to;
+ if (!(from < to)) return null;
+ const side = SegmentSide.Formatting;
+
+ /** @type {import('./mark-metadata.js').NormalizedTrackedAttrs} */
+ const attrs = {
+ id: ppr.id,
+ revisionGroupId: ppr.id,
+ splitFromId: '',
+ changeType: CanonicalChangeType.Formatting,
+ replacementGroupId: '',
+ replacementSideId: '',
+ overlapParentId: '',
+ sourceIds: {},
+ sourceId: '',
+ importedAuthor: '',
+ origin: '',
+ author: ppr.author,
+ authorId: '',
+ authorEmail: ppr.authorEmail,
+ authorImage: ppr.authorImage,
+ date: ppr.date,
+ markType: '',
+ side,
+ subtype: ppr.subtype,
+ explicitChangeType: CanonicalChangeType.Formatting,
+ hasReviewMetadata: true,
+ };
+
+ /** @type {TrackedSegment} */
+ const segment = {
+ segmentId: `${ppr.id}:pprchange:${from}:${to}:0`,
+ changeId: ppr.id,
+ markType: '',
+ side,
+ from,
+ to,
+ text: '',
+ mark: /** @type {*} */ (null),
+ markRuns: [],
+ attrs,
+ parentId: '',
+ parentSide: '',
+ overlapRole: 'standalone',
+ // Attr-based (no inline mark), like structural segments. Overlap grouping is
+ // driven by `overlapParentId` (standalone here), so the block-spanning range
+ // never nests inline changes that happen to sit inside the same paragraph.
+ pprChange: true,
+ };
+ if (doc) {
+ try {
+ segment.text = doc.textBetween(from, to, ' ', '');
+ } catch {
+ segment.text = '';
+ }
+ }
+
+ const segments = [segment];
+ /** @type {LogicalTrackedChange} */
+ const logical = {
+ id: ppr.id,
+ type: CanonicalChangeType.Formatting,
+ subtype: ppr.subtype,
+ state: 'open',
+ segments,
+ coverageSegments: [...segments],
+ insertedSegments: [],
+ deletedSegments: [],
+ formattingSegments: [...segments],
+ replacement: null,
+ author: ppr.author,
+ authorId: '',
+ authorEmail: ppr.authorEmail,
+ authorImage: ppr.authorImage,
+ date: ppr.date,
+ sourceIds: {},
+ revisionGroupId: ppr.id,
+ splitFromId: '',
+ sourcePlatform: '',
+ story,
+ parent: null,
+ children: [],
+ before: [],
+ after: [],
+ excerpt: segment.text.length > 200 ? `${segment.text.slice(0, 200)}…` : segment.text,
+ };
+ // Payload the decision engine reads to apply node-level accept/reject.
+ // Non-enumerable so it never leaks into deterministic JSON or contract
+ // projections that iterate own keys.
+ Object.defineProperty(logical, 'pprChange', {
+ value: ppr,
+ enumerable: false,
+ });
+
+ return logical;
+};
+
const aggregateSourceIds = (segments) => {
/** @type {Record} */
const out = {};
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js
index 9273d63822..464feaf711 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js
@@ -252,16 +252,24 @@ describe('review-graph: invariants', () => {
expect(warnings.some((d) => d.code === 'INV_CHILD_MISSING_PARENT')).toBe(true);
});
- it('warns when a replacement is missing one side', () => {
- // Force a "replacement" type with only an inserted side by setting
- // explicit changeType on the mark.
+ it('downgrades a one-sided "replacement" (side-reject survivor) to a plain insertion — no missing-side warning', () => {
+ // Editor-created replacements persist changeType:"replacement" on each half.
+ // After one side is resolved, the survivor still reports that type with one
+ // empty side. The classifier downgrades it to the plain insertion it now is,
+ // so it resolves normally (accept/reject) instead of being flagged as a
+ // malformed one-sided replacement that the decision engine then rejects.
const { state } = stateFromTrackedSpans({
schema,
spans: [{ text: 'incomplete', marks: [insertMark({ id: 'r2', changeType: CanonicalChangeType.Replacement })] }],
});
const graph = buildReviewGraph({ state });
+ const change = graph.changes.get('r2');
+ expect(change.type, 'downgraded to insertion').toBe(CanonicalChangeType.Insertion);
+ expect(change.replacement, 'no replacement projection').toBeNull();
+ expect(change.insertedSegments).toHaveLength(1);
+ expect(change.deletedSegments).toHaveLength(0);
const warnings = runGraphInvariants(graph).warnings;
- expect(warnings.some((d) => d.code === 'INV_REPLACEMENT_MISSING_SIDE')).toBe(true);
+ expect(warnings.some((d) => d.code === 'INV_REPLACEMENT_MISSING_SIDE')).toBe(false);
});
});
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/structural-decisions.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/structural-decisions.test.js
index 63b84abb8c..350b0ee910 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/structural-decisions.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/structural-decisions.test.js
@@ -156,6 +156,88 @@ describe('structural decide semantics', () => {
expect((cellText.marks || []).some((m) => m.type.name === TrackInsertMarkName)).toBe(false);
});
+ it('resolves a tracked pPrChange inside a KEPT table via planPprDecision, not the mark cascade', () => {
+ // A tracked numbering change lives on a cell paragraph's node attr (not an
+ // inline mark). When accept-all keeps the table, the pPr change must be
+ // resolved by id (planPprDecision), NOT routed through the mark-based
+ // table-child cascade — which, since pPr is typed "formatting" with no mark
+ // to toggle, would no-op on accept (numbering + change record left behind).
+ const schema = createReviewGraphTestSchema();
+ const pprId = 'ppr-1';
+ const { state } = stateWithTrackedTable({
+ schema,
+ trackChange: insertTrackChange('t1'),
+ cellText: 'Numbered cell',
+ cellParagraphAttrs: {
+ paragraphProperties: {
+ numberingProperties: { numId: 1, ilvl: 0 },
+ change: {
+ id: pprId,
+ author: ALICE.name,
+ authorEmail: ALICE.email,
+ date: '2026-05-20T16:00:00Z',
+ paragraphProperties: {}, // former (unnumbered) state
+ },
+ },
+ },
+ });
+
+ // Both the structural table change and the pПr change are enumerated.
+ const graph = buildReviewGraph({ state });
+ expect(graph.changes.get('t1')?.type).toBe(CanonicalChangeType.Structural);
+ expect(graph.changes.get(pprId)?.pprChange, 'pPr change enumerated inside the table').toBeTruthy();
+
+ const result = decideTrackedChanges({ state, editor: editorFor(), decision: 'accept', target: { kind: 'all' } });
+ expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true);
+ const next = state.apply(result.tr);
+
+ // Table stays; pPr change record cleared (accept), numbering kept.
+ expect(next.doc.child(1).type.name).toBe('table');
+ const cellPara = next.doc.child(1).child(0).child(0).child(0);
+ expect(cellPara.attrs.paragraphProperties?.change, 'pPr change record cleared on accept').toBeFalsy();
+ expect(cellPara.attrs.paragraphProperties?.numberingProperties, 'numbering kept on accept').toBeTruthy();
+ });
+
+ it('accepting a tracked table BY ID also resolves a contained pPr change', () => {
+ // Accepting the table by its own change id keeps the table AND its contents.
+ // The contained pPr change is NOT in the selection (only the table is), so it
+ // is reached via the side-effect sweep — which must resolve it through
+ // planPprDecision rather than skip it, leaving numbering revisions pending.
+ const schema = createReviewGraphTestSchema();
+ const pprId = 'ppr-2';
+ const { state } = stateWithTrackedTable({
+ schema,
+ trackChange: insertTrackChange('t2'),
+ cellText: 'Numbered cell',
+ cellParagraphAttrs: {
+ paragraphProperties: {
+ numberingProperties: { numId: 1, ilvl: 0 },
+ change: {
+ id: pprId,
+ author: ALICE.name,
+ authorEmail: ALICE.email,
+ date: '2026-05-20T16:00:00Z',
+ paragraphProperties: {},
+ },
+ },
+ },
+ });
+
+ const result = decideTrackedChanges({
+ state,
+ editor: editorFor(),
+ decision: 'accept',
+ target: { kind: 'id', id: 't2' },
+ });
+ expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true);
+ const next = state.apply(result.tr);
+
+ expect(next.doc.child(1).type.name).toBe('table');
+ const cellPara = next.doc.child(1).child(0).child(0).child(0);
+ expect(cellPara.attrs.paragraphProperties?.change, 'contained pPr resolved on accept-by-id').toBeFalsy();
+ expect(cellPara.attrs.paragraphProperties?.numberingProperties, 'numbering kept').toBeTruthy();
+ });
+
it('a RANGE/cursor decide over a tracked table resolves the STRUCTURAL change (not the inline child)', () => {
// Range and collapsed-cursor targets must prefer the structural change for a
// shared id, same as the by-id path — otherwise a cursor inside the table
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js
index b3a55746c6..0867b68523 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js
@@ -21,6 +21,10 @@ const NODES = {
paragraph: {
content: 'inline*',
group: 'block',
+ // paragraphProperties carries a tracked w:pPrChange record on the node attr
+ // (numbering/alignment revisions) — mirrors the production paragraph node so
+ // the pPrChange enumerator can find one in tests.
+ attrs: { paragraphProperties: { default: null } },
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
},
@@ -132,12 +136,19 @@ export const stateFromTrackedSpans = ({ schema, spans }) => {
* trackChange: { type: 'rowInsert'|'rowDelete', id: string, [k: string]: unknown },
* rowCount?: number,
* cellText?: string,
+ * cellParagraphAttrs?: Record,
* }} input
* @returns {{ state: EditorState, schema: Schema, tablePos: number }}
*/
-export const stateWithTrackedTable = ({ schema, trackChange, rowCount = 1, cellText = 'Cell' }) => {
+export const stateWithTrackedTable = ({
+ schema,
+ trackChange,
+ rowCount = 1,
+ cellText = 'Cell',
+ cellParagraphAttrs = {},
+}) => {
const makeRow = () => {
- const cellParagraph = schema.nodes.paragraph.create({}, [schema.text(cellText)]);
+ const cellParagraph = schema.nodes.paragraph.create(cellParagraphAttrs, [schema.text(cellText)]);
const cell = schema.nodes.tableCell.create({}, [cellParagraph]);
return schema.nodes.tableRow.create({ trackChange }, [cell]);
};
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js
index 10738b5adb..e458208cee 100644
--- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js
@@ -332,14 +332,14 @@ export const TrackChanges = Extension.create({
},
acceptTrackedChangeById:
- (id) =>
+ (id, options = {}) =>
({ state, dispatch, editor }) => {
const reviewDecision = dispatchReviewDecision({
editor,
state,
dispatch,
decision: 'accept',
- target: { kind: 'id', id },
+ target: { kind: 'id', id, ...(options.side ? { side: options.side } : {}) },
});
return reviewDecision.applied;
},
@@ -358,14 +358,14 @@ export const TrackChanges = Extension.create({
},
rejectTrackedChangeById:
- (id) =>
+ (id, options = {}) =>
({ state, dispatch, editor }) => {
const reviewDecision = dispatchReviewDecision({
editor,
state,
dispatch,
decision: 'reject',
- target: { kind: 'id', id },
+ target: { kind: 'id', id, ...(options.side ? { side: options.side } : {}) },
});
return reviewDecision.applied;
},
diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/pprChanges.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/pprChanges.js
new file mode 100644
index 0000000000..b8ff18b69f
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/pprChanges.js
@@ -0,0 +1,83 @@
+// @ts-check
+/**
+ * Paragraph-property tracked-change enumerator (w:pPrChange).
+ *
+ * A tracked paragraph-property revision (numbering attach, alignment change,
+ * ...) is recorded on `node.attrs.paragraphProperties.change` — a pPrChange
+ * record, NOT a mark (see `updateNumberingProperties` in
+ * `core/commands/changeListLevel.js` and the tracked format-paragraph path).
+ * The inline-mark enumerators (`getTrackChanges`, `enumerateTrackedMarkSpans`)
+ * never see attr-based revisions, exactly like the structural row changes in
+ * `structuralRowChanges.js`.
+ *
+ * Without this walk the change round-trips to Word as `w:pPrChange` but is
+ * invisible to `trackChanges.list` and cannot be accepted/rejected via
+ * `trackChanges.decide` — even though the operation advertises tracked-mode
+ * support. This module is the single owner of pPrChange discovery; the review
+ * graph projects each entry into a decidable formatting change and the decision
+ * engine reads `change.pprChange` to accept (drop the record, keep the new
+ * properties) or reject (restore the former properties).
+ *
+ * The record shape is:
+ * { id, author, authorEmail, date, paragraphProperties: }
+ * where `paragraphProperties` is the pre-change state used to reject.
+ */
+
+/**
+ * @typedef {Object} PprChange
+ * @property {string} id Logical (and public) change id.
+ * @property {number} from Block node start (absolute PM position).
+ * @property {number} to Block node end (`from + node.nodeSize`).
+ * @property {string} author
+ * @property {string} authorEmail
+ * @property {string} authorImage
+ * @property {string} date
+ * @property {Record} formerProperties Pre-change `paragraphProperties` (used to reject).
+ * @property {'paragraph-format'} subtype
+ */
+
+/**
+ * Enumerate tracked paragraph-property (pPrChange) revisions in the document.
+ *
+ * Tolerates a missing/partial state and returns `[]` instead of throwing, to
+ * match the inline and structural enumerators' bootstrap-safety contract.
+ *
+ * @param {import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node } | null | undefined} state
+ * @returns {Array}
+ */
+export const enumeratePprChanges = (state) => {
+ const doc = state?.doc;
+ if (!doc) return [];
+
+ /** @type {Array} */
+ const out = [];
+
+ try {
+ doc.descendants((node, pos) => {
+ // pPrChange records live on block-level nodes; skip descending into text.
+ if (node.isText) return false;
+ const change = node?.attrs?.paragraphProperties?.change;
+ // A valid, decidable pPrChange carries a stable id and the former
+ // paragraph properties to restore on reject. Anything else (e.g. a
+ // transient/malformed record) is ignored rather than surfaced.
+ if (change && typeof change.id === 'string' && change.id && change.paragraphProperties) {
+ out.push({
+ id: change.id,
+ from: pos,
+ to: pos + node.nodeSize,
+ author: change.author || '',
+ authorEmail: change.authorEmail || '',
+ authorImage: change.authorImage || '',
+ date: change.date || '',
+ formerProperties: change.paragraphProperties || {},
+ subtype: 'paragraph-format',
+ });
+ }
+ return undefined;
+ });
+ } catch {
+ return out;
+ }
+
+ return out;
+};
diff --git a/packages/super-editor/src/editors/v1/extensions/types/track-changes-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/track-changes-commands.ts
index e42feb7603..fe1aec47c6 100644
--- a/packages/super-editor/src/editors/v1/extensions/types/track-changes-commands.ts
+++ b/packages/super-editor/src/editors/v1/extensions/types/track-changes-commands.ts
@@ -91,8 +91,10 @@ export interface TrackChangesCommands {
/**
* Accept tracked change by its ID
* @param id - The tracked change ID
+ * @param options - Optional `side` ('inserted' | 'deleted') to accept only one
+ * half of a paired replacement, leaving the other half pending.
*/
- acceptTrackedChangeById: (id: string) => boolean;
+ acceptTrackedChangeById: (id: string, options?: { side?: 'inserted' | 'deleted' }) => boolean;
/**
* Accept all tracked changes in the document
@@ -134,8 +136,10 @@ export interface TrackChangesCommands {
/**
* Reject tracked change by its ID
* @param id - The tracked change ID
+ * @param options - Optional `side` ('inserted' | 'deleted') to reject only one
+ * half of a paired replacement, leaving the other half pending.
*/
- rejectTrackedChangeById: (id: string) => boolean;
+ rejectTrackedChangeById: (id: string, options?: { side?: 'inserted' | 'deleted' }) => boolean;
/**
* Reject all tracked changes in the document
diff --git a/packages/super-editor/src/editors/v1/tests/data/docxbench-comment-on-insertion-001.docx b/packages/super-editor/src/editors/v1/tests/data/docxbench-comment-on-insertion-001.docx
new file mode 100644
index 0000000000000000000000000000000000000000..692483376f5dacb10a99898fbc1f4962aa4ab5dc
GIT binary patch
literal 2652
zcmaJ@2{crFAHHK>CL=qaEFo+5eH|nWUW`H{TN7g&YqAw#3}p%5C^UqO46S&1a3r6?_JD&;jM+7;^Uh(y*&1n3iO`Ze2qaD9c3*+ZuH89om=Z(3i
z9lek%Y8#c&heS>b;E^}tB;G@fms_GV(+LZ`e8hOncw_b(4l$3JOjPZ%_kLA)&zgo|
zdd0sxwMupwy0a{7%R7N?_-ogf^ableG^L=j_NVmzrc05J(g&2bkDJtDbubgvP3+IK
zsEjo%jy3i05ODb!|1w+&%gVeXc3r<$z++9>E{ON~D;RvBW29COz1e1RD?I|jqB(P%
z_p!Q~V&Xfs=0eocDeJn{X+)77GQ&D@?{(4$A=zT~?OL(3XVfT?7XNjKtH3V$wjatO
z0A4oa7XV@!@B|J7FMc3l01SMQ51g=QCXrcZ)59r|^3pg7w$h@X5e2gPjYMd^4P!N#
zJUKFv%;cwYAi^g|PWJmfxv!Dsx;zKn%w4=^uPiOSJ2<}Mb|qhAbg@q6>2Om=xhB#v
z{&sbB+(hW6+SK5S`4nL<58fdJEls&U+_7UuF;_K0NFhLacST0gUfy7o>m{!TZqp+7
zQL3njs^V??X>PD(Jvb>`w&J|-lYC1(3
zfrHs8b*~s>aX&iWy^RHLk>}bH=3oI?6R9_=iZ#$6SVPhnm)!5It?#7O43Jxrp#lJY
z@-q|_cv0@J{w|{2Lww0r`R=;;D-HD?fEH{>%PP?iKct&zg^6zR|o2$We5e
znqo7u^D+yqJ;A3W26^!bntK-i$<&D3YWy@Ni(qfTLw|0ttW)P%re~US`QuhL7uxa8
zLTMFCeB+@0Xn2d4@Alpp5oE~dw{*xAG)y#b$$O?3e4Z{W`nH;S
zkK04YqaPdCXnG%TEL0_)T&SDt`jpJkD@{0?$^{DpiK7U^SEZCk@Srm8$tMkNQiylo
z>_rm^T0EH(GYr^Kz9RiFmIoFmM0MCkX85VWQR(-QIPf$3bLDX}6_66S8ZGp-k0oyG
zF-&Tu*~ptTEPsNlH7^UX#Ca6P#4VqB!+eC~YWj2wexu~>Yw@NQChS!;l@Uf_L#DT#YvlSa9AECPvrI2cK?NJToduQirkm{s9@!~2{cynCbn{adW)bp#a(
zE>W2X&qwBOmzmQz#W(euE3THR?b&Wlk<Ps96{%TifxgTVv!_=NnQWb7YfqrjZ8i>V)}~JG6K!_GpVcOQ)s
zGRwG2xvmq~btsKEuSaxEkMQ5#}!L#ogJPL
zR#OU1Sk&+0$c3vOM3F^FCSU)|V-O-Z80qKnL!Kiy^;w<@u^R2@(B2`@I`J678kZSA
z0yLdinoE3WdlE_J+H}M6NVk0rgS{-riXi=Dmv7s*|1a?*zfc&WRYhF;oDHqT~xH-+=@L!ixN;u^(9>VqL{)QipCMB5CEQjDp`k&xmtYc+H
TORfn3^yIUdOfQDv``dp2T?RTD
literal 0
HcmV?d00001
diff --git a/packages/super-editor/src/editors/v1/tests/data/docxbench-comment-reply-001.docx b/packages/super-editor/src/editors/v1/tests/data/docxbench-comment-reply-001.docx
new file mode 100644
index 0000000000000000000000000000000000000000..4a4d987749a4131081d032f8916aa7ad754cca23
GIT binary patch
literal 3252
zcmaJ@2{e>@A0Ebxv6H0@ktO@gAf$+~W*eljbBQdC-Pjd{ELn<+Y{@oc$-a*iQ(XIc
zEj7rNEkr3TzE|C@%=x}=-t&LwymQX;oOyn~=Y4)ZeQhum8wdn~fs!Z(Ekt4#5JD6n
zkP{UM#0vasuHxqEZSU%BZRGE6?`0|B=Yp+C>e2!u*&=p=qZ3?~@*0`%J@A=@
z1@WF6f_m{SR@b_%KOj+64Bo2Z{66`TS4Y8;8jjKL&mZe3b{2!ECYHo(skiiHsn;3u
zEIHPoSxz)iBn@?DXSqmbPrJ9POGLiUG<4)(mc|IH*yZaAIKJtVG&W6VQlQ2ua8C&(
zo>tCd!Et#?GUwa~!N3)wUY->_pG?V}Uq9vF)S$kcL6Ax_F)ntzNgw->kNr#?w?+M3
z6&X#Oa~><&El9)qvntV)*{!iZh7oJ@u*k$d(G<#Pd{g!tDt%(~xZFZht;Kx`)t2Cm
z5d)eD;>>X&&67-@_IR2|Rj`mqsbIuvdfy|2u(yDMK4%*I(U_tPUG2rRw&wyyeYG#Q
z+&_orVAylRAhUUa+$qbo5
z9;Aop=T$LD69npSj-KNVS?_f-s^j_jWF%7!-&1SlruJ``$j8f|)sNDn8|b40B+4v!
zUt>n%BGBN8TM6vi0i8VCl~74B?!B4Ua?YU|XI!B!yPNaZ`MNF$>CkgaXo^g&NmeO&
zX*EWVbHEQvyM%LErvzllf(>
zoEKyBOm-uf3<+J(&oa8cull62#}GaoU0UL;De_=UEcm+sX#|Jg=qzHooh*T!34Nhx
z6?$b*R+I9^GXDa|nhpCzL@DP}qgPu|RZcyj0CjFlAx*fTH~NjMvW@(_rTbX{PnD4%2))R|Z*@{QHn+Xxu66un43eMfXjh*H
zDbS<2C3u}Mrt%k~ig@25oiNX_YKiKaApe$&P4`YGGGYSh^}E1!{*&FKD)s}-ADA@}
zDjg&GU2#GfE&CzmCtI9+REm=}%V&`~r6&;Msr^d5V*PPDU1m7HC!TPWqj`2GvgVDsZWLeWvzFB0!AIfZ
zORBbo8Kv{6$9t-gYPA{iwcn^$EX$v|>v5IeZoeHqlpHk=_dv=mTQGAT;R+kMb79X0
z^(Rsn-1Uc})F9F4%QLgDqKjSg(Km60^1X03jD~MT@NiUd*u9nFC&_aYaV;~VM{M?D
z6;}21&i7yVXl4c1s8`8j9cvkY#d|`77nV1J^Bu&tjwtXOLl-SWV)@!6;AI7yoKu%E
zD!NSul}hz7syK?wP*Iw-k2Fn{rcr`UbQ|K_bmN-&xKcOPev2yREJ3n)$4Ks(@77j&ogb$
zZdcyOp-S{llqNk>atrakSmt9YzqOe%%Q8at-msFPN
zM-SQoQ6({OiOtF0x^AttxA8iA8|V&ac7@$@ID5gZtrB+WBGkHnSIMK*w$+#KvE!*N@2v?ROi$(Hdl|ENXQ#T=r;nlnBz8A`V{76ulMiQ>r(Dt&&(
zCvf61O<57<)Hsy+H+|dH2#*~)|M4&hC`Gfq61IP!euHhJ
z-kRwQPwEwfbJR0hKl3mHnYI`4ggDYBjp{L;Pg)o(LU*v-`!@imK()1LC(ki5_?{mFvrushX%Kn)x7nYMb
zf~~WlwazY6&Y=EgJ6>S(MbJUX|I*&Wxni;x;A;?lqB~&z2q4*+eyQ{F_Q%@)5Eh$=
zMpGi0iR(9_3w(t=5fSCgw|N5$Yza{0TaC|-Obikjfvv}
zbE}}ZYGT+FLqrBe0mkW-KoG&~m7=@4RnGkeHO)^nk$nZSiJm&&lfV^-cxegNEPv~}
z;n~t)KBqtETHWhvdNogJ)v@N|9=ppxa>_|_w$hv=I=-{-ml&?v;47C{vGa;MM#2VO
z+J8F^HAX)SS3{R@YHQW#eX>{xn3ZPI`WQKEY`80|c2?I~S)t>?Os+w3chQrm7uwq}
zv%;K2Rn?;ot1c*;;*0FO4Ji2rugVS){wux_?ZqN8ErnrEF;4T%r%_JX0?u=qyA6?1
zqhTH!Ip%4Qrs3O7Z$_sj%*#cFq}B_faF$}VBSoPDeP+U~Chw3#w2G3gXG3>m%2PC6
zO4q#r?Xq+B8Sv+P7`&76R}k)$uB@aVVaDHRFMhG^^LgyE@TZ_Mmin@#BQJ=Kf%>nJ
z&B<~Lav=G7asZSDhVK7cR>+yihxGxI
zJMe=1VETP@lf%i!$pM@JLY5r+5l9Xv&&~t5HIxkgdy@kl)wx}n=id9h&-cC0`##U__k6$q@4pzM!1O#I5QrIs;2Fi_moC6q
z=s=*ZV;~R|xV6#3UH8FW_pvhzz+(xv(*9na^{FjJ-LgE<+iFpZ_4=@r5KTQKoF~0V`r8+n+nS4(~I1V}Tau@e72~&AK*~VD-W)4TP>{K+A<<=WXM?;h+%C#+xF$mU^@LdGeLM+u8hgf
z-jcg|DsAhhL;8Nx*8vCJzmr!uptH`E%7-jY9GIuB71_CJP4F{3+Z*G%Y#f$}Tk{Ms
z%YoktU}PP2r8^LG{-hcYiuHca%^ve!CeC{Gf_=*lz44LAsXw~RQUgRx28bRTu-kcK
zJqa?>-@b{%Eyp8dc^Kz%iLOJ^3`|b!QN5W}r-H+ZRPX5o>eCE`QUV9_vr6vamwvm>
z=(W4P=pj0wa>jsNKw4Mw%}4oKb%K6#+$5jOQE7^G-h$nLq#{5X4A}i}-p(?A>vP6A
z`Fa8Q@hvgFYki#ZqV%acyUunE
zV{Wl1E9*727>Z&fsB^g_>{X$Z+ID^nah<3pglFFu2VD8F-^#2CFL1jayRGk;1%Sz!h+CNN{CbMs;`KQ5gwr_
z?Osf%Ae;ummVN&+Dt=lP(!ONLJPTF+^vc#?wJ?K^HA=^IE1_Y7-0
z+C@JWwJcP*b+K=}c$XbR%8q$MJ|hsG|7@i1b7|_a2#uBQk-mp5G6O*pj-y@6Twot6
zyNTLn{EL!$*Cc49Sf+5#)|g(#=!_(9;%&WWyhD&b{nWF39CdFag(n}3s3&0TPnp}A
zCuY@g*4Qz3l?T-=mGjANOPz@~EljLM%yb(-!UZ(8&My_n7O3e!e(7}xN%#5vEK)Ve
zk@G43K;8a8)RLkiq70#rCN}kN3bUp+q;cS=W6Mt7y19D|+%{C*$!orjq~_9W?6}m#
zctz1F0$
zz1Tjh?D@atrBs)%>{gUNuijtJ^Zg8?*g|cfL!KQ$$AvFE*<{_$=X66_Uj
zxnCkp3Cn!lIiJZIEa8G5-`p8gBt%Qs@CP!_1?vptC(#Hu-B=U7VkG@!|=WRWD74vJ1SzO`6CiLd|nY
zF>}A@o-#>9dvRokh1+7Qh^E^karXt?-KN+(uOe?y@@=vhTE=d-ynQt%ZBr#lKDSnM
z$JbV>VZ0>#<*>Cxr`0DpGOL<=r$YE{d{xG!XYfW6XqOi@Y$BHbc{C=YKiF5$P*L8$
zZ9c&4AbF|r%X!bPs18sC*OQ75+@xODAmdrN)^yxab7TS$2O)qdiNO1x@*-e;eB7?P
ze7A=qLF4#SA&B<2D#L>|2HTTb`YkIgn;?2pI{md%(N=A3QlYPdLap1y%Q!9c431Y6
z&_(em{W=9}&0ATd)6XLQ^Bfep^GByVC)tg`W+lCNU)>SS6yy;zZv8Z=ci=gbsSB>6
zw^41S2wkyW3^V=*QG6v^&d)YjWMqTFWQ?La#smJ};}Q7j?@t(zUjKDi(h|^4iNE0>
zP#7H&p!jKK&;n_D@F9>F7}fu;Bhxa`Hnc+~ZlFNEGyUA&XyLTgbO;}#|2O=wwrIh$
eZXbegF#Z7lU
literal 0
HcmV?d00001
diff --git a/packages/super-editor/src/editors/v1/tests/data/docxbench-numbering-001.docx b/packages/super-editor/src/editors/v1/tests/data/docxbench-numbering-001.docx
new file mode 100644
index 0000000000000000000000000000000000000000..da3b527be4b54d152079d8fa18a2980689cc002f
GIT binary patch
literal 2672
zcmaJ@3pkVg8{W*2hUJhP%GnobSeir8HVTtDq?L1MG3V1P+r*O5oMMtn_$WeYiIz}`
zO-0T*#v&?I>nkEj|JC>Pulx`1d;Q+u^}g42KiB)*_wzjW&(RJfEDHbt!~lM-Js#tV
zn_Sid0RUSN03ajy>TVep8jTN)_I8O4$47a>j)nx+G9O}_4WM*>oOs)SZp3&36f+EJ
zTW69ZYS3}<`aA8;i#1`L7q_C&vh!V=uEu{h%C6*acU-5ZA?R*ir?=^-<_!+ADyBx>
zm)|-qWShLFenU~NJF+g%p<)UF6qgjeg163jn}hD94IJbO(@rCfTsv79dnp^CLI%%}
z5%o^mBFB+niH(sK>x#(3Kgq)Wq1AF_~mPd~6gB!}@>ccR0+C7$PW^Z&mU=*Q;3%1DlZ$B!5NxPl`EqvzBmXlunXI
z>SrMHdIPlczKza2k5uF?B=JVg+V<77Z3QVTlzsrS6MQXVKTd2i
zOFAN1U<7Nq6~tINdqkryxm5>#IK@A`#OA3kCq~o%79@*WHK%5(VY)Yw!QJ#28R)rR
zO83GxmJR^MV)2bi-fnGtq$nnKCX-8=X)D3WdxtVezq8?*)wc){i2O>hq<^pxN(}ME
zM-oE)zY*b?g$iv}h3;Ok0HbHBx!6lJ)oC|U`(PEY9uRbIu;vCm)fn<;Y%Dg?
z)W_7BQH~;9SEUAoZEGm9qD5b&XY}%$S3ad@L%}uoO&s%AE+a|yI0w1>H%_sA>uM4l
z?ejkxO3L}6K0accsz(8lTXZwk!sx3(%lIy--q8h=2c_+QN)7Qf+KG>Yof{PE$~`u1IYhwrZ9&dGI{E#PfdFl*NR;O^HN;_NdI8!>>6e%T_1oj#O`^VEW>
zv@S**9mFmp0)|BT}0bKw^E38vN0PFU^HxEYM<6H;!5d&8LMp
z5=L;#11}H>otyiw-tpMYAn=
zZ8lx(p#(3}Oo4-!D;!<;19d*p$1?U*>BMpW
z-cc!H|5hf9vi*UV0@n}1hF}pnX&<0XDhUIb!^scAgQB-+oc7InJrQ6YK}9=ub`|ZH
zclshDYg$WNtgSeT3Q@w;Wo}$z?Akgw95iQJc388+W0K<``{B}{m{SHXY$b~lqXA;}
z`GRNKzRs)i_)n%3d8NH8aHaKT1PD`GmDzFjx%KY5SgdsFGYVq}G(y<&SnfW+ekZZ1
zwo~6_^En>A;2IYlqZwcCZl50g#$no4sXOZ!kvu^h;TYfIY`7lZED|Hn-e?2v%NwDd
z*wJznI1;BkK3Rog4x8cg#?k`a*nF=t8hrS^TPwYI*)4^@Dy8;F<6myY^u*fD4tf8s
z@-h{|3>TJ4`Kh(c=aAxsep+V@lrv
z=Ovxp+wHwk<_~s`mpE0nmtQ-{wVM-jyIjn)vXb|I9fJ0$+$E=0hc>H=s(zrWxt~OD
zsnkW>xqRX-jxg1rjwTdo1y0&5)uo*5KN0b+*!>*1zAv$UqW>Apou%8mjbD0_=&5(}
zNja^%(@m%8;9J99aSM3UcG^-Z>x^})5t|EGl2h(<(k!0uNj~$4N>s5oh97Mlk9GN)
z`I7x9l3)a6|FbW?XEf`+k<&j83
zN#6f9z0AR98^N9%EHU-7YvusL++5)!iu8kxje7B8lz6v0T9r~b+r5%iCBP(E6YXK;
zhJx8CAm&`zt8Fr7i+?u3rFscq_u1UbD@b1lQA$wq6Sw;roVEv|&i-Ll9Ja-5sYBIY
zmz5namTAqNHzavd)w@2t6Lqu$3dw^0_u)kF)8DTIL3#bxqsr<4t8MvjH~^3Uv=)T;
z#iUmQSDW5Xpt`^Y|DOe}j;IoDYn=j&temwQKi}eh=u~
zlu=Es$mo_U-lyrw(l;&d2J{$B+Vz7AiCk1KfMs2sU=QuppqkVXiCrG8+DwHQ0;75kk9osY1~?oyp=RE!;lnycBlvf)38AlBf
z=J+;0Bkdff$>&Dxda$*0|JoaQemz*)6rkF*4PK~&iTs=ZcNtglv
zU?=Uq?p}9A|H<#JyZr-Kl2pEHa)S
zx_0cIWghH49ie7-La|urz!3GMehE)-a4vUMseP4$bOxHCypE32)*v
zPwsA2<1xORghdVxY)>+G9xRb(OIx7sn#?PQm__gB^u4UTc5Eil-8PjmWW2%dhpf$jYpp)EK`*;@~!^%ol%fVejtxodoaiET|{H
zbbcj9i{A7z@@luPMbxCG=azwmkH!b5PvHs;XkDyVXdUjMN09b-tKH
zLOO75J(Iu5$g-&vHk>WsZ(9NWSSDMJ5Nrzda*Fgb#5;F7In-Jt1f~n5WZ0B9bX>9p
zP3LKvy+B}_M^z>Q+MQi@52i3c)iZ9(O%JE0tsA!aphaQ|k(oaZ
z5=LAN1M$lVBEm%AgwiHy7wy1hYvfnUi4cF7JT&BIwR9GYGAxkTF`zod&}p4KH+kts
z5Tk8f5u%!5kh`73q_KV{+O;DT_%g@(p|S8Z^SyYq+C+cbuj!v^Rzq#CQeVbti6^}7atk6xvE6hq5ZH30VC8G|1{ZiAC(V2!rK5!~X<|A%bSgm8M>|wjM+G62<
z?wj;vI`yP=nf>y)uhIkX#u3(Z`(kv3_6?r^5A(3S_xYQt!us{vm5(x(;4~Tvg^K+I
z?v{mtjFv8qMotqvlp4(Op0a0qh+?On?J2SRlWakOgnaTO@8J_j*#>KIr=r0T#)We4
z8GEdNR8w+^-t5?;28)Q|uOB*;kim2ZR@5-*Hs^-oz=YQkZylw|`qE2NkUmj|xJ!YK
zLs7Y=T~yUjg2?C}UQanjph<)D7I{}713->Sz3VjU*>U5CtkZ*di42(?y@WOuW%$2@(J&tC$bK#HT*9qM5bp{~RbQ|AoT3~<8DVQ?lkV2r?U~*6e*ZlG
z0W1QByn7_X&(coTGlxO6X&xj+ph^Bh|D$KTef(VjdpDNVriz}njuo7+ukQ^SkvfW#Vnew~wNYK`%bA!#j8vMJ-cdT`?_x<%5r#Z}n@3UkP|9m4+jAWr^|b?9
zoI3Rk&mX+-kOUQ0%`V=7vd^Bo2HRexv$wgOjA>1aa?zW+m$LY
zaW}$c8@+Kog4b{PqPt23CEJQ4+f7kREj(~kKCknl);r4zy77{*;X$N8hxvy1=vhTb2Q2I$zA8)eHMG7PaKOYqXn4Kw&ynb?
zzMu!3`ce?T)_H%EZ%Hfl`*JQXquK!#7aJ;8FLmRc0u5%wTQc2$ZdcI&$#21=q9l->
z{|q#52Ol4lo8xhL=pLz@@8r6mzNY|s+zPV3tg6$zcIGpHsymbF9vj-cwN*G|HZTO)
z##>5ns;zgvq6iqpD1Bp^y(MpL38<5U{g(uT?3aQL2>mDuWVx1Ee58B_0S>$$J&x>5
z#J}kQ8#%gH;OiCFFo=q+Vs?XPSl+cK;=a~FJVQG~uz@a+f|2rnA5};{eSC$I<<
zF605oZTcTL01ygPCxtj=+T=iT6FdQOk!@Tl7q>`asvJe{sBHE9RpnuDJB2_Cf#i$dP``&zx@rs
C^J5MG
literal 0
HcmV?d00001
diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js
index 8cf9a99f63..da9644c694 100644
--- a/packages/superdoc/src/stores/comments-store.js
+++ b/packages/superdoc/src/stores/comments-store.js
@@ -2190,6 +2190,16 @@ export const useCommentsStore = defineStore('comments', () => {
// original DOCX-schema JSON captured at import time. Otherwise, fall back
// to rebuilding commentJSON from the rich-text HTML.
const docxSchema = normalizeDocxSchemaForExport(values.docxCommentJSON);
+ // Synthetic tracked-change projection rows (one per revision, so the
+ // sidebar can render revisions beside comments) must never export as a
+ // . Decide BEFORE synthesizing commentJSON: convertHtmlToSchema
+ // of an empty body still yields one empty paragraph, which reads as a
+ // truthy body downstream and would leak the row past the converter's
+ // body-presence filter. A genuine comment carries commentText and/or the
+ // DOCX-schema JSON captured at import — imported comments that round-trip
+ // the custom:trackedChange flag keep their body, so they still export.
+ const hasGenuineBody = (typeof richText === 'string' && richText.length > 0) || docxSchema.length > 0;
+ if (values.trackedChange && !hasGenuineBody) return;
const schema = docxSchema.length ? docxSchema : convertHtmlToSchema(richText);
processedComments.push({
...values,
diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js
index 0985405e7a..a90e981df1 100644
--- a/packages/superdoc/src/stores/comments-store.test.js
+++ b/packages/superdoc/src/stores/comments-store.test.js
@@ -4328,4 +4328,44 @@ describe('comments-store', () => {
expect(floating.map((c) => c.commentId).sort()).toEqual(['inline-outside', 'user-comment']);
});
});
+
+ describe('translateCommentsForExport', () => {
+ // Synthetic tracked-change projection rows (one per revision, for the
+ // sidebar) must not reach the DOCX exporter: convertHtmlToSchema('') still
+ // yields one empty paragraph, so a synthesized commentJSON reads as a
+ // truthy body downstream and the row would export as an empty .
+ it('skips synthetic tracked-change rows but keeps real comments, including imported comments-on-revisions', () => {
+ const asModel = (values) => ({ ...values, getValues: () => values });
+ store.commentsList = [
+ // Synthetic sidebar row: identity IS the tracked change, no body.
+ asModel({
+ commentId: 'change-1',
+ trackedChange: true,
+ trackedChangeText: 'inserted text excerpt',
+ commentText: '',
+ }),
+ // Genuine user comment.
+ asModel({
+ commentId: 'real-1',
+ trackedChange: false,
+ commentText: 'Looks good
',
+ }),
+ // Imported comment ANCHORED ON a revision: round-trips the
+ // custom:trackedChange flag but carries a genuine DOCX body — must export.
+ asModel({
+ commentId: 'imported-on-revision',
+ trackedChange: true,
+ commentText: '',
+ docxCommentJSON: [{ type: 'paragraph', content: [{ type: 'text', text: 'imported body' }] }],
+ }),
+ ];
+
+ const exported = store.translateCommentsForExport();
+
+ expect(exported.map((c) => c.commentId).sort()).toEqual(['imported-on-revision', 'real-1']);
+ // Every exported row carries a commentJSON body; the synthetic row is gone
+ // rather than exported with a synthesized empty-paragraph body.
+ exported.forEach((c) => expect(Array.isArray(c.commentJSON) && c.commentJSON.length > 0).toBe(true));
+ });
+ });
});
From 23e00b43f300f3a6ff529fa226b6c33134ef6723 Mon Sep 17 00:00:00 2001
From: Andrii Orlov <120495135+andrii-harbour@users.noreply.github.com>
Date: Thu, 2 Jul 2026 21:30:52 +0200
Subject: [PATCH 2/6] =?UTF-8?q?perf(super-editor):=20large-document=20perf?=
=?UTF-8?q?ormance=20=E2=80=94=20headless=20linked-styles=20+=20visible=20?=
=?UTF-8?q?read=20model=20(#263)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(document-api): tracked-change actions, comment export, list/numbering read model
Engine + Document API work backing the LLM-tools core preset:
- tracked-change side-targeted reject (decide `side` selector), tracked
w:pPrChange apply, tracked lists.attach
- comment + comment-reply export; comments on tracked changes persist on export
- blocks.list read model: paragraph indent projection and computed numbering
(marker/path/kind) so agents can see legal clause numbers
- list-item / list-sequence resolver hardening; table cell shading background
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(document-api): tighten trackChanges.decide id-target side selector
Addresses review findings on the side-targeted reject surface:
- id-target `side` runtime accepted `insert`/`delete` aliases the published
schema forbids (strict `inserted`/`deleted`). Drop the aliases so runtime
matches the contract. Range targets are unchanged.
- narrow the id-target `side` type to a new `ReplacementSide`
(`'inserted'|'deleted'`); it no longer advertises move-only `source`/
`destination` that always throw for id targets.
- decision-engine: a stale `side` selector on a change whose targeted half was
already resolved (only the other side survives as a standalone insertion/
deletion) fell through and silently resolved the surviving side. Fail closed
unless the standalone side matches the requested side. Only id targets set
selection.side, so range/all decisions are unaffected.
Adds 5 regression tests (3 decide-validation, 2 decision-engine).
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): drop unused destructure var in tracked-numbering path
`_existingChange` was flagged by @typescript-eslint/no-unused-vars as an ERROR
(the file's TS lint config does not honor the /^_/ ignore pattern for
rest-destructure siblings), failing CI lint. Snapshot the former paragraph
properties with an explicit delete instead.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(super-editor): surface tracked paragraph-property changes in the review API
Tracked numbering-attach / alignment changes (w:pPrChange) were export-only:
they round-tripped to Word but never appeared in trackChanges.list and could
not be accepted/rejected via trackChanges.decide, because they live on node
attrs (paragraphProperties.change), not marks, and both enumeration sites (the
review graph and the doc-api resolver) scan marks + structural rows only. To a
customer that reads as "tracked numbering silently failed".
Mirror the existing tableRow structural precedent for attr-based changes:
- pprChanges.ts enumerator walks blocks for a valid paragraphProperties.change
- review-graph projects each into a Formatting logical change (synthetic
whole-block segment + non-enumerable change.pprChange payload)
- decision-engine routes change.pprChange to planPprDecision before the
type-based branches (it is typed Formatting but has no mark): accept drops the
change record (numbering stays), reject restores the former properties;
applied via setNodeMarkup like clearRowTrackChange
- the doc-api resolver (groupTrackedChanges) appends them as formatting changes
so they surface in trackChanges.list; the node-stored record id doubles as the
public + command id, routing decide to the same review-graph change
New integration test proves list + accept + reject end-to-end; 1476 tracked-
change tests pass. Also drops an unused `retired` param (lint).
Co-Authored-By: Claude Opus 4.8 (1M context)
* style(super-editor): prettier-format the 3551 integration tests
These integration tests were committed without prettier formatting, failing the
CI format:check gate. No logic change.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): address pPrChange review — type, reject sync, schema
- review-graph: declare the optional `pprChange` flag on TrackedSegment so the
synthetic segment literal type-checks (fixes CI TS2353; mirrors `structural`)
- decision-engine: rejecting a tracked pPrChange now also syncs the TOP-LEVEL
numberingProperties + listRendering to the restored former state, not just
paragraphProperties — otherwise a rejected block still reads/renders as a
numbered list item in any path that doesn't re-run the numbering plugin
- contract: publish the `numbering` and `indent` fields on the blocks.list
output schema (they were returned + typed on BlockListEntry but omitted from
the closed JSON schema, so schema-driven clients dropped them); regenerated
reference docs + manifest
check:types 0 errors, lint 0 errors, 1234 tracked-change tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(painter-dom): align thick-border test with SD-3028 authored-width rule
border-utils.test.ts expected thick borders at max(width*2, 3), but SD-3028
deliberately paints `thick` at the authored w:sz width (no 2x, min 1px) — see
getBorderBandWidthPx + border-band.test.ts ("thick paints at the authored
width"). The painter test and the applyBorder comment were stale leftovers from
before that decision and failed on origin/main too; they only surfaced here
because doc-api/sdk changes trigger CI's `--project=!*super-editor*` vitest job.
Helper is unchanged. 91 border tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(document-api): clear cell `background` on cell-scoped clearShading
Shading a cell writes the `background` attr (the render/export source of truth),
but tablesClearShadingAdapter only deleted `background` on the table-scoped
path. A cell-scoped clear (incl. tables.setShading({ color: null })) removed
tableCellProperties.shading but left `background`, so the cell stayed shaded on
screen and on export while the receipt reported success. Delete `background` on
the cell clear path too (mirrors the set path + the table-scoped per-cell clear).
Adds a regression test.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): export tracked pPrChange with a Word-safe decimal w:id
A tracked pPrChange (tracked numbering/alignment) exported its w:id as the
internal change.id, which for API-created changes is a uuidv4 — but OOXML w:id
must be a decimal integer, so Word repairs/drops it and re-import can't match.
Imported pPrChanges already carry a decimal id (kept as-is); API-created UUIDs
are converted to a stable decimal in a high, allocator-clear range. Adds
decode-path tests (imported-decimal preserved; UUID → deterministic decimal in
range) + a decimal-id assertion on the numbering export test.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): make tracked pPr changes non-positional in the review graph
The synthetic pPr segment spans the whole block, so treating it positionally
caused three collateral bugs when a paragraph also had other content: a
text-range decide captured it (partial Formatting → CAPABILITY_UNAVAILABLE),
accepting/rejecting it detached unrelated comments in the block, and a pPr
change inside a tracked table was routed through the mark-based contained-child
planner (null-mark deref). Fix, one idea — pPr changes are resolved only by
id/all via planPprDecision, never positionally:
- keep the pPr segment off graph.segments / bySegmentId (review-graph)
- skip the resolvedRanges push for pPr decisions (no comment detach)
- skip pPr changes in the staying-table cascade
1435 tracked-change tests pass (list + accept + reject flow intact).
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): address SD-3551 review — tracked-change edge cases + test rigor
Review round 2 (PR #262, @caio-pizzol). Each item reproduced, fixed where
broken, and covered with a test:
1. One-sided replacement survivor: buildLogicalChange now downgrades a change
labeled "replacement" with only one side present to the plain insertion/
deletion it is — so the survivor of a side-targeted accept/reject resolves
normally instead of failing "replacement missing inserted or deleted side".
2. pPrChange inside a KEPT tracked table: the main staying-table cascade now
excludes pPr changes (matching the side-effect sweep), so they resolve via
planPprDecision by id instead of no-opping through the mark-based child
planner (pPr is attr-based, not an inline mark).
3. Tracked lists.attach with no user: guarded with ensureTrackedCapability so a
tracked pPrChange can no longer be stamped with a blank author (mirrors the
ins/del and lists.insert tracked paths).
4. Comment export: assertions tightened to exact count + zero empty comments so
a sidebar-only tracked-change row leaking as an empty comment is caught (the
hasCommentBody filter itself was already correct — verified).
5. pPrChange w:id: routed through the shared Word revision-id allocator
(threaded into the pPr export path) for doc-wide uniqueness; the FNV-1a hash
is now only a no-allocator fallback.
194 review-model tests + touched integration tests + check:types + lint green.
Co-Authored-By: Claude Opus 4.8
* fix(super-editor): flatten pPrChange on final export + cascade it when a table is accepted
Follow-ups from the automated review of the previous commit, plus test fixes:
- Final-doc export now flattens a tracked paragraph-property revision: when
isFinalDoc, drop the .change record so the "final" DOCX carries only the
accepted numbering/alignment (no pending w:pPrChange) — matching how the
ins/del translators strip their wrappers on isFinalDoc.
- Accepting a tracked table BY ID now resolves contained pPr changes too: the
staying-table side-effect sweep routes them through planPprDecision instead
of skipping them, so a reviewed table has no leftover numbering revisions.
(accept-all already handled this via the main loop.)
Also:
- Fix two unit tests broken by the earlier w:id-allocator threading: the
generate-paragraph-properties decode assertion uses objectContaining (the
call also carries the allocator + part path), and the lists.attach conformance
throwCase drives its mode-independent TARGET_NOT_FOUND in direct mode (the
tracked path's user guard would otherwise fire first on a no-user editor).
- Drop ticket/review-process tags from code comments and test names.
Full review-model + document-api-adapters suites, generate-paragraph-properties,
and the pPrChange translator all green; check:types + lint clean.
Co-Authored-By: Claude Opus 4.8
* fix(super-editor): drop synthetic tracked-change rows from comment export
comments.list() returns one projection row per tracked change so the
sidebar can render revisions beside real comments. Feeding those rows
into exportDocx({ comments }) turned every tracked change into a spurious
: the row carries the change excerpt as `text`, so the
body-presence filter let it through.
Identify synthetic rows by identity — commentId/id equals
trackedChangeLink.trackedChangeId — and exclude them. A genuine comment
anchored on a tracked change keeps its own distinct id, so it still
exports.
Add a regression test for the comments.list() -> exportDocx({ comments })
path, drop leftover console.log calls from the comment integration tests,
and assert the threaded reply bumps the document revision.
Co-Authored-By: Claude Opus 4.8
* perf(super-editor): large-document performance — headless linked-styles + visible read model
- skip whole-doc linked-style inline-CSS decorations when the editor runs
headless (view-only work); ~50s -> ~16s on a 38-page redline
- blocks.list returns the VISIBLE text model (skips tracked-deleted runs) so a
second edit to an already-edited block resolves offsets against the same
text the plan engine applies against — no more "Offset N out of range" drift
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): blocks.list length/ref/preview use the visible text model
The visible read-model fix switched blocks.list `text` to visible but left
`textLength`, `isEmpty`, and the encoded block `ref` (segments[].end) on the
raw model via computeTextContentLength(node). For a redlined block that handed
out a whole-block ref ending past the visible text, so re-editing it through the
ref threw "text offset out of range" (the plan compiler resolves refs as
visible) — the very drift the visible read model set out to remove, only
half-applied. Compute length + preview on the visible model too. Adds two
regression tests (partial-delete ref end == visible length; fully-deleted block
reports isEmpty + no ref).
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): sample blocks.list formatting from visible runs only
blocks.list moved text/textPreview/length/ref to the visible text model, but
extractBlockFormatting still sampled the first RAW run, so a block whose
leading run is a styled tracked deletion reported visible text with the
DELETED run's fontFamily/fontSize/bold. The agent prompt tells callers to
copy those values, so rejected formatting could be replicated into new
content. Skip trackDelete runs when sampling; a fully deleted block emits no
run-formatting fields. Also document the visible model in the contract
(regenerated docs) and add headless linked-styles coverage for the SD-3552
decoration skip.
Claude-Session: https://claude.ai/code/session_01EP45Zp5VcSNQgzG8xoVsTm
---------
Co-authored-by: Claude Opus 4.8 (1M context)
Co-authored-by: Caio Pizzol
Ported-From-Source-Repo: superdoc/orbit
Ported-From-Source-Commit: ac0afb7a0a37c1f11b8771c1a505bd15829f4bb0
Ported-Public-Prefix: superdoc/public
---
.../reference/_generated-manifest.json | 2 +-
.../document-api/reference/blocks/list.mdx | 4 +-
apps/docs/document-api/reference/index.mdx | 2 +-
.../src/contract/operation-definitions.ts | 2 +-
.../document-api/src/types/blocks.types.ts | 13 ++-
.../plan-engine/blocks-wrappers.test.ts | 99 +++++++++++++++++++
.../plan-engine/blocks-wrappers.ts | 38 ++++++-
.../linked-styles/plugin.headless.test.js | 66 +++++++++++++
.../v1/extensions/linked-styles/plugin.js | 14 ++-
9 files changed, 226 insertions(+), 14 deletions(-)
create mode 100644 packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.headless.test.js
diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index 8954d6acd0..dad0ab1ab7 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -1090,5 +1090,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "06cbdc50cf2e7489d18e54cfee0d40297e08522eb553485e10dbdafba568b81b"
+ "sourceHash": "2954ad7f0581c8cbaa8903cf227429d4bc1c0ca550a663f3b5bd28449323717f"
}
diff --git a/apps/docs/document-api/reference/blocks/list.mdx b/apps/docs/document-api/reference/blocks/list.mdx
index 501986ae10..7b9b3c6b9b 100644
--- a/apps/docs/document-api/reference/blocks/list.mdx
+++ b/apps/docs/document-api/reference/blocks/list.mdx
@@ -1,14 +1,14 @@
---
title: blocks.list
sidebarTitle: blocks.list
-description: "List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `."
+description: "List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Text, previews, lengths, and formatting hints use the VISIBLE model: pending tracked deletions are excluded (matching how edit refs resolve), and inline atoms render as a single placeholder character. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `."
---
{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
## Summary
-List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `.
+List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Text, previews, lengths, and formatting hints use the VISIBLE model: pending tracked deletions are excluded (matching how edit refs resolve), and inline atoms render as a single placeholder character. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `.
- Operation ID: `blocks.list`
- API member path: `editor.doc.blocks.list(...)`
diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx
index 3d1cc11d89..9492e6f239 100644
--- a/apps/docs/document-api/reference/index.mdx
+++ b/apps/docs/document-api/reference/index.mdx
@@ -84,7 +84,7 @@ The tables below are grouped by namespace.
| Operation | API member path | Description |
| --- | --- | --- |
-| blocks.list | editor.doc.blocks.list(...) | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `. |
+| blocks.list | editor.doc.blocks.list(...) | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Text, previews, lengths, and formatting hints use the VISIBLE model: pending tracked deletions are excluded (matching how edit refs resolve), and inline atoms render as a single placeholder character. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `. |
| blocks.delete | editor.doc.blocks.delete(...) | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. |
| blocks.deleteRange | editor.doc.blocks.deleteRange(...) | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. |
diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts
index a3b29d5ca5..9e5ff3ef2a 100644
--- a/packages/document-api/src/contract/operation-definitions.ts
+++ b/packages/document-api/src/contract/operation-definitions.ts
@@ -963,7 +963,7 @@ export const OPERATION_DEFINITIONS = {
'blocks.list': {
memberPath: 'blocks.list',
description:
- 'List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `.',
+ 'List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Text, previews, lengths, and formatting hints use the VISIBLE model: pending tracked deletions are excluded (matching how edit refs resolve), and inline atoms render as a single placeholder character. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `.',
expectedResult:
'Returns a BlocksListResult with total block count, an ordered array of block entries (ordinal, nodeId, nodeType, textPreview, optional text, isEmpty), and the current document revision.',
requiresDocumentContext: true,
diff --git a/packages/document-api/src/types/blocks.types.ts b/packages/document-api/src/types/blocks.types.ts
index a35e3a3cef..eefa4bb3cc 100644
--- a/packages/document-api/src/types/blocks.types.ts
+++ b/packages/document-api/src/types/blocks.types.ts
@@ -10,14 +10,21 @@ export interface BlockListEntry {
nodeId: string;
nodeType: BlockNodeType;
textPreview: string | null;
- /** Full flattened block text when requested via BlocksListInput.includeText. */
+ /**
+ * Visible flattened block text when requested via BlocksListInput.includeText.
+ * Uses the same text model the plan engine resolves offsets against: pending
+ * tracked deletions are excluded, and inline leaf atoms (images, tabs)
+ * contribute one character (their leaf text or U+FFFC), so `text.length`
+ * always matches the encoded `ref` range. A block whose content is entirely
+ * tracked-deleted reads as "" (and reports isEmpty: true with no ref).
+ */
text?: string | null;
isEmpty: boolean;
/** Named paragraph style ID (e.g. 'Normal', 'Heading1'). */
styleId?: string | null;
- /** Font family from the block's first text run. */
+ /** Font family from the block's first visible text run (tracked-deleted runs are skipped). */
fontFamily?: string;
- /** Font size from the block's first text run. */
+ /** Font size from the block's first visible text run (tracked-deleted runs are skipped). */
fontSize?: number;
/** True if the block's text is bold. */
bold?: boolean;
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts
index 0416a9ec6b..b20c82d8c7 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts
@@ -5,6 +5,8 @@ import type { BlocksDeleteInput, MutationOptions } from '@superdoc/document-api'
import { blocksDeleteWrapper, blocksDeleteRangeWrapper, blocksListWrapper } from './blocks-wrappers.js';
import { registerBuiltInExecutors } from './register-executors.js';
import { DocumentApiAdapterError } from '../errors.js';
+import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js';
+import { decodeRef } from '../story-runtime/story-ref-codec.js';
// Ensure the domain.command executor is registered for executeDomainCommand
registerBuiltInExecutors();
@@ -606,6 +608,103 @@ describe('blocksListWrapper', () => {
expect(result.blocks[0]!.textPreview).toBe('Longer full text value');
});
+ // Regression: blocks.list must report length/ref/isEmpty on the VISIBLE text
+ // model, matching `text`. A raw length would encode a whole-block ref ending
+ // past the visible text, and re-editing that already-redlined block via the
+ // ref throws "text offset out of range". See SD-3552.
+ it('encodes textPreview/ref on the visible model for a redlined block', () => {
+ const paragraph = createNode(
+ 'paragraph',
+ [
+ // tracked-deleted "abc" (raw length 3) + visible "X" (visible length 1)
+ createNode('text', [], { text: 'abc', marks: [{ type: { name: TrackDeleteMarkName }, attrs: {} }] }),
+ createNode('text', [], { text: 'X' }),
+ ],
+ { attrs: { paraId: 'p1', sdBlockId: 'p1' }, isBlock: true, inlineContent: true },
+ );
+ const doc = createNode('doc', [paragraph], { isBlock: false });
+ const editor = { state: { doc } } as unknown as Editor;
+
+ const entry = blocksListWrapper(editor, { includeText: true }).blocks[0]!;
+ expect(entry.text).toBe('X');
+ expect(entry.textPreview).toBe('X');
+ const decoded = decodeRef(entry.ref!) as { segments?: Array<{ end: number }> } | null;
+ // Visible length (1), NOT raw length (4). Raw would fail compilation later.
+ expect(decoded?.segments?.[0]?.end).toBe(1);
+ });
+
+ it('reports a fully tracked-deleted block as empty with no ref (visible model)', () => {
+ const paragraph = createNode(
+ 'paragraph',
+ [createNode('text', [], { text: 'gone', marks: [{ type: { name: TrackDeleteMarkName }, attrs: {} }] })],
+ { attrs: { paraId: 'p1', sdBlockId: 'p1' }, isBlock: true, inlineContent: true },
+ );
+ const doc = createNode('doc', [paragraph], { isBlock: false });
+ const editor = { state: { doc } } as unknown as Editor;
+
+ const entry = blocksListWrapper(editor, { includeText: true }).blocks[0]!;
+ expect(entry.text).toBe('');
+ expect(entry.isEmpty).toBe(true); // raw length (4) would report false
+ expect(entry.ref).toBeUndefined();
+ });
+
+ // Regression: formatting hints must come from the first VISIBLE run, matching
+ // the visible text model. The agent prompt tells callers to copy
+ // fontFamily/fontSize/bold from blocks.list, so sampling a tracked-deleted
+ // run would replicate rejected formatting into new content.
+ it('samples formatting from the first visible run, not a leading tracked-deleted run', () => {
+ const paragraph = createNode(
+ 'paragraph',
+ [
+ createNode('text', [], {
+ text: 'gone',
+ marks: [
+ { type: { name: TrackDeleteMarkName }, attrs: {} },
+ { type: { name: 'textStyle' }, attrs: { fontFamily: 'DeletedFont', fontSize: 33 } },
+ { type: { name: 'bold' }, attrs: { value: true } },
+ ],
+ }),
+ createNode('text', [], {
+ text: 'X',
+ marks: [{ type: { name: 'textStyle' }, attrs: { fontFamily: 'VisibleFont', fontSize: 11 } }],
+ }),
+ ],
+ { attrs: { paraId: 'p1', sdBlockId: 'p1' }, isBlock: true, inlineContent: true },
+ );
+ const doc = createNode('doc', [paragraph], { isBlock: false });
+ const editor = { state: { doc } } as unknown as Editor;
+
+ const entry = blocksListWrapper(editor, { includeText: true }).blocks[0]!;
+ expect(entry.text).toBe('X');
+ expect(entry.fontFamily).toBe('VisibleFont');
+ expect(entry.fontSize).toBe(11);
+ expect(entry.bold).toBeUndefined();
+ });
+
+ it('emits no run-formatting fields for a fully tracked-deleted block', () => {
+ const paragraph = createNode(
+ 'paragraph',
+ [
+ createNode('text', [], {
+ text: 'gone',
+ marks: [
+ { type: { name: TrackDeleteMarkName }, attrs: {} },
+ { type: { name: 'textStyle' }, attrs: { fontFamily: 'DeletedFont' } },
+ { type: { name: 'bold' }, attrs: { value: true } },
+ ],
+ }),
+ ],
+ { attrs: { paraId: 'p1', sdBlockId: 'p1' }, isBlock: true, inlineContent: true },
+ );
+ const doc = createNode('doc', [paragraph], { isBlock: false });
+ const editor = { state: { doc } } as unknown as Editor;
+
+ const entry = blocksListWrapper(editor, { includeText: true }).blocks[0]!;
+ // Nothing visible to copy from: no stale formatting may leak out.
+ expect(entry.fontFamily).toBeUndefined();
+ expect(entry.bold).toBeUndefined();
+ });
+
it('omits full block text when includeText is false or omitted', () => {
const paragraph = createNode('paragraph', [createNode('text', [], { text: 'Body text' })], {
attrs: { paraId: 'p1', sdBlockId: 'p1' },
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts
index 94e38f8390..83dae76eb3 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts
@@ -23,6 +23,8 @@ import {
type ParagraphNumbering,
} from '@superdoc/document-api';
import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js';
+import { textContentInBlock } from '../helpers/text-offset-resolver.js';
+import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js';
import {
findBlockByIdStrict,
mapBlockNodeType,
@@ -61,14 +63,27 @@ const RANGE_DELETE_SAFE_NODE_TYPES = new Set(['passthroughBlock', 'passthroughIn
function extractTextPreview(node: ProseMirrorNode): string | null {
if (!node.isTextblock) return null;
- const text = node.textContent;
+ // VISIBLE text, so a preview of a mid-redline block does not include
+ // tracked-deleted runs (consistent with extractBlockText and textLength).
+ const text = textContentInBlock(node, { textModel: 'visible' });
if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text;
return text.slice(0, TEXT_PREVIEW_MAX_LENGTH);
}
function extractBlockText(node: ProseMirrorNode): string | null {
if (!node.isTextblock) return null;
- return node.textContent;
+ // Use the VISIBLE text model: tracked deletions remain in the doc as marked
+ // text nodes but are not part of the document's current reading. node.textContent
+ // would include them, so a block mid-redline reads e.g. "in relationZZ" (deleted
+ // "in relation" + inserted "ZZ"). That raw text is the model the agent and the
+ // scoped replace_text action see — but the plan-engine resolves text offsets in
+ // the 'visible' model (resolveTextRangeInBlock / getBlockText, compiler.ts), so a
+ // SECOND edit to an already-edited block computed offsets past the visible length
+ // and threw "Offset N out of range". Returning visible text here aligns the read
+ // model with the apply model. Note this is the OFFSET model's rendering, not
+ // node.textContent: inline leaf atoms (images, tabs) contribute one character
+ // (their leafText or U+FFFC), so text.length always equals the encoded ref end.
+ return textContentInBlock(node, { textModel: 'visible' });
}
const HEADING_PATTERN = /^Heading(\d)$/;
@@ -156,7 +171,10 @@ function extractBlockNumbering(node: ProseMirrorNode): ParagraphNumbering | unde
}
/**
- * Extract key formatting from a block node's first text run marks.
+ * Extract key formatting from a block node's first VISIBLE text run marks
+ * (tracked-deleted runs are skipped, matching the visible text model used by
+ * text/textPreview/textLength). A fully tracked-deleted block therefore emits
+ * no run-formatting fields, since there is no visible run to copy from.
* When styleCtx is provided, resolves fontSize from the block's paragraph style
* chain when inline marks don't specify one (common for inherited styles).
*/
@@ -207,6 +225,13 @@ function extractBlockFormatting(
if (fontFamily !== undefined) return false;
const marks = child.marks ?? [];
if (!child.isText || marks.length === 0) return;
+ // VISIBLE model, matching text/textPreview/textLength: a tracked-deleted
+ // run is not part of the document's current reading, so its formatting
+ // must not be sampled. Without this, a block whose leading run is a styled
+ // tracked deletion reports visible `text` but the DELETED run's
+ // fontFamily/bold. The agent prompt says to copy these values, so
+ // rejected formatting would be replicated into new content.
+ if (marks.some((mark) => (mark.type as { name?: string }).name === TrackDeleteMarkName)) return;
for (const mark of marks) {
const markName = (mark.type as { name?: string }).name;
const attrs = mark.attrs as Record;
@@ -362,7 +387,12 @@ export function blocksListWrapper(editor: Editor, input?: BlocksListInput): Bloc
const styleCtx = buildStyleContext(editor);
const blocks: BlockListEntry[] = paged.map((candidate, i) => {
- const textLength = computeTextContentLength(candidate.node);
+ // VISIBLE length, matching extractBlockText and the plan-engine's apply
+ // model. The encoded `ref` (segments[].end) and `isEmpty` derive from this;
+ // using raw length here would hand out a whole-block ref ending past the
+ // visible text, so re-editing an already-redlined block via that ref throws
+ // "text offset out of range" in resolveTextRangeInBlock.
+ const textLength = computeTextContentLength(candidate.node, { textModel: 'visible' });
const fullText = input?.includeText ? extractBlockText(candidate.node) : undefined;
const numbering = extractBlockNumbering(candidate.node);
const ref =
diff --git a/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.headless.test.js b/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.headless.test.js
new file mode 100644
index 0000000000..cbf6619124
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.headless.test.js
@@ -0,0 +1,66 @@
+/* @vitest-environment jsdom */
+/**
+ * Headless editors skip linked-style decoration generation (SD-3552 perf fix):
+ * decorations are a view concern, and generateDecorations walks the whole
+ * document on every transaction. The plugin must keep `styles` exposed (for
+ * setStyleById / getStyles) while pinning `decorations` to the empty set,
+ * both at init and across doc-changing transactions.
+ */
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import { DecorationSet } from 'prosemirror-view';
+import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js';
+import { LinkedStylesPluginKey } from './plugin.js';
+
+const FIXTURE = 'docxbench-format-paragraph-001.docx';
+
+describe('linked-styles plugin: headless skip', () => {
+ let docData;
+ let editor = null;
+
+ beforeAll(async () => {
+ docData = await loadTestDataForEditorTests(FIXTURE);
+ });
+
+ afterEach(() => {
+ editor?.destroy();
+ editor = null;
+ });
+
+ it('headless: decorations stay empty across transactions, styles remain exposed', () => {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ isHeadless: true,
+ useImmediateSetTimeout: false,
+ }));
+
+ const initial = LinkedStylesPluginKey.getState(editor.state);
+ expect(initial.decorations).toBe(DecorationSet.empty);
+ expect(Array.isArray(initial.styles)).toBe(true);
+
+ // A doc-changing transaction must NOT regenerate decorations headlessly.
+ const next = editor.state.apply(editor.state.tr.insertText('x', 1));
+ const after = LinkedStylesPluginKey.getState(next);
+ expect(after.decorations).toBe(DecorationSet.empty);
+ expect(Array.isArray(after.styles)).toBe(true);
+ });
+
+ it('mounted (non-headless): plugin state present and transactions apply cleanly', () => {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ useImmediateSetTimeout: false,
+ }));
+
+ const state = LinkedStylesPluginKey.getState(editor.state);
+ expect(state).toBeTruthy();
+ expect(Array.isArray(state.styles)).toBe(true);
+ // Mounted editors keep running generateDecorations without throwing.
+ editor.view.dispatch(editor.state.tr.insertText('x', 1));
+ expect(LinkedStylesPluginKey.getState(editor.state)).toBeTruthy();
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.js b/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.js
index ddb9dc0361..caaa4c97e2 100644
--- a/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.js
+++ b/packages/super-editor/src/editors/v1/extensions/linked-styles/plugin.js
@@ -33,7 +33,15 @@ export const createLinkedStylesPlugin = (editor) => {
*/
init() {
if (!editor.converter || editor.options.mode !== 'docx') return {};
- if (editor.presentationEditor) {
+ // Linked-style decorations are a VIEW concern (inline CSS painted by the
+ // EditorView / PresentationEditor). A headless editor has no view, and a
+ // PresentationEditor paints styles via its own resolver — in both cases
+ // the decorations are never consumed, but `generateDecorations` walks the
+ // WHOLE document (resolve + style-string build) on every transaction.
+ // For a batch of N tracked edits that is O(N * doc) — ~50s on a 38-page
+ // doc. Skip generation in those modes; `styles` is still exposed for
+ // setStyleById / getStyles consumers.
+ if (editor.presentationEditor || editor.options?.isHeadless) {
return { styles: editor.converter?.linkedStyles || [], decorations: DecorationSet.empty };
}
const styles = editor.converter?.linkedStyles || [];
@@ -53,7 +61,9 @@ export const createLinkedStylesPlugin = (editor) => {
*/
apply(tr, prev, oldEditorState, newEditorState) {
if (!editor.converter || editor.options.mode !== 'docx') return { ...prev };
- if (editor.presentationEditor) {
+ // See init(): headless / presentation editors never consume these
+ // decorations, so skip the whole-document regeneration on every tx.
+ if (editor.presentationEditor || editor.options?.isHeadless) {
return { ...prev, decorations: DecorationSet.empty };
}
let decorations = prev.decorations || DecorationSet.empty;
From 35ae250dc95347e41d80f63deffa2fd236d32112 Mon Sep 17 00:00:00 2001
From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:24:47 -0300
Subject: [PATCH 3/6] Merge pull request #289 from
superdoc/caio-pizzol/superdoc-architecture-guards
test(layout-engine): add backtested architecture boundary guards
Ported-From-Source-Repo: superdoc/orbit
Ported-From-Source-Commit: 131df3187b9cf0db05a3c4e619980d5b94c03845
Ported-Public-Prefix: superdoc/public
---
AGENTS.md | 3 +-
lefthook.yml | 6 +
packages/layout-engine/AGENTS.md | 12 +-
.../tests/src/architecture-boundaries.test.ts | 234 +++++++++++++++++-
.../src/workspace-import-boundaries.test.ts | 204 +++++++++++++++
packages/superdoc/CLAUDE.md | 4 +-
6 files changed, 452 insertions(+), 11 deletions(-)
create mode 100644 packages/layout-engine/tests/src/workspace-import-boundaries.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 77b6d4cec5..8e80869536 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,7 +46,8 @@ Before adding a visual or direction-aware path, run:
```bash
# Painter must not import upstream packages or the concrete v1 adapter.
-rg "@superdoc/(super-editor|style-engine|layout-bridge|layout-resolved)" packages/layout-engine/painters/dom/src
+# Runtime sources only; test files may import @superdoc/layout-resolved.
+rg "@superdoc/(super-editor|style-engine|layout-bridge|layout-resolved)" packages/layout-engine/painters/dom/src -g '!*.test.ts' -g '!_test-utils.ts'
```
More checks in `packages/layout-engine/AGENTS.md`.
diff --git a/lefthook.yml b/lefthook.yml
index 0f9c4297ba..743b29a87f 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -9,6 +9,12 @@ pre-commit:
glob: "*.{js,jsx,ts,tsx}"
run: pnpm exec eslint --fix --no-warn-ignored {staged_files}
stage_fixed: true
+ # Architecture boundary guards (~2s full run). Repo-wide invariants, so
+ # they run whole, not per staged file. CI runs the same files in the
+ # other-packages vitest shard.
+ architecture-guards:
+ glob: "{packages,shared,apps}/**/*.{js,jsx,ts,tsx,mjs,cjs,vue}"
+ run: pnpm --dir packages/layout-engine/tests exec vitest run src/architecture-boundaries.test.ts src/workspace-import-boundaries.test.ts --silent=true
cli-build:
glob: "apps/cli/**/*.{ts,js}"
run: pnpm --prefix apps/cli run build
diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md
index dae7de893d..e65a91dbac 100644
--- a/packages/layout-engine/AGENTS.md
+++ b/packages/layout-engine/AGENTS.md
@@ -37,7 +37,7 @@ This is enforced as two hard invariants, not aspirational language:
1. **No upstream package imports.** The painter has zero runtime imports
from the v1 adapter (`@superdoc/super-editor`), `@superdoc/layout-bridge`, or
`@superdoc/layout-resolved`. Guard D in
- `tests/src/architecture-boundaries.test.ts` enforces this (SD-2836).
+ `tests/src/architecture-boundaries.test.ts` enforces this.
2. **No paint-time DOM measurement.** The painter never reads
`clientHeight`, `offsetWidth`, or `getBoundingClientRect` off rendered
content. Every size and offset comes pre-computed from the resolved
@@ -51,6 +51,16 @@ The painter also does not coalesce resolved-item fields with the legacy
resolve stage is the unique source of truth for every field the painter
reads.
+Run all architecture guards (import boundaries, bidi pre-mirroring,
+PresentationEditor OOXML, converter cascade — ~2s) before claiming a
+boundary-touching change done:
+
+```bash
+pnpm --dir packages/layout-engine/tests exec vitest run src/architecture-boundaries.test.ts src/workspace-import-boundaries.test.ts
+```
+
+CI runs the same files via the other-packages vitest shard.
+
## Common Tasks
| Task | Where to look |
diff --git a/packages/layout-engine/tests/src/architecture-boundaries.test.ts b/packages/layout-engine/tests/src/architecture-boundaries.test.ts
index 16b1bd67f8..134c62c848 100644
--- a/packages/layout-engine/tests/src/architecture-boundaries.test.ts
+++ b/packages/layout-engine/tests/src/architecture-boundaries.test.ts
@@ -19,13 +19,22 @@ const LAYOUT_ENGINE_ROOT = path.resolve(__dirname, '../../');
// (it is v1 SuperEditor's projection from hidden PM state into FlowBlock[]),
// not in a standalone layout-engine package.
const V1_ADAPTER_ROOT = path.resolve(__dirname, '../../../super-editor/src/editors/v1/core/layout-adapter');
+const V1_CORE_ROOT = path.resolve(__dirname, '../../../super-editor/src/editors/v1/core');
+// Allowlist paths for guards I/J/K are relative to the super-editor package
+// root so they survive moves of this test file.
+const SUPER_EDITOR_ROOT = path.resolve(__dirname, '../../../super-editor');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
-/** Recursively collect runtime .ts source files, excluding tests and type-only files. */
-function collectRuntimeSources(dir: string): string[] {
+/**
+ * Recursively collect runtime source files, excluding tests and type-only
+ * files. Defaults to .ts only (the layout packages); guards over super-editor
+ * trees must pass ['.ts', '.js'] — the converter is mostly .js and a
+ * .ts-only scan silently exempts it.
+ */
+function collectRuntimeSources(dir: string, exts: string[] = ['.ts']): string[] {
const files: string[] = [];
function walk(d: string) {
let entries: fs.Dirent[];
@@ -35,14 +44,20 @@ function collectRuntimeSources(dir: string): string[] {
return;
}
for (const entry of entries) {
- if (entry.name === 'test-utils' || entry.name === '__test-utils__' || entry.name === 'node_modules') continue;
+ if (
+ entry.name === 'test-utils' ||
+ entry.name === '__test-utils__' ||
+ entry.name === '__tests__' ||
+ entry.name === '__mocks__' ||
+ entry.name === 'node_modules'
+ )
+ continue;
const full = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (
- entry.name.endsWith('.ts') &&
- !entry.name.endsWith('.test.ts') &&
- !entry.name.endsWith('.spec.ts') &&
+ exts.some((ext) => entry.name.endsWith(ext)) &&
+ !/\.(test|spec)\.[jt]sx?$/.test(entry.name) &&
!entry.name.endsWith('.d.ts')
) {
files.push(full);
@@ -53,6 +68,71 @@ function collectRuntimeSources(dir: string): string[] {
return files;
}
+/**
+ * Ratchet helper: `pattern` may only match inside `allowlist` files, and each
+ * allowlisted file is frozen at its current match COUNT — adding a call site
+ * to a frozen legacy file fails, not just adding a new file. Fails on
+ * (a) any match in a file not on the allowlist,
+ * (b) an allowlisted file whose match count grew, and
+ * (c) an allowlisted file whose count shrank or hit zero — update/prune the
+ * entry so the ratchet only tightens. Paths are relative to `relativeTo`.
+ */
+function checkRatchet(opts: {
+ dirs: string[];
+ exts: string[];
+ pattern: RegExp;
+ allowlist: Map;
+ relativeTo: string;
+ ruleName: string;
+ remedy: string;
+}) {
+ const actualCounts = new Map();
+ const violations: { file: string; line: string }[] = [];
+ for (const dir of opts.dirs) {
+ for (const file of collectRuntimeSources(dir, opts.exts)) {
+ const rel = path.relative(opts.relativeTo, file).split(path.sep).join('/');
+ const processed = preprocessSource(fs.readFileSync(file, 'utf-8'));
+ const lines = processed.split('\n');
+ const globalPattern = new RegExp(
+ opts.pattern.source,
+ opts.pattern.flags.includes('g') ? opts.pattern.flags : opts.pattern.flags + 'g',
+ );
+ let count = 0;
+ let match: RegExpExecArray | null;
+ while ((match = globalPattern.exec(processed)) !== null) {
+ count += 1;
+ if (!opts.allowlist.has(rel)) {
+ const lineNo = processed.slice(0, match.index).split('\n').length;
+ violations.push({ file: `${rel}:${lineNo}`, line: lines[lineNo - 1].trim() });
+ }
+ // A zero-width match would never advance lastIndex and hang the scan.
+ if (match.index === globalPattern.lastIndex) globalPattern.lastIndex += 1;
+ }
+ if (count > 0) actualCounts.set(rel, count);
+ }
+ }
+ for (const [rel, allowed] of opts.allowlist) {
+ const actual = actualCounts.get(rel) ?? 0;
+ if (actual > allowed) {
+ violations.push({
+ file: rel,
+ line: `match count grew ${allowed} -> ${actual}: new call site(s) added to a frozen legacy file`,
+ });
+ }
+ }
+ if (violations.length > 0) {
+ const details = violations.map((v) => ` ${v.file}\n ${v.line}`).join('\n');
+ expect.fail(`${opts.ruleName}: found ${violations.length} new violation(s).\n${opts.remedy}\n\n${details}`);
+ }
+ const stale = [...opts.allowlist.entries()].filter(([rel, allowed]) => (actualCounts.get(rel) ?? 0) < allowed);
+ if (stale.length > 0) {
+ expect.fail(
+ `${opts.ruleName}: allowlist counts shrank — tighten the entries (the ratchet only shrinks):\n` +
+ stale.map(([rel, allowed]) => ` ${rel}: ${allowed} -> ${actualCounts.get(rel) ?? 0}`).join('\n'),
+ );
+ }
+}
+
/** Strip single-line and multi-line comments, then collapse multiline imports. */
function preprocessSource(raw: string): string {
// Strip multi-line comments (non-greedy)
@@ -254,7 +334,7 @@ describe('architecture boundaries', () => {
const violations: { file: string; line: string; receiver: string; api: string }[] = [];
for (const file of files) {
- const relPath = path.relative(LAYOUT_ENGINE_ROOT, file);
+ const relPath = path.relative(LAYOUT_ENGINE_ROOT, file).split(path.sep).join('/');
if (ALLOWED_INTERACTION_FILES.has(relPath)) continue;
const raw = fs.readFileSync(file, 'utf-8');
const processed = preprocessSource(raw);
@@ -383,4 +463,144 @@ describe('architecture boundaries', () => {
});
}
});
+
+ describe('Guard I: upstream layers do not pre-mirror bidiVisual sides (SD-3134)', () => {
+ // Upstream stores logical sides LTR-default; DomPainter mirrors exactly
+ // once at paint time. A `cond ? 'right' : 'left'` on an RTL flag upstream
+ // is the double-swap this guard exists for — SD-3134's violation was
+ // literally `isRtlTable ? 'right' : 'left'`, which the direction README's
+ // narrower `rightToLeft` quick-check would not have matched.
+ // Allowlisted files convert logical↔physical as their declared job; new
+ // entries require explicit reviewer sign-off.
+ // Counts freeze each file's existing conversions; adding one more
+ // `rtl ? 'left'/'right'` to a sanctioned file fails too.
+ const ALLOWED_DIRECTION_FILES = new Map([
+ // Sanctioned resolver: the single place logical sides become physical
+ // (direction/README.md names resolver files as expected raw readers).
+ ['src/editors/v1/core/layout-adapter/direction/logicalSides.ts', 6],
+ // Logical indent/justification conversion helpers; frozen as-is pending
+ // a direction-owner review (SD-3580) — do not add siblings.
+ ['src/editors/v1/core/layout-adapter/attributes/spacing-indent.ts', 4],
+ ]);
+
+ it('layout-adapter and super-converter do not resolve RTL flags to physical left/right', () => {
+ checkRatchet({
+ dirs: [V1_ADAPTER_ROOT, path.join(V1_CORE_ROOT, 'super-converter')],
+ exts: ['.ts', '.js'],
+ pattern: /(isRtl\w*|rightToLeft|\brtl)\s*\?\s*['"](left|right)['"]/,
+ allowlist: ALLOWED_DIRECTION_FILES,
+ relativeTo: SUPER_EDITOR_ROOT,
+ ruleName: 'Guard I (bidi pre-mirroring)',
+ remedy:
+ 'Store logical sides LTR-default upstream; DomPainter mirrors once at paint time.\n' +
+ 'Pre-mirroring here double-swaps under w:bidiVisual. See layout-adapter/direction/README.md.',
+ });
+ });
+ });
+
+ describe('Guard J: PresentationEditor does not resolve OOXML semantics', () => {
+ // PresentationEditor bridges editor events into layout/paint state. Raw
+ // OOXML vocabulary (`'w:...'` literals) in its production source means it
+ // is resolving document semantics that belong in the converter,
+ // style-engine, or the v1 adapter. The two allowlisted files are known
+ // violations from the SD-2656 footnote work, frozen until extracted
+ // (SD-3578) — the allowlist only shrinks.
+ // Counts freeze the existing violations; adding another w: literal to
+ // either file fails.
+ const ALLOWED_OOXML_FILES = new Map([
+ ['src/editors/v1/core/presentation-editor/PresentationEditor.ts', 2],
+ ['src/editors/v1/core/presentation-editor/layout/separatorContentClassifier.ts', 8],
+ ]);
+
+ it('presentation-editor production source contains no w: OOXML literals outside the frozen allowlist', () => {
+ checkRatchet({
+ dirs: [path.join(V1_CORE_ROOT, 'presentation-editor')],
+ exts: ['.ts', '.js'],
+ pattern: /['"]w:[a-zA-Z]/,
+ allowlist: ALLOWED_OOXML_FILES,
+ relativeTo: SUPER_EDITOR_ROOT,
+ ruleName: 'Guard J (PresentationEditor OOXML)',
+ remedy:
+ 'Resolve OOXML semantics upstream (converter/style-engine/adapter) and pass typed data into\n' +
+ 'PresentationEditor. Do not add files to the allowlist; extract instead.',
+ });
+ });
+ });
+
+ describe('Guard K: the converter does not resolve style cascades', () => {
+ // The converter parses and stores only what is explicitly in the XML;
+ // layout-engine/style-engine owns cascade resolution. Style-engine call
+ // sites under super-converter/ are frozen legacy — the allowlist only
+ // shrinks. textbox-content-helpers.js is a post-rule violation (#3700),
+ // frozen here pending an extract-vs-sanction decision (SD-3579), not
+ // sanctioned.
+ // The identifier list is DERIVED from style-engine's export surface at
+ // test time (every exported resolve*/combine*/determine* helper), so a
+ // new cascade helper is covered automatically — converter files import
+ // these through the `styles.js` shim (`@converter/styles.js`), and
+ // matching only the package specifier would miss every laundered
+ // consumer. Known limit: a converter-local wrapper around a frozen
+ // file's helper (e.g. resolveParagraphPropertiesForTextBox) is chased
+ // only via its defining file's frozen count, not import-graph analysis.
+ const STYLE_ENGINE_SRC = ['style-engine/src/index.ts', 'style-engine/src/ooxml/index.ts'];
+ const cascadeIdentifiers = new Set();
+ for (const relFile of STYLE_ENGINE_SRC) {
+ const src = fs.readFileSync(path.join(LAYOUT_ENGINE_ROOT, relFile), 'utf-8');
+ for (const m of src.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) cascadeIdentifiers.add(m[1]);
+ for (const m of src.matchAll(/export\s*\{([^}]+)\}/g)) {
+ for (const raw of m[1].split(',')) {
+ const name =
+ raw
+ .trim()
+ .split(/\s+as\s+/)
+ .pop()
+ ?.trim() ?? '';
+ if (/^[A-Za-z_$]/.test(name) && !name.startsWith('type ')) cascadeIdentifiers.add(name);
+ }
+ }
+ }
+ const cascadeNames = [...cascadeIdentifiers].filter((n) => /^(resolve|combine|determine)/.test(n)).sort();
+ // Counts freeze each file's existing call sites; adding one more to a
+ // frozen legacy file fails.
+ const ALLOWED_LEGACY_CASCADE_FILES = new Map([
+ ['src/editors/v1/core/super-converter/styles.js', 10], // the re-export shim itself
+ ['src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js', 2],
+ ['src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js', 2],
+ ['src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js', 2],
+ // Post-rule violation (#3700), frozen pending SD-3579; calls the real
+ // cascade and wraps it as resolveParagraphPropertiesForTextBox.
+ ['src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js', 7],
+ ]);
+
+ // An empty name list would make `\b(?:)\b` match zero-width everywhere;
+ // fail loudly at construction instead of degrading or hanging.
+ function buildCascadePattern(): RegExp {
+ if (cascadeNames.length === 0) {
+ throw new Error('Guard K: derived cascade-identifier list is empty — style-engine export parsing broke');
+ }
+ return new RegExp(`@superdoc/style-engine|\\b(?:${cascadeNames.join('|')})\\b`);
+ }
+
+ it('sanity check: the derived cascade-identifier list is non-trivial', () => {
+ // If style-engine's export shape changes such that derivation breaks,
+ // fail loudly instead of silently matching nothing.
+ expect(cascadeNames.length).toBeGreaterThanOrEqual(8);
+ expect(cascadeNames).toContain('resolveRunProperties');
+ expect(cascadeNames).toContain('combineRunProperties');
+ });
+
+ it('super-converter production source adds no new style-engine cascade call sites', () => {
+ checkRatchet({
+ dirs: [path.join(V1_CORE_ROOT, 'super-converter')],
+ exts: ['.ts', '.js', '.mjs'],
+ pattern: buildCascadePattern(),
+ allowlist: ALLOWED_LEGACY_CASCADE_FILES,
+ relativeTo: SUPER_EDITOR_ROOT,
+ ruleName: 'Guard K (converter cascade resolution)',
+ remedy:
+ 'The converter stores explicit XML only; resolving cascades at import bakes inline properties\n' +
+ 'into nodes and export loses style references. Resolve in style-engine at render time instead.',
+ });
+ });
+ });
});
diff --git a/packages/layout-engine/tests/src/workspace-import-boundaries.test.ts b/packages/layout-engine/tests/src/workspace-import-boundaries.test.ts
new file mode 100644
index 0000000000..8331a6c16d
--- /dev/null
+++ b/packages/layout-engine/tests/src/workspace-import-boundaries.test.ts
@@ -0,0 +1,204 @@
+/**
+ * Workspace import boundary guardrails (R1/R2).
+ *
+ * Two escape modes recur in this repo's history (SD-609, SD-2836, SD-3222,
+ * v2 restructures — roughly one cleanup event per quarter before these
+ * guards):
+ * R1: relative imports that climb out of the importing package's root into
+ * a sibling package (`../../super-editor/src/...`).
+ * R2: package-specifier imports of another workspace package's internals
+ * (`@superdoc/foo/src/...`, `superdoc/dist/...`).
+ *
+ * Scope: runtime source of the product slice (packages/, apps/, shared/).
+ * Tests, scripts, dist, examples, demos, and fixtures are out of scope —
+ * generated re-exports in scripts (e.g. superdoc's ensure-types.cjs) are
+ * intentionally not scanned.
+ */
+
+import { describe, it, expect } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// packages/layout-engine/tests/src → superdoc/public
+const WORKSPACE_ROOT = path.resolve(__dirname, '../../../../');
+const PRODUCT_SLICE = ['packages', 'apps', 'shared'];
+
+const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue'];
+const EXCLUDED_DIRS = new Set([
+ 'node_modules',
+ 'dist',
+ 'build',
+ 'coverage',
+ 'test',
+ 'tests',
+ '__tests__',
+ '__mocks__',
+ 'test-utils',
+ '__test-utils__',
+ 'e2e-tests',
+ 'fixtures',
+ 'scripts',
+ 'demo',
+ 'demos',
+ 'examples',
+ '.tmp',
+]);
+
+// R1 exceptions. Each entry is `|`
+// so a new escape in an allowlisted file still fails. This list only shrinks.
+const R1_ALLOWLIST = new Set([
+ // Intentional per the adjacent comment: consume the editor stylesheet from
+ // source so monorepo dev gets CSS live reload; the named ./style.css export
+ // resolves to dist. Remove once a dev-only alias maps the package specifier
+ // to source (SD-3580).
+ 'packages/superdoc/src/SuperDoc.vue|../../super-editor/src/style.css',
+ // Dev-only panel; toolbarIcons has no named export today and adding an
+ // unnamed wildcard subpath import would fight the exports-narrowing
+ // ratchet. Remove once super-editor names a toolbar-icons export (SD-3580).
+ 'packages/superdoc/src/dev/components/SuperdocDev.vue|../../../../super-editor/src/editors/v1/components/toolbar/toolbarIcons',
+]);
+
+function collectSources(dir: string): string[] {
+ const files: string[] = [];
+ function walk(d: string) {
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(d, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (EXCLUDED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
+ const full = path.join(d, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ } else if (
+ SOURCE_EXTS.some((ext) => entry.name.endsWith(ext)) &&
+ !/\.(test|spec|stories|bench)\.(m|c)?[jt]sx?$/.test(entry.name) &&
+ !entry.name.endsWith('.d.ts') &&
+ !entry.name.endsWith('.config.js') &&
+ !entry.name.endsWith('.config.ts') &&
+ !entry.name.endsWith('.config.mjs')
+ ) {
+ files.push(full);
+ }
+ }
+ }
+ walk(dir);
+ return files;
+}
+
+function stripComments(raw: string): string {
+ return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
+}
+
+/** Every import/export/require specifier in the file, static or dynamic. */
+function extractSpecifiers(src: string): string[] {
+ const specifiers: string[] = [];
+ const patterns = [
+ /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]/g,
+ /import\s*['"]([^'"]+)['"]/g, // side-effect imports (CSS etc.)
+ /import\s*\(\s*['"]([^'"]+)['"]/g,
+ /require\s*\(\s*['"]([^'"]+)['"]/g,
+ ];
+ for (const pattern of patterns) {
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(src)) !== null) specifiers.push(match[1]);
+ }
+ return specifiers;
+}
+
+/** Nearest ancestor directory (within the workspace) containing package.json. */
+function packageRootOf(file: string): string | null {
+ let dir = path.dirname(file);
+ while (dir.length >= WORKSPACE_ROOT.length) {
+ if (dir !== WORKSPACE_ROOT && fs.existsSync(path.join(dir, 'package.json'))) return dir;
+ if (dir === WORKSPACE_ROOT) break;
+ dir = path.dirname(dir);
+ }
+ return null;
+}
+
+interface Violation {
+ file: string;
+ specifier: string;
+}
+
+function formatViolations(violations: Violation[]): string {
+ return violations.map((v) => ` ${v.file}\n → ${v.specifier}`).join('\n');
+}
+
+describe('workspace import boundaries', () => {
+ const allFiles = PRODUCT_SLICE.flatMap((slice) => collectSources(path.join(WORKSPACE_ROOT, slice)));
+
+ it('sanity check: the product slice yields a plausible source set', () => {
+ // Guards that scan zero files pass vacuously; fail loudly if the layout
+ // of the repo changes underneath this test.
+ expect(allFiles.length).toBeGreaterThan(1000);
+ expect(allFiles.some((f) => f.endsWith('.vue'))).toBe(true);
+ expect(allFiles.some((f) => f.endsWith('.js'))).toBe(true);
+ });
+
+ it('R2: no package imports another workspace package via /src or /dist internals', () => {
+ // `superdoc` (unscoped) is also a workspace package; match it and both
+ // @superdoc/@superdoc-dev scopes.
+ const deepImport = /^(?:@superdoc(?:-dev)?\/[^/]+|superdoc)\/(?:src|dist)(?:\/|$)/;
+ const violations: Violation[] = [];
+ for (const file of allFiles) {
+ const src = stripComments(fs.readFileSync(file, 'utf-8'));
+ for (const spec of extractSpecifiers(src)) {
+ if (deepImport.test(spec)) {
+ violations.push({ file: path.relative(WORKSPACE_ROOT, file), specifier: spec });
+ }
+ }
+ }
+ if (violations.length > 0) {
+ expect.fail(
+ `Found ${violations.length} deep workspace import(s). Import through the package's declared\n` +
+ `exports instead; if the symbol is not exported, add a named export entry.\n\n` +
+ formatViolations(violations),
+ );
+ }
+ });
+
+ it('R1: no relative import escapes its own package root', () => {
+ const violations: Violation[] = [];
+ for (const file of allFiles) {
+ const pkgRoot = packageRootOf(file);
+ if (!pkgRoot) continue;
+ const src = stripComments(fs.readFileSync(file, 'utf-8'));
+ for (const spec of extractSpecifiers(src)) {
+ if (!spec.startsWith('.')) continue;
+ const resolved = path.resolve(path.dirname(file), spec);
+ if (resolved === pkgRoot || resolved.startsWith(pkgRoot + path.sep)) continue;
+ const rel = path.relative(WORKSPACE_ROOT, file).split(path.sep).join('/');
+ if (R1_ALLOWLIST.has(`${rel}|${spec}`)) continue;
+ violations.push({ file: rel, specifier: spec });
+ }
+ }
+ if (violations.length > 0) {
+ expect.fail(
+ `Found ${violations.length} relative import(s) escaping their package root. Import the sibling\n` +
+ `package through its workspace specifier and declared exports instead.\n\n` +
+ formatViolations(violations),
+ );
+ }
+ });
+
+ it('R1 allowlist entries still exist (the list only shrinks)', () => {
+ const stale: string[] = [];
+ for (const entry of R1_ALLOWLIST) {
+ const [rel, spec] = entry.split('|');
+ const abs = path.join(WORKSPACE_ROOT, rel);
+ if (!fs.existsSync(abs)) {
+ stale.push(entry);
+ continue;
+ }
+ const src = stripComments(fs.readFileSync(abs, 'utf-8'));
+ if (!extractSpecifiers(src).includes(spec)) stale.push(entry);
+ }
+ if (stale.length > 0) {
+ expect.fail(`R1 allowlist entries no longer match and must be pruned:\n${stale.map((s) => ` ${s}`).join('\n')}`);
+ }
+ });
+});
diff --git a/packages/superdoc/CLAUDE.md b/packages/superdoc/CLAUDE.md
index abecc50408..9258332747 100644
--- a/packages/superdoc/CLAUDE.md
+++ b/packages/superdoc/CLAUDE.md
@@ -33,12 +33,12 @@ import { SuperDoc } from 'superdoc';
const superdoc = new SuperDoc({
selector: '#editor',
document: docxArrayBuffer,
- mode: 'edit', // or 'view'
+ documentMode: 'editing', // or 'viewing' | 'suggesting'
// ... options
});
// Key methods
-superdoc.setMode('view');
+superdoc.setDocumentMode('viewing');
superdoc.getDocument();
superdoc.destroy();
```
From 7e794ab747f2f0a8e8baffc8c05f5a4745acba6f Mon Sep 17 00:00:00 2001
From: Luccas Correa
Date: Fri, 3 Jul 2026 01:30:15 -0300
Subject: [PATCH 4/6] =?UTF-8?q?fix(super-editor):=20v1=20=E2=80=94=20expor?=
=?UTF-8?q?t/import=20citation=20hyperlinks=20as=20tracked=20changes=20(SD?=
=?UTF-8?q?-3563)=20(#305)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(super-editor): export tracked citation hyperlinks
SD-3563 fixes v1 headless structural insertion of citation hyperlinks when track changes are enabled.
The structural writer already materialized linked citation text with both link and trackInsert marks, but DOCX export prioritized the track-change wrapper and paragraph merge could emit a single w:ins around both linked and unlinked text. That lost the valid tracked-hyperlink shape Bloomberg needs for reviewable citations.
Text export now routes linked tracked text through the hyperlink translator first so DOCX emits w:hyperlink containing w:ins/w:del content. Hyperlink export preserves remaining revision marks while stripping only the link mark, and hyperlink import now includes revision children so w:hyperlink > w:ins/w:del round-trips back to linked tracked text.
Adds SD-3563 regression coverage for the v1 headless plan flow plus focused translator tests for linked tracked export/import.
* fix(super-editor): drop final linked deletions
* fix: tracked hyperlink rejection cleanup
Ported-From-Source-Repo: superdoc/orbit
Ported-From-Source-Commit: 8a0951df3b15045bf47390e82ca24e5714aa9f1d
Ported-Public-Prefix: superdoc/public
---
.../w/hyperlink/hyperlink-translator.js | 72 ++++++-
.../w/hyperlink/hyperlink-translator.test.js | 202 ++++++++++++++++++
.../v3/handlers/w/t/t-translator.js | 17 +-
.../v3/handlers/w/t/t-translator.test.js | 36 +++-
.../structural-write-engine.test.ts | 127 +++++++++++
5 files changed, 440 insertions(+), 14 deletions(-)
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js
index 96421ed7be..fc38e9edf0 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js
@@ -58,11 +58,12 @@ const encode = (params, encodedAttrs) => {
// Add marks to the run nodes and process them
const linkMark = { type: 'link', attrs: { ...encodedAttrs, href } };
const referenceNodeTypes = ['sd:pageReference', 'sd:autoPageNumber', 'sd:totalPageNumber'];
- const contentNodes = node.elements.filter((el) => el.name === 'w:r' || referenceNodeTypes.includes(el.name));
+ const revisionNodeTypes = ['w:ins', 'w:del'];
+ const contentNodes = node.elements.filter(
+ (el) => el.name === 'w:r' || revisionNodeTypes.includes(el.name) || referenceNodeTypes.includes(el.name),
+ );
contentNodes.forEach((contentNode) => {
- const existingMarks = Array.isArray(contentNode.marks) ? contentNode.marks : [];
- const marksWithoutLink = existingMarks.filter((mark) => mark?.type !== 'link');
- contentNode.marks = [...marksWithoutLink, linkMark];
+ addLinkMarkToContentNode(contentNode, linkMark, referenceNodeTypes);
});
const updatedNode = nodeListHandler.handler({
@@ -74,6 +75,34 @@ const encode = (params, encodedAttrs) => {
return updatedNode;
};
+/**
+ * @param {Record} contentNode
+ * @param {{ type: string, attrs: Record }} linkMark
+ * @param {string[]} referenceNodeTypes
+ */
+function addLinkMarkToContentNode(contentNode, linkMark, referenceNodeTypes) {
+ if (contentNode.name === 'w:ins' || contentNode.name === 'w:del') {
+ (contentNode.elements || []).forEach((child) => addLinkMarkToContentNode(child, linkMark, referenceNodeTypes));
+ return;
+ }
+
+ if (contentNode.name !== 'w:r' && !referenceNodeTypes.includes(contentNode.name)) {
+ return;
+ }
+
+ addLinkMark(contentNode, linkMark);
+}
+
+/**
+ * @param {Record} node
+ * @param {{ type: string, attrs: Record }} linkMark
+ */
+function addLinkMark(node, linkMark) {
+ const existingMarks = Array.isArray(node.marks) ? node.marks : [];
+ const marksWithoutLink = existingMarks.filter((mark) => mark?.type !== 'link');
+ node.marks = [...marksWithoutLink, linkMark];
+}
+
/**
* Resolve the href from the relationship ID or anchor.
* @param {Object} docx - The parsed DOCX object.
@@ -112,24 +141,45 @@ function decode(params) {
const linkAttrs = this.decodeAttributes({ ...params, node: linkMark });
let { href: link, anchor } = linkMark.attrs;
- if (!linkAttrs['r:id'] && !anchor) {
- linkAttrs['r:id'] = _addNewLinkRelationship(params, link);
- }
-
let contentNodes = [];
hyperlinkGroup.forEach((linkNode) => {
+ let nodeWithoutLink = linkNode;
if ('marks' in linkNode) {
- linkNode.marks = linkNode.marks.filter((m) => m.type !== 'link');
+ nodeWithoutLink = {
+ ...linkNode,
+ marks: linkNode.marks.filter((m) => m.type !== 'link'),
+ };
} else {
- linkNode.attrs.marksAsAttrs = linkNode.attrs.marksAsAttrs.filter((m) => m.type !== 'link');
+ nodeWithoutLink = {
+ ...linkNode,
+ attrs: {
+ ...linkNode.attrs,
+ marksAsAttrs: linkNode.attrs.marksAsAttrs.filter((m) => m.type !== 'link'),
+ },
+ };
}
- const outputNode = exportSchemaToJson({ ...params, node: linkNode });
+ const outputNode = exportSchemaToJson({
+ ...params,
+ node: nodeWithoutLink,
+ extraParams: {
+ ...params.extraParams,
+ linkProcessed: true,
+ },
+ });
if (outputNode) {
if (outputNode instanceof Array) contentNodes.push(...outputNode);
else contentNodes.push(outputNode);
}
});
+ if (!contentNodes.length) {
+ return null;
+ }
+
+ if (!linkAttrs['r:id'] && !anchor) {
+ linkAttrs['r:id'] = _addNewLinkRelationship(params, link);
+ }
+
const newNode = {
name: 'w:hyperlink',
type: 'element',
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.test.js
index bf172c2c8a..f90cf8b6ed 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.test.js
@@ -202,6 +202,84 @@ describe('w:hyperlink translator', () => {
path: [...params.path, params.nodes[0]],
});
});
+
+ it('should import revision-wrapped runs with both link and track marks', () => {
+ const nodeListHandler = {
+ handler: vi.fn(({ nodes }) => {
+ const revisionNode = nodes[0];
+ const runNode = revisionNode.elements[0];
+ return [
+ {
+ type: 'text',
+ text: '[27]',
+ marks: [
+ ...runNode.marks,
+ {
+ type: 'trackInsert',
+ attrs: {
+ id: '1',
+ sourceId: '1',
+ author: 'A',
+ date: '2026-07-02T00:00:00Z',
+ },
+ },
+ ],
+ },
+ ];
+ }),
+ };
+ const params = {
+ nodes: [
+ {
+ name: 'w:hyperlink',
+ attributes: { 'r:id': 'rId1' },
+ elements: [
+ {
+ name: 'w:ins',
+ attributes: { 'w:id': '1', 'w:author': 'A', 'w:date': '2026-07-02T00:00:00Z' },
+ elements: [{ name: 'w:r', elements: [] }],
+ },
+ ],
+ },
+ ],
+ docx: {
+ 'word/_rels/document.xml.rels': {
+ elements: [
+ {
+ name: 'Relationships',
+ elements: [{ name: 'Relationship', attributes: { Id: 'rId1', Target: 'https://example.com' } }],
+ },
+ ],
+ },
+ },
+ nodeListHandler,
+ path: [],
+ };
+
+ const result = config.encode(params, { rId: 'rId1' });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].marks).toEqual([
+ { type: 'link', attrs: { rId: 'rId1', href: 'https://example.com' } },
+ {
+ type: 'trackInsert',
+ attrs: {
+ id: '1',
+ sourceId: '1',
+ author: 'A',
+ date: '2026-07-02T00:00:00Z',
+ },
+ },
+ ]);
+ expect(nodeListHandler.handler).toHaveBeenCalledWith({
+ ...params,
+ nodes: params.nodes[0].elements,
+ path: [...params.path, params.nodes[0]],
+ });
+ expect(params.nodes[0].elements[0].elements[0].marks).toEqual([
+ { type: 'link', attrs: { rId: 'rId1', href: 'https://example.com' } },
+ ]);
+ });
});
describe('config.decode', () => {
@@ -270,6 +348,72 @@ describe('w:hyperlink translator', () => {
expect(params.relationships).toHaveLength(1);
expect(params.relationships[0]).toBe(existingRel);
});
+
+ it('preserves revision marks inside the hyperlink wrapper on export', () => {
+ const trackInsert = {
+ type: 'trackInsert',
+ attrs: {
+ id: 'track-1',
+ sourceId: '9',
+ author: 'A',
+ authorEmail: 'a@example.com',
+ date: '2026-07-02T00:00:00Z',
+ },
+ };
+ const params = {
+ node: {
+ type: 'text',
+ text: '[27]',
+ marks: [trackInsert, { type: 'link', attrs: { href: 'https://example.com/citation', rId: 'rId7' } }],
+ },
+ relationships: [],
+ };
+
+ vi.mocked(exportSchemaToJson).mockImplementation((p) => {
+ expect(p.extraParams.linkProcessed).toBe(true);
+ expect(p.node.marks).toEqual([trackInsert]);
+ return {
+ name: 'w:ins',
+ attributes: {
+ 'w:id': '9',
+ 'w:author': 'A',
+ 'w:authorEmail': 'a@example.com',
+ 'w:date': '2026-07-02T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:t', elements: [{ type: 'text', text: '[27]' }] }],
+ },
+ ],
+ };
+ });
+
+ const result = translator.decode(params);
+
+ expect(result).toEqual({
+ name: 'w:hyperlink',
+ type: 'element',
+ attributes: { 'r:id': 'rId7' },
+ elements: [
+ {
+ name: 'w:ins',
+ attributes: {
+ 'w:id': '9',
+ 'w:author': 'A',
+ 'w:authorEmail': 'a@example.com',
+ 'w:date': '2026-07-02T00:00:00Z',
+ },
+ elements: [
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:t', elements: [{ type: 'text', text: '[27]' }] }],
+ },
+ ],
+ },
+ ],
+ });
+ });
});
describe('with hyperlinkGroup', () => {
@@ -310,5 +454,63 @@ describe('w:hyperlink translator', () => {
expect(result.elements[0].elements[0].elements[0].text).toBe('link text 1');
expect(result.elements[1].elements[0].elements[0].text).toBe('link text 2');
});
+
+ it('preserves surviving final-doc content in deletion-first tracked hyperlink groups', () => {
+ const linkMark = { type: 'link', attrs: { href: 'https://example.com/citation', rId: 'rId9' } };
+ const trackDelete = { type: 'trackDelete' };
+ const trackInsert = { type: 'trackInsert' };
+ const deletedNode = { type: 'text', text: 'old', marks: [trackDelete, linkMark] };
+ const insertedNode = { type: 'text', text: 'new', marks: [trackInsert, linkMark] };
+ const insertedRun = {
+ name: 'w:ins',
+ elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'new' }] }] }],
+ };
+ const params = {
+ node: deletedNode,
+ isFinalDoc: true,
+ extraParams: { hyperlinkGroup: [deletedNode, insertedNode] },
+ relationships: [],
+ };
+
+ vi.mocked(exportSchemaToJson).mockImplementation((p) => {
+ if (p.node.marks.some((mark) => mark.type === 'trackDelete')) return null;
+ return insertedRun;
+ });
+
+ const result = translator.decode(params);
+
+ expect(result).toEqual({
+ name: 'w:hyperlink',
+ type: 'element',
+ attributes: { 'r:id': 'rId9' },
+ elements: [insertedRun],
+ });
+ expect(exportSchemaToJson).toHaveBeenCalledTimes(2);
+ expect(vi.mocked(exportSchemaToJson).mock.calls.map(([p]) => p.node.marks)).toEqual([
+ [trackDelete],
+ [trackInsert],
+ ]);
+ });
+
+ it('returns null when a hyperlink group has no exportable content', () => {
+ const linkMark = { type: 'link', attrs: { href: 'https://example.com/citation' } };
+ const deletedNode = { type: 'text', text: 'old', marks: [{ type: 'trackDelete' }, linkMark] };
+ const nextDeletedNode = { type: 'text', text: 'older', marks: [{ type: 'trackDelete' }, linkMark] };
+ const params = {
+ node: deletedNode,
+ isFinalDoc: true,
+ extraParams: { hyperlinkGroup: [deletedNode, nextDeletedNode] },
+ relationships: [],
+ };
+
+ vi.mocked(exportSchemaToJson).mockReturnValue(null);
+
+ const result = translator.decode(params);
+
+ expect(result).toBeNull();
+ expect(exportSchemaToJson).toHaveBeenCalledTimes(2);
+ expect(params.relationships).toHaveLength(0);
+ expect(generateDocxRandomId).not.toHaveBeenCalled();
+ });
});
});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.js
index d45c4b0c8f..41160ffdf0 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.js
@@ -104,6 +104,22 @@ const decode = (params) => {
// Separate tracked changes from regular text
const trackedMarks = ['trackDelete', 'trackInsert'];
const trackedMark = node.marks?.find((m) => trackedMarks.includes(m.type));
+ const isLinkNode = node.marks?.some((m) => m.type === 'link');
+ const isGroupedHyperlinkExport =
+ isLinkNode &&
+ !extraParams?.linkProcessed &&
+ Array.isArray(extraParams?.hyperlinkGroup) &&
+ extraParams.hyperlinkGroup.length > 1;
+
+ // Standalone final-doc linked deletions must resolve as w:del before hyperlink export
+ // so accepted deletions do not leave an empty w:hyperlink wrapper.
+ if (params.isFinalDoc && trackedMark?.type === 'trackDelete' && !isGroupedHyperlinkExport) {
+ return wDelTranslator.decode(params);
+ }
+
+ if (isLinkNode && trackedMark && !extraParams?.linkProcessed) {
+ return wHyperlinkTranslator.decode(params);
+ }
if (trackedMark) {
switch (trackedMark.type) {
@@ -115,7 +131,6 @@ const decode = (params) => {
}
// Separate links from regular text
- const isLinkNode = node.marks?.some((m) => m.type === 'link');
if (isLinkNode && !extraParams?.linkProcessed) {
return wHyperlinkTranslator.decode(params);
}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.test.js
index 58f5167c0f..d7ce876e28 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/t-translator.test.js
@@ -384,6 +384,38 @@ describe('w:t translator', () => {
expect(wHyperlinkTranslator.decode).toHaveBeenCalled();
});
+ it('delegates linked tracked text to wHyperlinkTranslator first', () => {
+ const node = { type: 'text', marks: [{ type: 'trackInsert' }, { type: 'link' }] };
+ const params = { node, extraParams: {} };
+
+ config.decode(params);
+
+ expect(wHyperlinkTranslator.decode).toHaveBeenCalledWith(params);
+ expect(wInsTranslator.decode).not.toHaveBeenCalled();
+ });
+
+ it('lets standalone final linked deletions resolve through wDelTranslator', () => {
+ const node = { type: 'text', marks: [{ type: 'trackDelete' }, { type: 'link' }] };
+ const params = { node, isFinalDoc: true, extraParams: {} };
+
+ config.decode(params);
+
+ expect(wDelTranslator.decode).toHaveBeenCalledWith(params);
+ expect(wHyperlinkTranslator.decode).not.toHaveBeenCalled();
+ });
+
+ it('delegates final deletion-first hyperlink groups to wHyperlinkTranslator', () => {
+ const linkMark = { type: 'link', attrs: { href: 'https://example.com', rId: 'rId1' } };
+ const node = { type: 'text', text: 'old', marks: [{ type: 'trackDelete' }, linkMark] };
+ const nextNode = { type: 'text', text: 'new', marks: [{ type: 'trackInsert' }, linkMark] };
+ const params = { node, isFinalDoc: true, extraParams: { hyperlinkGroup: [node, nextNode] } };
+
+ config.decode(params);
+
+ expect(wHyperlinkTranslator.decode).toHaveBeenCalledWith(params);
+ expect(wDelTranslator.decode).not.toHaveBeenCalled();
+ });
+
it('calls getTextNodeForExport for regular text nodes', () => {
const node = { type: 'text', text: 'Hello', marks: [] };
const params = { node };
@@ -392,10 +424,10 @@ describe('w:t translator', () => {
});
it('skips hyperlink delegation when linkProcessed is true', () => {
- const node = { type: 'text', marks: [{ type: 'link' }] };
+ const node = { type: 'text', marks: [{ type: 'trackInsert' }, { type: 'link' }] };
config.decode({ node, extraParams: { linkProcessed: true } });
expect(wHyperlinkTranslator.decode).not.toHaveBeenCalled();
- expect(getTextNodeForExport).toHaveBeenCalled();
+ expect(wInsTranslator.decode).toHaveBeenCalled();
});
});
});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts
index d3e3085c80..734b3745e0 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts
@@ -11,6 +11,7 @@ import { enforceNestingPolicy } from './nesting-guard.js';
import { validateDocumentFragment } from '@superdoc/document-api';
import { DocumentApiAdapterError } from '../errors.js';
import type { SDFragment, SelectionTarget, SDReplaceInput } from '@superdoc/document-api';
+import { getExportedResultWithDocContent } from '@tests/export/export-helpers/index.js';
let docData: Awaited>;
@@ -103,6 +104,54 @@ function enableTrackedMode(editor: Editor): void {
};
}
+function collectTextMarks(editor: Editor, text: string): string[][] {
+ const matches: string[][] = [];
+ editor.state.doc.descendants((node) => {
+ if (!node.isText || node.text !== text) return true;
+ matches.push(node.marks.map((mark) => mark.type.name));
+ return true;
+ });
+ return matches;
+}
+
+function collectXmlText(node: any): string {
+ if (!node) return '';
+ if (node.type === 'text' && typeof node.text === 'string') return node.text;
+ if (!Array.isArray(node.elements)) return '';
+ return node.elements.map(collectXmlText).join('');
+}
+
+function hasXmlDescendant(node: any, name: string): boolean {
+ if (!node || !Array.isArray(node.elements)) return false;
+ return node.elements.some((child: any) => child.name === name || hasXmlDescendant(child, name));
+}
+
+function findXmlNodes(node: any, predicate: (node: any) => boolean, matches: any[] = []): any[] {
+ if (!node) return matches;
+ if (predicate(node)) matches.push(node);
+ if (Array.isArray(node.elements)) {
+ node.elements.forEach((child: any) => findXmlNodes(child, predicate, matches));
+ }
+ return matches;
+}
+
+function findTrackedInsertedHyperlinkCitations(exported: any, citationText: string): any[] {
+ const hyperlinkOwnsTrackedInsertion = findXmlNodes(
+ exported,
+ (node) => node.name === 'w:hyperlink' && collectXmlText(node) === citationText && hasXmlDescendant(node, 'w:ins'),
+ );
+ const trackedInsertionOwnsHyperlink = findXmlNodes(
+ exported,
+ (node) => node.name === 'w:ins' && collectXmlText(node) === citationText && hasXmlDescendant(node, 'w:hyperlink'),
+ );
+ return [...hyperlinkOwnsTrackedInsertion, ...trackedInsertionOwnsHyperlink];
+}
+
+function getDocumentRelationships(editor: Editor): any[] {
+ const rels = (editor.converter as any).convertedXml['word/_rels/document.xml.rels'];
+ return rels?.elements?.find((element: any) => element.name === 'Relationships')?.elements ?? [];
+}
+
// ---------------------------------------------------------------------------
// executeStructuralInsert
// ---------------------------------------------------------------------------
@@ -567,6 +616,84 @@ describe('mutations.apply structural steps', () => {
expect(dispatchedTr?.getMeta('skipTrackChanges')).not.toBe(true);
});
+ it('inserts SD-3563 citation hyperlinks as tracked inserted link text', async () => {
+ enableTrackedMode(editor);
+ const anchor = executeStructuralInsert(editor, {
+ content: { type: 'paragraph', content: [{ type: 'text', text: 'citation anchor' }] },
+ });
+ const citationText = '[27]';
+ const citationHref = 'https://www.bloomberg.com/example-citation';
+
+ const receipt = executePlan(editor, {
+ changeMode: 'tracked',
+ steps: [
+ {
+ id: 'step-structural-hyperlink-insert',
+ op: 'structural.insert',
+ where: { by: 'ref', ref: anchor.insertedBlockIds[0]! },
+ args: {
+ content: {
+ kind: 'paragraph',
+ paragraph: {
+ inlines: [
+ { kind: 'run', run: { text: 'See ' } },
+ {
+ kind: 'hyperlink',
+ hyperlink: {
+ href: citationHref,
+ inlines: [{ kind: 'run', run: { text: citationText } }],
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ });
+
+ expect(receipt.success).toBe(true);
+ const citationMarks = collectTextMarks(editor, citationText);
+ expect(citationMarks).toHaveLength(1);
+ expect(citationMarks[0]).toEqual(expect.arrayContaining(['link', 'trackInsert']));
+
+ const exported = await getExportedResultWithDocContent(editor.getJSON().content ?? []);
+ const insertedCitationLinks = findTrackedInsertedHyperlinkCitations(exported, citationText);
+
+ expect(insertedCitationLinks).toHaveLength(1);
+
+ await editor.converter.exportToDocx(
+ editor.getUpdatedJson(),
+ editor.schema,
+ (editor.storage.image as { media?: Record }).media ?? {},
+ false,
+ 'external',
+ [],
+ editor,
+ false,
+ null,
+ );
+
+ const [exportedCitationLink] = findTrackedInsertedHyperlinkCitations(
+ (editor.converter as any).convertedXml['word/document.xml'],
+ citationText,
+ );
+ const relationshipId = exportedCitationLink?.attributes?.['r:id'];
+ const relationship = getDocumentRelationships(editor).find(
+ (element) =>
+ element.attributes?.Id === relationshipId &&
+ element.attributes?.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
+ );
+
+ expect(relationshipId).toBeTruthy();
+ expect(relationship?.attributes).toEqual(
+ expect.objectContaining({
+ Target: citationHref,
+ TargetMode: 'External',
+ }),
+ );
+ });
+
it('passes tracked mode through structural.replace step execution', () => {
enableTrackedMode(editor);
const targetSeed = executeStructuralInsert(editor, {
From a906137948f06019b505bb2db1d1c72da991604e Mon Sep 17 00:00:00 2001
From: Andrii Orlov <120495135+andrii-harbour@users.noreply.github.com>
Date: Sat, 4 Jul 2026 03:01:11 +0200
Subject: [PATCH 5/6] LLM tools core preset (#264)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(document-api): tracked-change actions, comment export, list/numbering read model
Engine + Document API work backing the LLM-tools core preset:
- tracked-change side-targeted reject (decide `side` selector), tracked
w:pPrChange apply, tracked lists.attach
- comment + comment-reply export; comments on tracked changes persist on export
- blocks.list read model: paragraph indent projection and computed numbering
(marker/path/kind) so agents can see legal clause numbers
- list-item / list-sequence resolver hardening; table cell shading background
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(document-api): tighten trackChanges.decide id-target side selector
Addresses review findings on the side-targeted reject surface:
- id-target `side` runtime accepted `insert`/`delete` aliases the published
schema forbids (strict `inserted`/`deleted`). Drop the aliases so runtime
matches the contract. Range targets are unchanged.
- narrow the id-target `side` type to a new `ReplacementSide`
(`'inserted'|'deleted'`); it no longer advertises move-only `source`/
`destination` that always throw for id targets.
- decision-engine: a stale `side` selector on a change whose targeted half was
already resolved (only the other side survives as a standalone insertion/
deletion) fell through and silently resolved the surviving side. Fail closed
unless the standalone side matches the requested side. Only id targets set
selection.side, so range/all decisions are unaffected.
Adds 5 regression tests (3 decide-validation, 2 decision-engine).
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): drop unused destructure var in tracked-numbering path
`_existingChange` was flagged by @typescript-eslint/no-unused-vars as an ERROR
(the file's TS lint config does not honor the /^_/ ignore pattern for
rest-destructure siblings), failing CI lint. Snapshot the former paragraph
properties with an explicit delete instead.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(super-editor): surface tracked paragraph-property changes in the review API
Tracked numbering-attach / alignment changes (w:pPrChange) were export-only:
they round-tripped to Word but never appeared in trackChanges.list and could
not be accepted/rejected via trackChanges.decide, because they live on node
attrs (paragraphProperties.change), not marks, and both enumeration sites (the
review graph and the doc-api resolver) scan marks + structural rows only. To a
customer that reads as "tracked numbering silently failed".
Mirror the existing tableRow structural precedent for attr-based changes:
- pprChanges.ts enumerator walks blocks for a valid paragraphProperties.change
- review-graph projects each into a Formatting logical change (synthetic
whole-block segment + non-enumerable change.pprChange payload)
- decision-engine routes change.pprChange to planPprDecision before the
type-based branches (it is typed Formatting but has no mark): accept drops the
change record (numbering stays), reject restores the former properties;
applied via setNodeMarkup like clearRowTrackChange
- the doc-api resolver (groupTrackedChanges) appends them as formatting changes
so they surface in trackChanges.list; the node-stored record id doubles as the
public + command id, routing decide to the same review-graph change
New integration test proves list + accept + reject end-to-end; 1476 tracked-
change tests pass. Also drops an unused `retired` param (lint).
Co-Authored-By: Claude Opus 4.8 (1M context)
* style(super-editor): prettier-format the 3551 integration tests
These integration tests were committed without prettier formatting, failing the
CI format:check gate. No logic change.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): address pPrChange review — type, reject sync, schema
- review-graph: declare the optional `pprChange` flag on TrackedSegment so the
synthetic segment literal type-checks (fixes CI TS2353; mirrors `structural`)
- decision-engine: rejecting a tracked pPrChange now also syncs the TOP-LEVEL
numberingProperties + listRendering to the restored former state, not just
paragraphProperties — otherwise a rejected block still reads/renders as a
numbered list item in any path that doesn't re-run the numbering plugin
- contract: publish the `numbering` and `indent` fields on the blocks.list
output schema (they were returned + typed on BlockListEntry but omitted from
the closed JSON schema, so schema-driven clients dropped them); regenerated
reference docs + manifest
check:types 0 errors, lint 0 errors, 1234 tracked-change tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(painter-dom): align thick-border test with SD-3028 authored-width rule
border-utils.test.ts expected thick borders at max(width*2, 3), but SD-3028
deliberately paints `thick` at the authored w:sz width (no 2x, min 1px) — see
getBorderBandWidthPx + border-band.test.ts ("thick paints at the authored
width"). The painter test and the applyBorder comment were stale leftovers from
before that decision and failed on origin/main too; they only surfaced here
because doc-api/sdk changes trigger CI's `--project=!*super-editor*` vitest job.
Helper is unchanged. 91 border tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(document-api): clear cell `background` on cell-scoped clearShading
Shading a cell writes the `background` attr (the render/export source of truth),
but tablesClearShadingAdapter only deleted `background` on the table-scoped
path. A cell-scoped clear (incl. tables.setShading({ color: null })) removed
tableCellProperties.shading but left `background`, so the cell stayed shaded on
screen and on export while the receipt reported success. Delete `background` on
the cell clear path too (mirrors the set path + the table-scoped per-cell clear).
Adds a regression test.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): export tracked pPrChange with a Word-safe decimal w:id
A tracked pPrChange (tracked numbering/alignment) exported its w:id as the
internal change.id, which for API-created changes is a uuidv4 — but OOXML w:id
must be a decimal integer, so Word repairs/drops it and re-import can't match.
Imported pPrChanges already carry a decimal id (kept as-is); API-created UUIDs
are converted to a stable decimal in a high, allocator-clear range. Adds
decode-path tests (imported-decimal preserved; UUID → deterministic decimal in
range) + a decimal-id assertion on the numbering export test.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): make tracked pPr changes non-positional in the review graph
The synthetic pPr segment spans the whole block, so treating it positionally
caused three collateral bugs when a paragraph also had other content: a
text-range decide captured it (partial Formatting → CAPABILITY_UNAVAILABLE),
accepting/rejecting it detached unrelated comments in the block, and a pPr
change inside a tracked table was routed through the mark-based contained-child
planner (null-mark deref). Fix, one idea — pPr changes are resolved only by
id/all via planPprDecision, never positionally:
- keep the pPr segment off graph.segments / bySegmentId (review-graph)
- skip the resolvedRanges push for pPr decisions (no comment detach)
- skip pPr changes in the staying-table cascade
1435 tracked-change tests pass (list + accept + reject flow intact).
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): address SD-3551 review — tracked-change edge cases + test rigor
Review round 2 (PR #262, @caio-pizzol). Each item reproduced, fixed where
broken, and covered with a test:
1. One-sided replacement survivor: buildLogicalChange now downgrades a change
labeled "replacement" with only one side present to the plain insertion/
deletion it is — so the survivor of a side-targeted accept/reject resolves
normally instead of failing "replacement missing inserted or deleted side".
2. pPrChange inside a KEPT tracked table: the main staying-table cascade now
excludes pPr changes (matching the side-effect sweep), so they resolve via
planPprDecision by id instead of no-opping through the mark-based child
planner (pPr is attr-based, not an inline mark).
3. Tracked lists.attach with no user: guarded with ensureTrackedCapability so a
tracked pPrChange can no longer be stamped with a blank author (mirrors the
ins/del and lists.insert tracked paths).
4. Comment export: assertions tightened to exact count + zero empty comments so
a sidebar-only tracked-change row leaking as an empty comment is caught (the
hasCommentBody filter itself was already correct — verified).
5. pPrChange w:id: routed through the shared Word revision-id allocator
(threaded into the pPr export path) for doc-wide uniqueness; the FNV-1a hash
is now only a no-allocator fallback.
194 review-model tests + touched integration tests + check:types + lint green.
Co-Authored-By: Claude Opus 4.8
* fix(super-editor): flatten pPrChange on final export + cascade it when a table is accepted
Follow-ups from the automated review of the previous commit, plus test fixes:
- Final-doc export now flattens a tracked paragraph-property revision: when
isFinalDoc, drop the .change record so the "final" DOCX carries only the
accepted numbering/alignment (no pending w:pPrChange) — matching how the
ins/del translators strip their wrappers on isFinalDoc.
- Accepting a tracked table BY ID now resolves contained pPr changes too: the
staying-table side-effect sweep routes them through planPprDecision instead
of skipping them, so a reviewed table has no leftover numbering revisions.
(accept-all already handled this via the main loop.)
Also:
- Fix two unit tests broken by the earlier w:id-allocator threading: the
generate-paragraph-properties decode assertion uses objectContaining (the
call also carries the allocator + part path), and the lists.attach conformance
throwCase drives its mode-independent TARGET_NOT_FOUND in direct mode (the
tracked path's user guard would otherwise fire first on a no-user editor).
- Drop ticket/review-process tags from code comments and test names.
Full review-model + document-api-adapters suites, generate-paragraph-properties,
and the pPrChange translator all green; check:types + lint clean.
Co-Authored-By: Claude Opus 4.8
* fix(super-editor): drop synthetic tracked-change rows from comment export
comments.list() returns one projection row per tracked change so the
sidebar can render revisions beside real comments. Feeding those rows
into exportDocx({ comments }) turned every tracked change into a spurious
: the row carries the change excerpt as `text`, so the
body-presence filter let it through.
Identify synthetic rows by identity — commentId/id equals
trackedChangeLink.trackedChangeId — and exclude them. A genuine comment
anchored on a tracked change keeps its own distinct id, so it still
exports.
Add a regression test for the comments.list() -> exportDocx({ comments })
path, drop leftover console.log calls from the comment integration tests,
and assert the threaded reply bumps the document revision.
Co-Authored-By: Claude Opus 4.8
* perf(super-editor): large-document performance — headless linked-styles + visible read model
- skip whole-doc linked-style inline-CSS decorations when the editor runs
headless (view-only work); ~50s -> ~16s on a 38-page redline
- blocks.list returns the VISIBLE text model (skips tracked-deleted runs) so a
second edit to an already-edited block resolves offsets against the same
text the plan engine applies against — no more "Offset N out of range" drift
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(super-editor): blocks.list length/ref/preview use the visible text model
The visible read-model fix switched blocks.list `text` to visible but left
`textLength`, `isEmpty`, and the encoded block `ref` (segments[].end) on the
raw model via computeTextContentLength(node). For a redlined block that handed
out a whole-block ref ending past the visible text, so re-editing it through the
ref threw "text offset out of range" (the plan compiler resolves refs as
visible) — the very drift the visible read model set out to remove, only
half-applied. Compute length + preview on the visible model too. Adds two
regression tests (partial-delete ref end == visible length; fully-deleted block
reports isEmpty + no ref).
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(sdk): LLM-tools core preset — 3-tool surface, actions, windowed inspect
Ships the `core` preset (DEFAULT_PRESET stays `legacy`) exposing three
model-facing tools: superdoc_inspect, superdoc_perform_action (with an
`action` parameter), superdoc_execute_code.
- actions layer (formerly "recipes"): renamed throughout the SDK/CLI/MCP surface
- move_text reimplemented as tracked delete-then-insert (source deleted first so
the text search can't match the inserted copy), inheriting destination style —
no dedicated engine move op
- windowed + lean agent_inspect (blockOffset/blockLimit/omitEmptyBlocks/
dropTextPreview) so large documents fit the context budget
- Python parity (core preset + smoke), operation catalog, truthful receipts
Co-Authored-By: Claude Opus 4.8 (1M context)
* docs(ai): core-preset + llm-tools reference, execute-code + core-chat examples
AI-tools documentation and runnable examples for the core preset. Kept as a
separate commit so it can move to its own PR (docs/examples) if desired.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(sdk): clear lint errors in the core-preset surface
- actions.ts: `applied` is only property-mutated, never reassigned → const
(prefer-const error)
- product-action-smoke.mjs: the `fail` counter was incremented but never read
(no-unused-vars error); report it in the summary line, which also surfaces the
failure count the smoke was silently dropping
Co-Authored-By: Claude Opus 4.8 (1M context)
* style(cli): prettier-format core-preset CLI files
Committed without prettier formatting, failing the CI format:check gate.
No logic change.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(sdk): batch add_comments, move_table/delete_table, fix tracked changeMode threading
- add_comments (renamed from add_comment): comment many targets in ONE call via
selectors[]; avoids the model fanning out N concurrent add_comment tool calls.
- move_table: relocate a whole table in one call (wraps doc.tables.move).
- delete_table: remove an entire table in one call (wraps doc.blocks.delete).
- fix: attach_numbering / add_list_items / insert_list_items passed changeMode in
the input object, but lists.attach/insert read it from the second options arg —
so changeMode:"tracked" was silently dropped and no w:pPrChange was recorded.
Now passed in the options arg; tracked numbering is reviewable end-to-end.
- prompt + tool-hint updates for the new/renamed actions and the MOVE rule.
Co-Authored-By: Claude Opus 4.8
* chore(examples): actions-only core-chat demo — filter execute_code, fix bridge multi-arg, add .docx export
- server: advertise reads + actions only (filter superdoc_execute_code) and load
a demo-local actions-only system prompt (no code-execution guidance).
- doc-bridge: forward the FULL argument list to the browser — it was sending only
argList[0], silently dropping the 2nd options arg (e.g. { changeMode:'tracked' }).
- add an "Export .docx" button to round-trip the live document.
- system-prompt-actions.md: the core prompt with execute_code/scripting removed.
Co-Authored-By: Claude Opus 4.8
* feat(sdk): expand + fix core-preset action surface
Consolidate and extend the LLM-tools core preset actions:
- Fold insert_list_items into add_list_items; support anchorText|listOrdinal,
entries|items, RELATIVE levels incl. NEGATIVE (dedent toward the top level),
placement after the anchor's whole sub-tree (path-prefix, skips interleaved
non-numbered paragraphs so a new item can't steal siblings), and neighbour
style-match (fontFamily/fontSize/bold/color from the anchor).
- Add reply_to_comment (threads via comments.create + parentCommentId — the
document-API contract exposes no reply op), set_font_family, redo_changes,
and split_list (wraps doc.lists.split).
- Replace move_section (ordinal-only, real-headings-only) with move_range:
text-addressed range / visual section (auto-extends to the next heading-like
block), section-aware afterText/beforeText destination.
- delete_text: optional selector to scope deletions to one block; refuse an
unscoped whitespace-only find (the 500-target footgun).
- move_text: honour changeMode (direct by default, tracked on request) instead
of forcing tracked; direct requires afterText.
- Regenerate the advertised tool schema/hints/groups from the registry; update
the SDK + demo system prompts and the core-preset reference for the
renamed/added actions.
- Unit tests: 70 passing, covering the new and changed actions.
Co-Authored-By: Claude Opus 4.8
* fix(sdk): sync the system prompt with the 40-action registry
The shipped prompt's ACTIONS section had drifted badly from the action
registry: it documented 2 PHANTOM actions (insert_image_with_caption,
set_table_shading — hallucination bait: the model calls them and gets
"unknown action") and omitted 8 real ones (resolve_comments, redo_changes,
format_paragraph, move_text, style_table, set_paragraph_spacing,
insert_page_break, add_hyperlink). delete_text's note also predated its
selector scoping.
Sync both the SDK prompt and the demo actions-only prompt to the full
40-action registry, and add a drift-guard unit test that fails on any
missing or phantom per-action entry so the prompt can't silently drift
again.
Validated with an actions-only revision-fidelity eval run: 74/84
correctness (88%) vs the 73/84 baseline — no regression.
Co-Authored-By: Claude Opus 4.8
* feat(sdk): tool-surface configuration + provider-native formats
Two customer-facing controls on the core preset's advertised surface:
- excludeActions: remove actions from superdoc_perform_action — the enum,
the grouped description, and any argument properties only those actions
use all shrink together. Unknown names throw (typo protection);
excluding every action drops the tool entirely.
- excludeTools: remove whole tools (e.g. superdoc_execute_code).
Available on chooseTools/getTools in the Node SDK, threaded through the
CLI preset op (comma-separated flags), and exposed in the Python SDK
(choose_tools excludeActions/excludeTools, core preset kwargs) — verified
end-to-end Python -> CLI -> Node. Dispatch accepts the same lists as
defense-in-depth and refuses excluded calls. The legacy preset ignores
the options unchanged (covered by tests).
Provider formats: 'vercel' now emits the AI SDK's flat
{name, description, inputSchema} dialect (was openai-nested), matching
tool()/jsonSchema(); 'openai' stays Chat-Completions-nested, 'anthropic'
{name, description, input_schema} with cache_control on the last tool.
Format-shape tests lock all four providers; the demo/eval consumers read
inputSchema with a parameters fallback for older builds.
Co-Authored-By: Claude Opus 4.8
* fix(cli): repair pre-existing branch breaks caught by review-prep gates
- execute-code (and preset dispatch, in the previous commit) narrowed the
runtime-neutral OpenedRuntimeDocument to the v1 editor-backed handle —
openSessionDocument's return type lost `editor` in the main merge and
CLI typecheck failed (same guard legacy-compat's assertV1Opened uses).
- manual-command-allowlist test: the branch added `execute code` + the six
`preset *` commands/operations/files to the runtime allowlist without
updating the test's expected lists — CLI suite failed 3 tests.
- Python core-preset smoke: dispatched `insert_paragraph` (singular), an
action that never existed in the core set; fixed to insert_paragraphs.
CLI suite: 1373 pass / 0 fail. Python: 128 unit tests + live smoke pass.
Co-Authored-By: Claude Opus 4.8
* fix(sdk): honor tracked mode on every host + Codex review fixes
Codex pre-merge review findings, verified and fixed:
- P1 tracked inserts silently ran DIRECT on in-process hosts: the create
helpers (paragraph/heading/table/list-create/list-insert/toc/attach)
passed changeMode only in the input, which the in-process DocumentApi
(browser bridge, CLI preset dispatch, Python core) ignores — it reads
MutationOptions from the second arg, while the CLI-transport client
reads the input flag. Pass BOTH (each dialect ignores the other's copy)
so changeMode:"tracked" produces real tracked changes everywhere.
- P1 set_paragraph_spacing / insert_page_break / add_hyperlink advertised
changeMode but their v1 adapters reject tracked mode — removed the arg
from types/registry/hints/prompts and marked them "Direct edit".
- P1 move_text could delete the source and THEN fail on a bad destination
anchor (data loss reported as failure) — pre-flight now verifies both
spans exist before any mutation.
- P2 Python dispatch exclusion parity: doc.preset.dispatch accepts
excludeActions/excludeTools (CSV), threaded from the Python core preset
into the Node dispatch guard — verified end-to-end (excluded action and
tool refused, allowed action unaffected).
- P3 add_hyperlink receipt named doc.hyperlinks.insert; it calls wrap.
Tests: dual-dialect options capture, move_text pre-flight, no-changeMode
hints; 229 SDK + 1373 CLI tests green.
Co-Authored-By: Claude Opus 4.8
* perf(sdk): cap per-item receipt lists (token hygiene)
Receipts live in the conversation and are re-billed as prompt tokens on
every subsequent model turn (measured: 96% of eval spend is prompt-side).
Per-item actions (whole-body set_font_family, format_text on every
occurrence, batch add_comments) emitted one executedOperations /
selectedTargets entry per item — a 200-paragraph font change produced a
receipt costing thousands of tokens, forever.
Cap both lists at 8 entries in the compacted receipt and carry the true
totals in executedOperationCount / selectedTargetCount. The work itself is
unchanged — only the receipt shrinks.
Co-Authored-By: Claude Opus 4.8
* feat(sdk): exclusion-aware system prompt (narrows WITH the tool surface)
excludeActions/excludeTools narrowed the advertised tools but
getSystemPrompt still returned the full prompt — teaching the model
actions it could not call (wasted tokens + guaranteed schema rejections).
getSystemPrompt(preset, { excludeActions, excludeTools }) now narrows the
prompt with the SAME options as getTools:
- excluding superdoc_execute_code returns the hand-tuned ACTIONS-ONLY
variant, promoted from the demo into a bundled SDK asset
(system-prompt-actions-only.md — the exact prompt the actions-only
evals validated at 75/84). ~1.8K tokens/turn smaller than the full
prompt before caching.
- excludeActions drops the per-action documentation lines (single-line
entries enforced by the drift guard, which now covers BOTH prompt
variants); a paired line survives while either action remains callable.
- unknown names throw — same typo protection as getTools.
Threaded through the CLI preset op (CSV flags) and the Python SDK
(get_system_prompt kwargs) — verified end-to-end. The demo now consumes
the SDK asset via getSystemPrompt('core', {excludeTools}) instead of
carrying a local copy.
Co-Authored-By: Claude Opus 4.8
* feat(sdk): core preset is actions-only by default; drop excludeTools
Product decision: code execution (superdoc_execute_code) is WIP and ships
behind a future safety flag —
- the core preset now advertises TWO tools (superdoc_inspect,
superdoc_perform_action); execute_code is no longer in getTools output
or the catalog, but remains dispatchable for SDK callers.
- getSystemPrompt('core') serves the actions-only prompt (the variant the
evals validate; ~1.8K tokens/turn smaller). The code-inclusive prompt
stays bundled, unserved, for the future opt-in. The MCP prompt no longer
mentions execute_code.
- the excludeTools option is removed everywhere it was threaded (getTools,
getSystemPrompt, chooseTools, dispatch guard, CLI flags, Python kwargs) —
not needed yet; excludeActions stays.
Demo simplified to the preset defaults (no local tool filtering, no prompt
options). Tests updated: 235 Node + 1373 CLI + 128 Python + live smoke.
Co-Authored-By: Claude Opus 4.8
* refactor(sdk): simplification-review cleanup
From the Codex + internal simplification review:
- Dead code: unused _systemPromptCache; 6 orphan ACTION_ARG_SCHEMA entries
left by the removed insert_image_with_caption phantom.
- Stale docs/comments: core preset headers and descriptions said
"3-tool / 29 verbs" (it is 2 advertised tools / 40 actions), presets.ts
said v1 ships only legacy, Python descriptor/docstrings matched.
- Coherence: getSystemPromptForProvider now accepts excludeActions so the
provider-shaped prompt narrows with the tool surface on every path; CLI
help mentions --excludeActions.
- Integrator types exported from the package root: BoundDocApi (the
doc-handle contract dispatch expects), ActionName, AgentReceipt, and the
preset option/result types.
- matchOneBlock builds its inline payload via inlineLookFromRow (single
source for "inline look"), applying only the delta vs the created block.
Larger refactors from the review (receipt harness, format-range
consolidation, prompt base+addendum, table-family helper, replace_text
path fold, CLI session plumbing, Python proxy dedupe) are logged as
follow-ups — each is eval-gated or too broad for pre-review.
Co-Authored-By: Claude Opus 4.8
* fix(cli): silence no-relative-packages in the contract export script
export-sdk-contract.ts deliberately imports document-api SOURCE (it runs
pre-build, and the package's exports map exposes neither ./src/* nor
./scripts/*, so the alias form the rule suggests would not resolve).
Justified inline disables; CLI lint is now 0 errors and the script still
produces the 426-operation contract.
Co-Authored-By: Claude Opus 4.8
* fix(sdk): unbreak CI lint — dynamic import in the product-action smoke
CI lints pre-build, so the smoke script's static import of ../dist/index.js
could not resolve there (import-x/no-unresolved error) while passing locally
where dist exists. An inline disable would flip to an "unused directive"
warning locally and get stripped by --fix, re-breaking CI — so use a
top-level-await dynamic import instead: env-independent lint, identical
runtime behavior (verified: script imports and runs against the built dist).
Co-Authored-By: Claude Opus 4.8
* fix(lint): ignore built dist/ paths in import-x/no-unresolved
The previous attempt (dynamic import) did not survive CI: the rule checks
dynamic import() specifiers too. Root cause stands — CI lints pre-build,
so scripts that exercise the BUILT package (../dist/index.js) cannot
resolve there while resolving fine locally.
Fix it where the config already fixes the same class: the rule's ignore
list has '^\..*/generated/' for codegen artifacts not in git; add
'^\..*/dist/' for built output on the same reasoning. The smoke script
goes back to a plain static import. Verified by linting with dist/
removed (the CI condition): clean both with and without the build.
Co-Authored-By: Claude Opus 4.8
* fix(sdk): exempt the branch's CLI-only ops in contract-integrity tests
The contract-integrity suite exempts CLI-only operations from the
doc-backed success/failure-schema invariant, but its exemption set
predated this branch's CLI-only additions: doc.executeCode and the six
doc.preset.* proxy ops. The two mutating ones (executeCode, preset
dispatch) tripped the invariant and failed CI SDK / validate.
Same drift pattern as the manual-command-allowlist test fixed earlier —
the runtime lists gained the ops, the test's mirror list did not.
Codegen suite: 58 pass locally after a full artifact regen.
Co-Authored-By: Claude Opus 4.8
* chore: strip docs and example apps from PR — shipping separately
- Revert apps/docs changes to main (docs land in a dedicated PR; current
drafts still said 'three tools' and covered the WIP execute_code surface)
- Remove examples/ai/core-chat-demo and examples/ai/execute-code-agent
(kept as local dev harnesses, not part of this PR)
- Prune the corresponding pnpm-lock importers; remaining lockfile delta vs
main is only the CLI's @superdoc-dev/sdk workspace link
- Repoint the Python core-preset smoke at a tracked super-editor fixture
instead of the demo app fixture (smoke re-run: PASSED)
Co-Authored-By: Claude Fable 5
* ci(superdoc): build Node SDK before CLI tests
apps/cli/src/lib/preset-ops.ts imports @superdoc-dev/sdk (the core preset
proxy), so the cli-tests job needs the SDK's dist built after install —
same invocation ci-sdk.yml uses. Reproduced locally: host tests fail with
'Cannot find module @superdoc-dev/sdk' without dist, pass with it.
Co-Authored-By: Claude Fable 5
* fix(sdk): distinguish unreadable prompt assets from missing ones
readPromptFile swallowed every readFile error and reported
TOOLS_ASSET_NOT_FOUND, misclassifying permission/IO failures (EACCES,
EISDIR, transient IO) as a missing asset. Now only ENOENT falls through
to the next layout candidate; any other failure throws
TOOLS_ASSET_UNREADABLE with the underlying cause in details.
Addresses Qodo review finding #2 on PR #264.
Co-Authored-By: Claude Fable 5
* ci(superdoc): generate SDK sources before building it in cli-tests
The Node SDK's src/generated/ client is gitignored (produced by
generate:all), so the SDK build added for cli-tests failed on a fresh
checkout with TS2307 on ../generated/client.js. Run generate:all first,
mirroring ci-sdk.yml's install → generate → build sequence. Verified
locally from a cleaned generated dir: generate:all → SDK build → SDK
238/238 + CLI host 7/7.
Co-Authored-By: Claude Fable 5
* fix(sdk): thread exclude_actions through Python dispatch; complete ActionArgs surface
Codex round-3 review fixes:
- Python dispatch_superdoc_tool/_async now expose exclude_actions and
forward it to the preset dispatch guard (parity with Node); legacy
dispatch accepts-and-ignores it like its other core-only kwargs, and
the PresetDescriptor protocol declares the kwarg
- ActionArgs union gains the 9 newer action-args types (convert_list,
attach_numbering, split_list, format_text, format_paragraph,
apply_style, move_text, undo/redo_changes); agent barrel re-exports
the full public set
- Scrub stale text: catalog comment said 3 public tools + excludeTools,
test header/property referenced removed excludeTools, CLI example used
nonexistent insert_paragraph, smoke docstring named the unserved
system-prompt.md
Gates: node typecheck + 238 SDK tests + build, 131 python tests, python
smoke E2E, 1753 CLI tests — all green.
Co-Authored-By: Claude Fable 5
* test(sdk): gate CI on the full core-preset test surface
Review follow-up (PR #264): the new SDK/python suites and the product
smoke were never wired into CI, and the smoke scored 0/84 because it
dispatched without preset:'core' (default legacy has no
superdoc_perform_action).
- product-action-smoke: dispatch through the core preset; refresh stale
mock actions (insert_paragraphs, color_text→format_text) and replace
the removed insert_image_with_caption tasks with TOC coverage → 84/84
- sdk-validate: run the full Node SDK unit tree (238 tests), python
pytest suite (131, uv fallback on dev machines), and the 84-task smoke
- ci-sdk.yml (+ subtree mirror): install pytest for the validate job
Co-Authored-By: Claude Fable 5
* fix(sdk): blockOrdinal targets the right block on the workflow path
Review follow-up (PR #264): blockOrdinal requests are 1-based
(parseOrdinal rejects < 1) but the workflow doc-index keys blocks on the
raw doc-api block.ordinal, which is 0-based — so blockOrdinal:1 silently
edited the SECOND block via the list/text/structure workflow tools. Add
the missing -1 at the lookup (mirrors the agent-selector resolver).
Also normalize the model-facing snapshot block ordinal to 1-based (both
ingestion points) so superdoc_inspect displays the same convention the
selectors accept — it previously echoed the doc-api's 0-based value while
every sibling ordinal kind (paragraph/heading/table) was 1-based.
New workflow-resolve tests pin 1..N coverage, first-block resolution, and
no-wrap on out-of-range.
Co-Authored-By: Claude Fable 5
* fix(sdk): reply_to_comment sends a contract-valid create payload
Review follow-up (PR #264): two defects made the verb effectively
non-functional —
- the single-segment anchor omitted the 'text' kind discriminator; every
oneOf branch of comments.create's target requires one, so the common
case hard-failed with VALIDATION_ERROR
- the threading key was parentCommentId, but the contract param is
parentId; the SDK→CLI transport silently drops unknown keys, so even
successful replies landed as unthreaded top-level comments
The mock's create capture had the same destructure blind spot
({text, parentCommentId} only) which is how the wrong key passed tests;
it now records raw payloads and the test asserts the exact contract
shape (parentId present, parentCommentId absent, kind/blockId/range).
Co-Authored-By: Claude Fable 5
* fix(sdk): honor tracked changeMode on the append_list placement path
Review follow-up (PR #264): appendListAtPlacement created the item
paragraphs with no changeMode in either dialect, so changeMode:'tracked'
with a positional placement produced untracked direct edits while the
receipt reported ok. The fromParagraphs list conversion
(createListFromParagraphRange) also only set the input-dialect key, so
in-process hosts (browser bridge, CLI preset dispatch, Python core) ran
it untracked; same for the add_list_items ghost-normalization path.
All three sites now pass changeMode in both dialects (input key + 2nd
MutationOptions arg — see executeCreateParagraph). Regression test pins
both channels on the placement path; mock lists.create now supports the
{from,to} range form and captures its options arg.
Co-Authored-By: Claude Fable 5
* fix(sdk): rewrite_block never fabricates replacement text
Review follow-up (PR #264): preserveShortTitleMeaning silently replaced
a tracked-mode rewrite of any short title-like block with canned
boilerplate ('This states the same thing in plainer English...')
whenever the requested text reused fewer than two of the original's
keywords — invented redline content on the flagship tracked-changes
path. Removed the wrapper and its whole helper cluster; the caller's
text now lands verbatim.
Kept normalizeTitleLikeRewriteText (actions.ts), which is
content-preserving: it only re-cases the quoted ALL-CAPS original inside
the rewrite. Both behaviors pinned by tests.
Co-Authored-By: Claude Fable 5
* fix(sdk): move_range refuses ranges it would flatten
Review follow-up (PR #264): move_range recreates blocks as plain
paragraph/heading text and force-deletes the originals — a table (or
list/image) inside the range collapsed to its text preview with the
original destroyed, silently. The execute step now refuses any range
containing a non-paragraph/heading block BEFORE mutating, with a
teaching error naming the offending blocks and suggesting move_table /
a narrower range. Prompt lines updated (both files) so the model knows
the constraint up front.
True structural relocation (preserving node subtrees and inline marks)
is follow-up work; this closes the silent-data-loss hole.
Co-Authored-By: Claude Fable 5
* docs(document-engine): regenerate sdks.mdx operations table
Review follow-up (PR #264): generate:all rewrites this generated table
from the contract, which this branch extends (doc.executeCode +
doc.preset.* CLI ops). Committing the regenerated artifact so a clean
generate:all leaves the tree unchanged. (Prose documentation for the
core preset ships in the SD-3553 docs PR.)
Co-Authored-By: Claude Fable 5
* fix(sdk): embed prompts so native CLI binaries can serve them
Review follow-up (PR #264): bun-compiled binaries resolve import.meta.url
inside bun's virtual filesystem, so readPromptFile's on-disk candidates
never exist there — preset get-system-prompt failed with
TOOLS_ASSET_NOT_FOUND in every published native CLI package and in the
Python wheels' embedded companion CLI (which Python's core preset
proxies through).
src/prompts/*.md are now also compiled in via a generated TS module
(scripts/embed-prompts.mjs, committed + regenerated on every build);
readPromptFile stays filesystem-first and falls back to the embedded
copy when all candidates fail. Drift test pins the module to the .md
sources; fallback test covers the no-candidates path. Verified against
a real bun --compile binary: preset get-system-prompt --preset core now
returns the prompt.
Co-Authored-By: Claude Fable 5
* chore(sdk): exempt embedded-prompts.generated.ts from prettier
The commit hook reformatted the generated module, which the next
embed-prompts.mjs run would revert — permanent churn. Generated file,
generator-owned formatting.
Co-Authored-By: Claude Fable 5
* fix(cli): preset-dispatched execute_code gets the same crash rollback
Review follow-up (PR #264): the 'execute code' command snapshot-and-
restores when a script mutates and then throws, but the preset-dispatch
shim (doc.preset.dispatch → superdoc_execute_code — the path Python's
core preset and SDK preset.dispatch use) called the raw runner, so crash
debris persisted into the session and the next save.
The envelope now lives in lib/execute-code-rollback.ts and both paths
share it: crash → document restored to its pre-script state, receipt
marked rolledBack, nothing persists (preset dispatch also ignores the
restore-transaction's revision bump). Success/read-only behavior is
byte-identical to before. Tests cover the envelope directly plus the
reviewer's repro end-to-end through runPresetDispatch('core',
'superdoc_execute_code', ...).
Co-Authored-By: Claude Fable 5
* refactor(sdk): drop unused list-create helpers
executeListCreateFromParagraph / executeListInsert were never called —
the placement paths thread changeMode inline now. Their unused-var lint
warnings were exactly the noise that masked the append_list tracked-mode
gap during review.
Co-Authored-By: Claude Fable 5
* test(sdk): keep e2e host tests out of the validate sweep; surface check output
CI SDK validate failed on the new full-tree sweep for two reasons this
fixes/exposes:
- request-timeout-ms.e2e.test.ts spawns a live CLI host; in CI that
resolves to the published platform binary rather than the branch
build. e2e files are dev-local now (the cli-tests job covers host
behavior against the branch build); all plain unit files still run.
- check failures printed only 'Command failed: ' — the pytest
failure detail was swallowed. Failing checks now print the command's
stdout/stderr tail.
Co-Authored-By: Claude Fable 5
* test(sdk): deselect Linux-CI-fragile mock-host pipe tests from validate
The surfaced pytest output pinpointed the CI SDK failures: 4 pre-existing
test_transport.py async large-response/overflow tests whose mock-host
child dies mid-response on Linux runners ('Host process disconnected').
They pass locally and predate this branch — deselected in the validate
gate, kept for local runs. Follow-up: harden the mock host on CI.
Co-Authored-By: Claude Fable 5
* feat(mcp): serve the core preset over MCP (MCP_PRESET=core)
The MCP server only supported the legacy intent tools; MCP_PRESET=core
exited with 'unknown preset'. It now registers the SDK core preset's two
advertised tools (superdoc_inspect, superdoc_perform_action) straight
from the preset catalog — schemas can't drift from the SDK surface — and
dispatches through the SDK preset dispatcher against the session's
in-process DocumentApi (same host dialect as CLI preset dispatch).
Instructions switch to the SDK's MCP-flavored core prompt.
superdoc_execute_code stays unreachable over MCP. Legacy remains the
default and is untouched.
Verified: protocol integration test (list → open → inspect →
perform_action receipt → text present) plus a live run of the built
dist bundle. 42/42 MCP tests green.
Co-Authored-By: Claude Fable 5
* fix(sdk): reply_to_comment threads for real — no target, dual parent keys
The eval surfaced the actual engine contract: comments.create REJECTS a
reply that carries any target ('Cannot combine parentCommentId with
target') — the thread inherits the parent's anchor. Both prior attempts
passed the parent's segments as a target, so every reply failed.
Now the create sends only {text, parentId, parentCommentId}: parentId is
the contract/transport param (the CLI reverses it after parsing);
parentCommentId is what in-process hosts (MCP server, browser bridge)
read. Verified end-to-end against the eval's own fixture: receipt ok,
reply present as a second w:comment in the saved docx.
Also: convert_list's lists.setType now passes tracked mode in both
dialects (same gap class as append_list).
Co-Authored-By: Claude Fable 5
* fix(document-api): comments.create accepts the contract param parentId
Live-demo trace: in-process hosts (browser bridge, MCP, CLI preset
dispatch) strict-validate comments.create input and rejected parentId
with 'Unknown field' — while parentId IS the operation's public contract
param (the CLI already renames it to parentCommentId after parsing, and
the transport drops the engine-side name). Replies therefore worked over
the SDK transport but failed on every in-process host.
validateCreateCommentInput now normalizes the alias (parentId →
parentCommentId, erroring if both are present and disagree), so the
SDK's dual-key reply payload works on every host. Covered by
document-api unit tests and an MCP protocol test replaying the demo
flow (add comment → reply → thread visible).
Co-Authored-By: Claude Fable 5
* fix(sdk): attach_numbering tracked mode survives the CLI transport
Deterministic repro from the numbering-001 eval probe: the attach call
carried changeMode only in the 2nd MutationOptions arg, which the CLI
transport does not encode — over the wire the attach ran direct and no
w:pPrChange was recorded (receipt still said ok because the numbering
itself landed). Raw lists.attach with input.changeMode produced
pPrChange=2, isolating the gap to this call site.
Now dual-dialect like every other mutation call; regression test pins
both channels via the mock's new attach capture. Verified end-to-end:
the agent-path repro now saves pPrChange=2.
Co-Authored-By: Claude Fable 5
* fix: address CodeRabbit findings on MCP annotations + comments input type
- superdoc_perform_action no longer advertises destructiveHint:false over
MCP — the action surface includes destructive verbs (delete_table,
delete_text, replace_text), so clients must not treat it as
additive-only and skip confirmations
- CommentsCreateInput now declares the parentId contract alias so typed
callers don't need a cast to use the public param name
Co-Authored-By: Claude Fable 5
* refactor(sdk): single core system prompt; real MCP instructions
- Drop the unserved code-inclusive prompt variant (Code-Act guidance
returns with the execute_code safety-flag work; keeping a 28K shadow
prompt in sync bought nothing today)
- system-prompt-actions-only.md becomes system-prompt.md — it IS the
core system prompt; served bytes unchanged (rename only, no eval
needed), drift guard now covers the single file
- mcp-prompt.md was a one-line stub; it is now a real MCP instructions
document (session lifecycle, inspect-first workflow, receipts,
tracked-changes guidance) mirroring the legacy MCP prompt's structure
Co-Authored-By: Claude Fable 5
* feat(sdk): createAgentToolkit — tools, prompt, and dispatch coherent by construction
excludeActions previously relied on the caller passing the SAME list to
chooseTools, getSystemPrompt, and dispatchSuperDocTool — forget one and
an excluded action lingers in the prompt (or executes on dispatch).
createAgentToolkit takes one options object and returns {tools, meta,
systemPrompt, dispatch} with the preset + exclusions applied to all
three; the dispatcher is pre-bound with the exclusion guard.
Python parity: create_agent_toolkit returns the same surface with
dispatch/dispatch_async closures. The legacy preset ignores exclusion
options everywhere, matching the standalone functions — legacy callers
see identical tools and prompt through the toolkit.
Co-Authored-By: Claude Fable 5
* docs(sdk): align the MOVE prompt rule with move_range's content guard
CodeRabbit caught the contradiction: the MOVE overview still promised
move_range handles whole visual sections 'with ALL content', while the
guard refuses ranges containing tables/lists/images — inviting refused
attempts on mixed-content sections (the refused-retry thrash the eval
measured). The rule now states the piecewise strategy up front. Also
'nodeIds' → 'node IDs' in the MCP prompt.
Co-Authored-By: Claude Fable 5
* fix(sdk): close PR 264 review gaps (#346)
---------
Co-authored-by: Claude Opus 4.8 (1M context)
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Note: this ports only the public subtree changes from a mixed source commit (81 public paths, 2 non-public paths ignored).
Ported-From-Source-Repo: superdoc/orbit
Ported-From-Source-Commit: e86a7bbd9d1c9defb6b014927d9a02f92deceff4
Ported-Public-Prefix: superdoc/public
---
.github/workflows/ci-sdk.yml | 3 +
.github/workflows/ci-superdoc.yml | 6 +
.prettierignore | 1 +
apps/cli/package.json | 3 +-
apps/cli/scripts/export-sdk-contract.ts | 6 +
apps/cli/src/__tests__/cli.test.ts | 53 +
apps/cli/src/__tests__/host.test.ts | 58 +
.../lib/execute-code-rollback.test.ts | 128 +
.../lib/manual-command-allowlist.test.ts | 18 +
.../lib/operation-runtime-metadata.test.ts | 2 +-
.../src/cli/cli-only-operation-definitions.ts | 134 +
apps/cli/src/cli/commands.ts | 34 +
apps/cli/src/cli/operation-params.ts | 148 +
apps/cli/src/cli/types.ts | 7 +
apps/cli/src/commands/execute-code.ts | 143 +
apps/cli/src/commands/legacy-compat.ts | 5 +-
apps/cli/src/commands/preset.ts | 325 +
apps/cli/src/host/invoke.ts | 3 +
apps/cli/src/host/server.ts | 1 +
apps/cli/src/index.ts | 22 +-
apps/cli/src/lib/execute-code-rollback.ts | 117 +
apps/cli/src/lib/execute-code.ts | 387 +
apps/cli/src/lib/legacy-operation-dispatch.ts | 16 +
apps/cli/src/lib/manual-command-allowlist.ts | 14 +
.../cli/src/lib/operation-runtime-metadata.ts | 70 +
apps/cli/src/lib/preset-ops.ts | 132 +
apps/docs/document-engine/sdks.mdx | 22 +-
apps/mcp/package.json | 1 +
apps/mcp/src/__tests__/protocol-core.test.ts | 143 +
.../src/__tests__/server-preset-env.test.ts | 13 +-
apps/mcp/src/generated/catalog.ts | 14 +
apps/mcp/src/server.ts | 38 +-
apps/mcp/src/tools/core.ts | 88 +
apps/mcp/src/tools/index.ts | 11 +-
eslint.config.mjs | 1 +
.../src/comments/comments.test.ts | 27 +
.../document-api/src/comments/comments.ts | 21 +
.../src/__tests__/contract-integrity.test.ts | 9 +
packages/sdk/langs/node/package.json | 3 +-
.../sdk/langs/node/scripts/embed-prompts.mjs | 44 +
.../node/scripts/product-action-smoke.mjs | 847 +++
.../langs/node/src/__tests__/actions.test.ts | 2097 ++++++
.../node/src/__tests__/agent-runtime.test.ts | 394 +
.../langs/node/src/__tests__/cache.test.ts | 82 +-
.../src/__tests__/handle-and-tools.test.ts | 93 +-
.../src/__tests__/operation-catalog.test.ts | 90 +
.../langs/node/src/__tests__/presets.test.ts | 448 +-
.../src/__tests__/workflow-resolve.test.ts | 120 +
.../node/src/action-primitives/doc-index.ts | 338 +
.../node/src/action-primitives/engine.ts | 302 +
.../node/src/action-primitives/receipt.ts | 95 +
.../node/src/action-primitives/resolve.ts | 327 +
.../src/action-primitives/session-cache.ts | 54 +
.../action-primitives/tools/list-transform.ts | 884 +++
.../tools/structure-insert.ts | 1816 +++++
.../action-primitives/tools/text-transform.ts | 884 +++
.../langs/node/src/action-primitives/types.ts | 58 +
packages/sdk/langs/node/src/agent/actions.ts | 6444 +++++++++++++++++
packages/sdk/langs/node/src/agent/catalog.ts | 569 ++
.../sdk/langs/node/src/agent/doc-snapshot.ts | 947 +++
packages/sdk/langs/node/src/agent/index.ts | 128 +
packages/sdk/langs/node/src/agent/ir.ts | 337 +
.../langs/node/src/agent/operation-catalog.ts | 520 ++
packages/sdk/langs/node/src/agent/runtime.ts | 635 ++
.../node/src/embedded-prompts.generated.ts | 10 +
packages/sdk/langs/node/src/index.ts | 28 +-
packages/sdk/langs/node/src/presets.ts | 84 +-
packages/sdk/langs/node/src/presets/core.ts | 531 ++
.../sdk/langs/node/src/prompts/mcp-prompt.md | 23 +
.../langs/node/src/prompts/system-prompt.md | 108 +
.../node/src/runtime/transport-common.ts | 9 +
packages/sdk/langs/node/src/tools.ts | 120 +-
.../sdk/langs/python/superdoc/__init__.py | 6 +-
.../langs/python/superdoc/presets/__init__.py | 48 +-
.../sdk/langs/python/superdoc/presets/core.py | 330 +
.../langs/python/superdoc/presets/legacy.py | 12 +-
.../sdk/langs/python/superdoc/tools_api.py | 112 +-
.../langs/python/tests/smoke_core_preset.py | 160 +
.../sdk/langs/python/tests/test_presets.py | 138 +
packages/sdk/scripts/sdk-validate.mjs | 53 +-
pnpm-lock.yaml | 8 +-
81 files changed, 22369 insertions(+), 191 deletions(-)
create mode 100644 apps/cli/src/__tests__/lib/execute-code-rollback.test.ts
create mode 100644 apps/cli/src/commands/execute-code.ts
create mode 100644 apps/cli/src/commands/preset.ts
create mode 100644 apps/cli/src/lib/execute-code-rollback.ts
create mode 100644 apps/cli/src/lib/execute-code.ts
create mode 100644 apps/cli/src/lib/preset-ops.ts
create mode 100644 apps/mcp/src/__tests__/protocol-core.test.ts
create mode 100644 apps/mcp/src/tools/core.ts
create mode 100644 packages/sdk/langs/node/scripts/embed-prompts.mjs
create mode 100644 packages/sdk/langs/node/scripts/product-action-smoke.mjs
create mode 100644 packages/sdk/langs/node/src/__tests__/actions.test.ts
create mode 100644 packages/sdk/langs/node/src/__tests__/agent-runtime.test.ts
create mode 100644 packages/sdk/langs/node/src/__tests__/operation-catalog.test.ts
create mode 100644 packages/sdk/langs/node/src/__tests__/workflow-resolve.test.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/doc-index.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/engine.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/receipt.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/resolve.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/session-cache.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/tools/list-transform.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/tools/structure-insert.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/tools/text-transform.ts
create mode 100644 packages/sdk/langs/node/src/action-primitives/types.ts
create mode 100644 packages/sdk/langs/node/src/agent/actions.ts
create mode 100644 packages/sdk/langs/node/src/agent/catalog.ts
create mode 100644 packages/sdk/langs/node/src/agent/doc-snapshot.ts
create mode 100644 packages/sdk/langs/node/src/agent/index.ts
create mode 100644 packages/sdk/langs/node/src/agent/ir.ts
create mode 100644 packages/sdk/langs/node/src/agent/operation-catalog.ts
create mode 100644 packages/sdk/langs/node/src/agent/runtime.ts
create mode 100644 packages/sdk/langs/node/src/embedded-prompts.generated.ts
create mode 100644 packages/sdk/langs/node/src/presets/core.ts
create mode 100644 packages/sdk/langs/node/src/prompts/mcp-prompt.md
create mode 100644 packages/sdk/langs/node/src/prompts/system-prompt.md
create mode 100644 packages/sdk/langs/python/superdoc/presets/core.py
create mode 100644 packages/sdk/langs/python/tests/smoke_core_preset.py
diff --git a/.github/workflows/ci-sdk.yml b/.github/workflows/ci-sdk.yml
index 146e83af53..97f4ea95b6 100644
--- a/.github/workflows/ci-sdk.yml
+++ b/.github/workflows/ci-sdk.yml
@@ -39,6 +39,9 @@ jobs:
with:
python-version: '3.12'
+ - name: Install Python test dependencies
+ run: python3 -m pip install pytest pytest-asyncio
+
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml
index 079308bbbf..756353bf37 100644
--- a/.github/workflows/ci-superdoc.yml
+++ b/.github/workflows/ci-superdoc.yml
@@ -247,6 +247,12 @@ jobs:
- name: Build superdoc (CLI runtime dependency)
run: pnpm run build:superdoc
+ - name: Generate SDK sources (gitignored generated client)
+ run: pnpm run generate:all
+
+ - name: Build Node SDK (CLI preset ops dependency)
+ run: pnpm --prefix packages/sdk/langs/node run build
+
- name: Run CLI tests
run: pnpm run test:cli
diff --git a/.prettierignore b/.prettierignore
index 3def11d1bb..d19017c7b3 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -36,3 +36,4 @@ tests/consumer-typecheck/tsconfig.matrix.json
# Markdown files
*.md
+packages/sdk/langs/node/src/embedded-prompts.generated.ts
diff --git a/apps/cli/package.json b/apps/cli/package.json
index b11f8e4008..ac7c62e524 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -13,7 +13,7 @@
"predev": "node scripts/ensure-superdoc-build.js",
"dev": "bun run src/index.ts",
"prebuild": "node scripts/ensure-superdoc-build.js",
- "build": "bun build src/index.ts --outdir dist --target node --format esm && node scripts/copy-runtime-assets.js",
+ "build": "bun build src/index.ts --outdir dist --target node --format esm && node scripts/copy-runtime-assets.js && rm -rf dist/prompts && mkdir -p dist/prompts && cp ../../packages/sdk/langs/node/src/prompts/*.md dist/prompts/",
"prebuild:native": "node scripts/ensure-superdoc-build.js",
"build:native": "bun build src/index.ts --compile --outfile dist/superdoc && node scripts/copy-runtime-assets.js",
"build:native:all": "node scripts/build-native-cli.js --all",
@@ -45,6 +45,7 @@
"yjs": "catalog:"
},
"devDependencies": {
+ "@superdoc-dev/sdk": "workspace:*",
"@superdoc/document-api": "workspace:*",
"@superdoc/super-editor": "workspace:*",
"@types/bun": "catalog:",
diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts
index 016553594f..50de875c4a 100644
--- a/apps/cli/scripts/export-sdk-contract.ts
+++ b/apps/cli/scripts/export-sdk-contract.ts
@@ -15,8 +15,14 @@ import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { tmpdir } from 'node:os';
+// This build script deliberately reaches into document-api SOURCE (it runs
+// pre-build, and the package's `exports` map exposes neither ./src/* nor
+// ./scripts/*, so the alias form the lint rule suggests would not resolve).
+// eslint-disable-next-line import-x/no-relative-packages
import { COMMAND_CATALOG } from '../../../packages/document-api/src/contract/command-catalog.ts';
+// eslint-disable-next-line import-x/no-relative-packages
import { INTENT_GROUP_META } from '../../../packages/document-api/src/contract/operation-definitions.ts';
+// eslint-disable-next-line import-x/no-relative-packages
import { buildContractSnapshot } from '../../../packages/document-api/scripts/lib/contract-snapshot.ts';
import { ensureDocumentApiBuild } from './ensure-superdoc-build.js';
diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts
index 60f5f76441..9b48408a25 100644
--- a/apps/cli/src/__tests__/cli.test.ts
+++ b/apps/cli/src/__tests__/cli.test.ts
@@ -2352,6 +2352,59 @@ describe('superdoc CLI', () => {
expect(closeResult.code).toBe(0);
});
+ test('expected revision protects execute code and preset dispatch', async () => {
+ await runCli(['open', SAMPLE_DOC, '--session', 'llm-guard']);
+
+ const advance = await runCli([
+ 'execute',
+ 'code',
+ '--session',
+ 'llm-guard',
+ '--code',
+ "doc.create.paragraph({ text: 'REVISION_GUARD_BASELINE' }); return 'ok';",
+ ]);
+ expect(advance.code).toBe(0);
+
+ const staleExecute = await runCli([
+ 'execute',
+ 'code',
+ '--session',
+ 'llm-guard',
+ '--expected-revision',
+ '0',
+ '--code',
+ "doc.create.paragraph({ text: 'STALE_EXECUTE_CODE' }); return 'ok';",
+ ]);
+ expect(staleExecute.code).toBe(1);
+ expect(parseJsonOutput(staleExecute).error.code).toBe('REVISION_MISMATCH');
+
+ const stalePreset = await runCli([
+ 'preset',
+ 'dispatch',
+ '--session',
+ 'llm-guard',
+ '--preset',
+ 'core',
+ '--tool-name',
+ 'superdoc_perform_action',
+ '--args-json',
+ JSON.stringify({ action: 'insert_paragraphs', text: 'STALE_PRESET_DISPATCH' }),
+ '--expected-revision',
+ '0',
+ ]);
+ expect(stalePreset.code).toBe(1);
+ expect(parseJsonOutput(stalePreset).error.code).toBe('REVISION_MISMATCH');
+
+ const textResult = await runCli(['get-text', '--session', 'llm-guard']);
+ expect(textResult.code).toBe(0);
+ expect(textResult.stdout).toContain('REVISION_GUARD_BASELINE');
+ expect(textResult.stdout).not.toContain('STALE_EXECUTE_CODE');
+ expect(textResult.stdout).not.toContain('STALE_PRESET_DISPATCH');
+
+ const closeResult = await runCli(['close', '--discard', '--session', 'llm-guard']);
+ expect(closeResult.code).toBe(0);
+ });
+
test('session use switches default session', async () => {
const alphaOpen = await runCli(['open', SAMPLE_DOC, '--session', 'alpha']);
expect(alphaOpen.code).toBe(0);
diff --git a/apps/cli/src/__tests__/host.test.ts b/apps/cli/src/__tests__/host.test.ts
index 6738887a39..fca8f5e5a8 100644
--- a/apps/cli/src/__tests__/host.test.ts
+++ b/apps/cli/src/__tests__/host.test.ts
@@ -563,6 +563,64 @@ describe('CLI host mode', () => {
HOST_TEST_TIMEOUT_MS,
);
+ test(
+ 'host request timeout blocks delayed execute-code mutations',
+ async () => {
+ const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-'));
+ cleanup.push(stateDir);
+ await mkdir(stateDir, { recursive: true });
+
+ const docCopy = path.join(stateDir, 'doc.docx');
+ await copyFile(await resolveSourceDocFixture(), docCopy);
+
+ const host = launchHost(stateDir, ['--request-timeout-ms', '1000']);
+ const sessionId = 'timeout-guard';
+ const marker = 'HOST_TIMEOUT_LATE_MUTATION';
+
+ const open = await host.request('cli.invoke', {
+ argv: ['open', docCopy, '--session', sessionId],
+ stdinBase64: '',
+ });
+ expect(open.error).toBeUndefined();
+
+ const delayedMutation = await host.request('cli.invoke', {
+ argv: [
+ 'preset',
+ 'dispatch',
+ '--session',
+ sessionId,
+ '--preset',
+ 'core',
+ '--tool-name',
+ 'superdoc_execute_code',
+ '--args-json',
+ JSON.stringify({
+ code: `await new Promise((resolve) => setTimeout(resolve, 1200)); doc.create.paragraph({ text: '${marker}' }); return 'late';`,
+ }),
+ ],
+ stdinBase64: '',
+ });
+ if (delayedMutation.error) {
+ expect(delayedMutation.error.code).toBe(-32011);
+ } else {
+ const payload = delayedMutation.result as { data?: { ok?: boolean; error?: { message?: string } } };
+ expect(payload.data?.ok).toBe(false);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ const text = await host.request('cli.invoke', {
+ argv: ['get-text', '--session', sessionId],
+ stdinBase64: '',
+ });
+ expect(text.error).toBeUndefined();
+ expect(JSON.stringify(text.result)).not.toContain(marker);
+
+ await host.shutdown();
+ },
+ HOST_TEST_TIMEOUT_MS,
+ );
+
test(
'rejects --request-timeout-ms with a non-numeric value',
async () => {
diff --git a/apps/cli/src/__tests__/lib/execute-code-rollback.test.ts b/apps/cli/src/__tests__/lib/execute-code-rollback.test.ts
new file mode 100644
index 0000000000..e06a38b83a
--- /dev/null
+++ b/apps/cli/src/__tests__/lib/execute-code-rollback.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, test } from 'bun:test';
+import { executeCodeWithRollback } from '../../lib/execute-code-rollback';
+import { runPresetDispatch } from '../../lib/preset-ops';
+import type { EditorWithDoc } from '../../lib/document';
+
+/**
+ * Fake v1 editor: a doc with a real revision counter plus the ProseMirror-ish
+ * state/dispatch surface the rollback envelope uses (state.doc snapshot,
+ * state.tr.replaceWith, dispatch). Restoring bumps the revision like a real
+ * editor transaction would — the envelope must normalize that away.
+ */
+function makeFakeEditor() {
+ let revision = 0;
+ let content = 'ORIGINAL';
+ const doc = {
+ info: () => ({ revision: String(revision) }),
+ mutate: (next: string) => {
+ content = next;
+ revision += 1;
+ return { ok: true };
+ },
+ };
+ const snapshot = { content: { size: 10, restoreTo: 'ORIGINAL' } };
+ const editor = {
+ doc,
+ state: {
+ doc: snapshot,
+ get tr() {
+ return {
+ replaceWith: (_from: number, _to: number, restored: { restoreTo: string }) => ({ restored }),
+ };
+ },
+ },
+ dispatch: (tr: { restored: { restoreTo: string } }) => {
+ content = tr.restored.restoreTo;
+ revision += 1; // a restore transaction still advances the editor revision
+ },
+ };
+ return {
+ editor: editor as unknown as EditorWithDoc,
+ getContent: () => content,
+ };
+}
+
+describe('executeCodeWithRollback', () => {
+ test('crash after mutation restores the document and does not count as mutated', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const outcome = await executeCodeWithRollback(editor, "doc.mutate('BROKEN'); throw new Error('boom');");
+ expect(outcome.result.ok).toBe(false);
+ expect(outcome.result.rolledBack).toBe(true);
+ expect(outcome.mutated).toBe(false);
+ expect(outcome.revisionAfter).toBe(outcome.revisionBefore);
+ expect(getContent()).toBe('ORIGINAL');
+ });
+
+ test('successful script keeps its mutations and reports mutated', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const outcome = await executeCodeWithRollback(editor, "doc.mutate('CHANGED'); return 'done';");
+ expect(outcome.result.ok).toBe(true);
+ expect(outcome.result.rolledBack).toBeUndefined();
+ expect(outcome.mutated).toBe(true);
+ expect(getContent()).toBe('CHANGED');
+ });
+
+ test('read-only script mutates nothing', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const outcome = await executeCodeWithRollback(editor, 'return doc.info().revision;');
+ expect(outcome.result.ok).toBe(true);
+ expect(outcome.mutated).toBe(false);
+ expect(getContent()).toBe('ORIGINAL');
+ });
+
+ test('timeout blocks late document calls from mutating the session', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const outcome = await executeCodeWithRollback(
+ editor,
+ "await new Promise((resolve) => setTimeout(resolve, 25)); doc.mutate('LATE'); return 'late';",
+ { timeoutMs: 5 },
+ );
+
+ expect(outcome.result.ok).toBe(false);
+ expect(outcome.mutated).toBe(false);
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ expect(getContent()).toBe('ORIGINAL');
+ });
+
+ test('delayed work after a successful return cannot mutate the session', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const outcome = await executeCodeWithRollback(
+ editor,
+ "setTimeout(() => doc.mutate('LATE'), 25); return 'scheduled';",
+ { timeoutMs: 100 },
+ );
+
+ expect(outcome.result.ok).toBe(true);
+ expect(outcome.mutated).toBe(false);
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ expect(getContent()).toBe('ORIGINAL');
+ });
+});
+
+describe('preset dispatch superdoc_execute_code (shim parity)', () => {
+ test('a crashing script dispatched through the core preset is rolled back', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const result = (await runPresetDispatch(
+ 'core',
+ 'superdoc_execute_code',
+ { code: "doc.mutate('BROKEN'); throw new Error('boom');" },
+ editor,
+ )) as { ok?: boolean; rolledBack?: boolean };
+ expect(result.ok).toBe(false);
+ expect(result.rolledBack).toBe(true);
+ // Reviewer repro (PR #264): the paragraph used to survive the crash.
+ expect(getContent()).toBe('ORIGINAL');
+ });
+
+ test('a successful script dispatched through the core preset keeps its edits', async () => {
+ const { editor, getContent } = makeFakeEditor();
+ const result = (await runPresetDispatch(
+ 'core',
+ 'superdoc_execute_code',
+ { code: "doc.mutate('CHANGED'); return 'ok';" },
+ editor,
+ )) as { ok?: boolean };
+ expect(result.ok).toBe(true);
+ expect(getContent()).toBe('CHANGED');
+ });
+});
diff --git a/apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts b/apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts
index 47ee878154..3534c08064 100644
--- a/apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts
+++ b/apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts
@@ -13,11 +13,20 @@ describe('manual command allowlist', () => {
'close',
'insert tab',
'insert line-break',
+ // CLI/SDK-only session op: model-authored JS against the live editor.doc.
+ 'execute code',
'session list',
'session save',
'session close',
'session set-default',
'session use',
+ // LLM-tools preset proxies (cross-language surface for the Python SDK).
+ 'preset list',
+ 'preset get-catalog',
+ 'preset get-tools',
+ 'preset get-system-prompt',
+ 'preset get-mcp-prompt',
+ 'preset dispatch',
]);
});
@@ -28,10 +37,17 @@ describe('manual command allowlist', () => {
'doc.close',
'doc.insertTab',
'doc.insertLineBreak',
+ 'doc.executeCode',
'doc.session.list',
'doc.session.save',
'doc.session.close',
'doc.session.setDefault',
+ 'doc.preset.list',
+ 'doc.preset.getCatalog',
+ 'doc.preset.getTools',
+ 'doc.preset.getSystemPrompt',
+ 'doc.preset.getMcpPrompt',
+ 'doc.preset.dispatch',
]);
});
@@ -44,10 +60,12 @@ describe('manual command allowlist', () => {
expect(actual).toEqual([
'call.ts',
'close.ts',
+ 'execute-code.ts',
'insert-inline-special.ts',
'install.ts',
'legacy-compat.ts',
'open.ts',
+ 'preset.ts',
'save.ts',
'session-close.ts',
'session-list.ts',
diff --git a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts
index f0dfbbecfe..ae590adaee 100644
--- a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts
+++ b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts
@@ -112,7 +112,7 @@ describe('operation runtime metadata', () => {
expect(openOptions.map((o) => o.name)).not.toContain('runtime');
});
- test('final recipe-provider parity operations expose their promoted CLI params', () => {
+ test('final action-provider parity operations expose their promoted CLI params', () => {
const blocksListMeta = CLI_OPERATION_METADATA['doc.blocks.list'];
expect(blocksListMeta.params.find((p) => p.name === 'in')?.flag).toBe('in-json');
diff --git a/apps/cli/src/cli/cli-only-operation-definitions.ts b/apps/cli/src/cli/cli-only-operation-definitions.ts
index 22e0b72cb7..5db3c53c60 100644
--- a/apps/cli/src/cli/cli-only-operation-definitions.ts
+++ b/apps/cli/src/cli/cli-only-operation-definitions.ts
@@ -208,6 +208,48 @@ export const CLI_ONLY_OPERATION_DEFINITIONS: Record = {
'superdoc insert line-break --target-json \'{"kind":"selection","start":{"kind":"text","blockId":"abc123","offset":5},"end":{"kind":"text","blockId":"abc123","offset":5}}\'',
],
},
+ executeCode: {
+ mutates: true,
+ examples: [
+ "superdoc execute code --session my-session --code 'const t = doc.getText({}); return { length: t.length };'",
+ 'superdoc execute code --session my-session # large code via stdin',
+ ],
+ },
status: { mutates: false, examples: ['superdoc status'] },
describe: { mutates: false, examples: ['superdoc describe'] },
describeCommand: { mutates: false, examples: ['superdoc describe command find'] },
@@ -93,6 +100,33 @@ const CLI_ONLY_OVERRIDES: Record = {
mutates: true,
examples: ['superdoc session set-default my-session', 'superdoc session set-default --session my-session'],
},
+ 'preset.list': { mutates: false, examples: ['superdoc preset list'] },
+ 'preset.getCatalog': {
+ mutates: false,
+ examples: ['superdoc preset get-catalog', 'superdoc preset get-catalog --preset core'],
+ },
+ 'preset.getTools': {
+ mutates: false,
+ examples: [
+ 'superdoc preset get-tools --provider openai',
+ 'superdoc preset get-tools --provider anthropic --preset core --cache',
+ ],
+ },
+ 'preset.getSystemPrompt': {
+ mutates: false,
+ examples: ['superdoc preset get-system-prompt', 'superdoc preset get-system-prompt --preset core'],
+ },
+ 'preset.getMcpPrompt': {
+ mutates: false,
+ examples: ['superdoc preset get-mcp-prompt', 'superdoc preset get-mcp-prompt --preset core'],
+ },
+ 'preset.dispatch': {
+ mutates: true,
+ examples: [
+ 'superdoc preset dispatch --session my-session --tool-name superdoc_inspect --args-json \'{"countsOnly":true}\' --preset core',
+ 'superdoc preset dispatch --session my-session --tool-name superdoc_perform_action --args-json \'{"action":"insert_paragraphs","text":"Hello"}\' --preset core',
+ ],
+ },
};
function buildCliOnlySpec(cliOnlyOp: string, cliOpId: CliOperationId): CliCommandSpec {
diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts
index 06c3f4ff91..5741314794 100644
--- a/apps/cli/src/cli/operation-params.ts
+++ b/apps/cli/src/cli/operation-params.ts
@@ -1188,6 +1188,23 @@ const CLI_ONLY_METADATA: Record = {
mutuallyExclusive: [['target', 'ref']],
},
},
+ 'doc.executeCode': {
+ command: 'execute code',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [
+ SESSION_PARAM,
+ EXPECTED_REVISION_PARAM,
+ {
+ name: 'code',
+ kind: 'flag',
+ type: 'string',
+ description:
+ 'JavaScript body run as an async function with `doc` (synchronous Document API — do NOT await) and `console` injected. Omit to pass code on stdin.',
+ },
+ ],
+ constraints: null,
+ },
'doc.status': {
command: 'status',
positionalParams: [],
@@ -1245,6 +1262,137 @@ const CLI_ONLY_METADATA: Record = {
params: [{ name: 'sessionId', kind: 'doc', type: 'string', required: true }],
constraints: null,
},
+ 'doc.preset.list': {
+ command: 'preset list',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [],
+ constraints: null,
+ },
+ 'doc.preset.getCatalog': {
+ command: 'preset get-catalog',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [
+ {
+ name: 'preset',
+ kind: 'flag',
+ type: 'string',
+ description: 'Preset id. Defaults to the SDK default preset (legacy).',
+ },
+ ],
+ constraints: null,
+ },
+ 'doc.preset.getTools': {
+ command: 'preset get-tools',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [
+ {
+ name: 'provider',
+ kind: 'flag',
+ type: 'string',
+ required: true,
+ schema: {
+ oneOf: [{ const: 'openai' }, { const: 'anthropic' }, { const: 'vercel' }, { const: 'generic' }],
+ } as CliTypeSpec,
+ description: 'Tool provider format: openai | anthropic | vercel | generic.',
+ },
+ {
+ name: 'preset',
+ kind: 'flag',
+ type: 'string',
+ description: 'Preset id. Defaults to the SDK default preset (legacy).',
+ },
+ {
+ name: 'cache',
+ kind: 'flag',
+ type: 'boolean',
+ description: 'Apply provider-specific prompt-cache markers.',
+ },
+ {
+ name: 'excludeActions',
+ kind: 'flag',
+ type: 'string',
+ description:
+ 'Comma-separated action names to remove from the advertised action surface (core preset). Unknown names fail.',
+ },
+ ],
+ constraints: null,
+ },
+ 'doc.preset.getSystemPrompt': {
+ command: 'preset get-system-prompt',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [
+ {
+ name: 'preset',
+ kind: 'flag',
+ type: 'string',
+ description: 'Preset id. Defaults to the SDK default preset (legacy).',
+ },
+ {
+ name: 'excludeActions',
+ kind: 'flag',
+ type: 'string',
+ description:
+ 'Comma-separated action names whose per-action documentation lines are dropped from the prompt (core preset).',
+ },
+ ],
+ constraints: null,
+ },
+ 'doc.preset.getMcpPrompt': {
+ command: 'preset get-mcp-prompt',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [
+ {
+ name: 'preset',
+ kind: 'flag',
+ type: 'string',
+ description: 'Preset id. Defaults to the SDK default preset (legacy).',
+ },
+ ],
+ constraints: null,
+ },
+ 'doc.preset.dispatch': {
+ command: 'preset dispatch',
+ positionalParams: [],
+ docRequirement: 'none',
+ params: [
+ SESSION_PARAM,
+ EXPECTED_REVISION_PARAM,
+ {
+ name: 'toolName',
+ kind: 'flag',
+ flag: 'tool-name',
+ type: 'string',
+ required: true,
+ description: 'Tool name to dispatch (e.g. superdoc_perform_action, superdoc_inspect, superdoc_execute_code).',
+ },
+ {
+ name: 'args',
+ kind: 'jsonFlag',
+ flag: 'args-json',
+ type: 'json',
+ description: 'Tool arguments as a JSON object (matches the preset tool input schema).',
+ },
+ {
+ name: 'excludeActions',
+ kind: 'flag',
+ type: 'string',
+ description:
+ 'Comma-separated action names to refuse at dispatch (defense-in-depth for a narrowed tool surface).',
+ },
+ {
+ name: 'preset',
+ kind: 'flag',
+ type: 'string',
+ description: 'Preset id. Defaults to the SDK default preset (legacy).',
+ },
+ ],
+ constraints: null,
+ },
};
// ---------------------------------------------------------------------------
diff --git a/apps/cli/src/cli/types.ts b/apps/cli/src/cli/types.ts
index 93a485fc91..bcdb6e2d5b 100644
--- a/apps/cli/src/cli/types.ts
+++ b/apps/cli/src/cli/types.ts
@@ -139,6 +139,7 @@ export const CLI_ONLY_OPERATIONS = [
'close',
'insertTab',
'insertLineBreak',
+ 'executeCode',
'status',
'describe',
'describeCommand',
@@ -146,6 +147,12 @@ export const CLI_ONLY_OPERATIONS = [
'session.save',
'session.close',
'session.setDefault',
+ 'preset.list',
+ 'preset.getCatalog',
+ 'preset.getTools',
+ 'preset.getSystemPrompt',
+ 'preset.getMcpPrompt',
+ 'preset.dispatch',
] as const;
export type CliOnlyOperation = (typeof CLI_ONLY_OPERATIONS)[number];
diff --git a/apps/cli/src/commands/execute-code.ts b/apps/cli/src/commands/execute-code.ts
new file mode 100644
index 0000000000..155b779115
--- /dev/null
+++ b/apps/cli/src/commands/execute-code.ts
@@ -0,0 +1,143 @@
+/**
+ * `execute_code` command runner — CLI/SDK-only session operation.
+ *
+ * Resolves the active session's live editor and runs model-authored JavaScript
+ * IN-HOST against the SYNCHRONOUS `editor.doc` (a real DocumentApi) via
+ * {@link executeCode}. This is the in-host counterpart to the eval provider's
+ * old async, provider-side execute_code: the doc is synchronous, so the model's
+ * code calls `doc.*` WITHOUT await.
+ *
+ * Session resolution mirrors the inline-special insert runner: open (or reuse,
+ * in host mode, via the SessionPool) the session document, run the script,
+ * then — if the script mutated the doc and we are in host mode — mark the
+ * session dirty and bump the revision so persistence behaves like any other
+ * mutating op. Read-only scripts (no revision change) leave the session clean.
+ */
+
+import { getStringOption } from '../lib/args';
+import type { ParsedArgs } from '../lib/args';
+import { assertExpectedRevision, markContextUpdated, withActiveContext, writeContextMetadata } from '../lib/context';
+import { exportToPath, openSessionDocument, type OpenedDocument } from '../lib/document';
+import { CliError } from '../lib/errors';
+import { executeCodeWithRollback } from '../lib/execute-code-rollback';
+import { parseOperationArgs } from '../lib/operation-args';
+import { syncCollaborativeSessionSnapshot } from '../lib/session-collab';
+import type { CliOperationId } from '../cli';
+import type { CommandContext, CommandExecution } from '../lib/types';
+
+const COMMAND_NAME = 'execute code';
+const OPERATION_ID = 'doc.executeCode' as CliOperationId;
+
+function scriptTimeoutMs(commandTimeoutMs: number | undefined): number | undefined {
+ return commandTimeoutMs == null ? undefined : Math.max(1, commandTimeoutMs - 1);
+}
+
+/** Extract the model code from CLI options or the stdin payload (large code). */
+async function resolveCode(parsed: ParsedArgs, context: CommandContext): Promise {
+ const inline = getStringOption(parsed, 'code');
+ if (typeof inline === 'string' && inline.length > 0) {
+ return inline;
+ }
+
+ // Large code is delivered via the host stdin channel (stdinBase64, 32MiB cap).
+ const bytes = await context.io.readStdinBytes();
+ if (bytes.byteLength === 0) {
+ throw new CliError('MISSING_REQUIRED', `${COMMAND_NAME}: missing code. Pass --code or provide it on stdin.`);
+ }
+ return Buffer.from(bytes).toString('utf8');
+}
+
+export async function runExecuteCode(tokens: string[], context: CommandContext): Promise {
+ const { parsed, args, help } = parseOperationArgs(OPERATION_ID, tokens, { commandName: COMMAND_NAME });
+
+ if (help) {
+ return {
+ command: COMMAND_NAME,
+ data: {
+ usage: [
+ `superdoc execute code --session --code ''`,
+ `superdoc execute code --session (code on stdin)`,
+ ],
+ },
+ pretty: [
+ 'Usage:',
+ ` superdoc execute code --session --code ''`,
+ ` superdoc execute code --session (code on stdin)`,
+ ].join('\n'),
+ };
+ }
+
+ const code = await resolveCode(parsed, context);
+ const expectedRevision = typeof args.expectedRevision === 'number' ? args.expectedRevision : undefined;
+
+ return withActiveContext(
+ context.io,
+ COMMAND_NAME,
+ async ({ metadata, paths }) => {
+ assertExpectedRevision(metadata, expectedRevision);
+
+ const isHostMode = context.executionMode === 'host' && context.sessionPool != null;
+ const openedRuntime = await openSessionDocument(paths.workingDocPath, context.io, metadata, {
+ sessionId: context.sessionId ?? metadata.contextId,
+ executionMode: context.executionMode,
+ sessionPool: context.sessionPool,
+ });
+ // execute-code runs model JS against the live v1 editor.doc — a v1-only
+ // path. openSessionDocument returns the runtime-neutral handle; narrow it
+ // (same defensive guard as legacy-compat's assertV1Opened).
+ if (!('editor' in openedRuntime)) {
+ throw new CliError('COMMAND_FAILED', `${COMMAND_NAME}: expected a v1 editor-backed session.`);
+ }
+ const opened = openedRuntime as OpenedDocument;
+
+ try {
+ // Shared snapshot→run→rollback envelope (same semantics as the
+ // preset-dispatch shim): a script that mutates and then throws is
+ // restored; only surviving changes count as a mutation.
+ const { result, mutated } = await executeCodeWithRollback(opened.editor, code, {
+ timeoutMs: scriptTimeoutMs(context.timeoutMs),
+ });
+
+ let updatedMetadata = metadata;
+
+ if (mutated) {
+ if (isHostMode) {
+ context.sessionPool!.markDirty(metadata.contextId);
+ updatedMetadata = markContextUpdated(context.io, metadata, {
+ dirty: true,
+ revision: metadata.revision + 1,
+ });
+ await writeContextMetadata(paths, updatedMetadata);
+ context.sessionPool!.updateMetadataRevision(metadata.contextId, updatedMetadata.revision);
+ } else if (metadata.sessionType === 'collab') {
+ const synced = await syncCollaborativeSessionSnapshot(context.io, metadata, paths, opened.editor);
+ updatedMetadata = synced.updatedMetadata;
+ } else {
+ // Oneshot local: export the mutated working doc to disk.
+ await exportToPath(opened.editor, paths.workingDocPath, true);
+ updatedMetadata = markContextUpdated(context.io, metadata, {
+ dirty: true,
+ revision: metadata.revision + 1,
+ });
+ await writeContextMetadata(paths, updatedMetadata);
+ }
+ }
+
+ return {
+ command: COMMAND_NAME,
+ data: {
+ ...result,
+ context: { dirty: updatedMetadata.dirty, revision: updatedMetadata.revision, mutated },
+ },
+ pretty: result.ok
+ ? `execute_code: ok (revision ${updatedMetadata.revision}${mutated ? ', mutated' : ''})`
+ : `execute_code: failed — ${result.error.name}: ${result.error.message}`,
+ };
+ } finally {
+ opened.dispose();
+ }
+ },
+ context.sessionId,
+ context.executionMode,
+ );
+}
diff --git a/apps/cli/src/commands/legacy-compat.ts b/apps/cli/src/commands/legacy-compat.ts
index 2a783b6fdd..6a33101982 100644
--- a/apps/cli/src/commands/legacy-compat.ts
+++ b/apps/cli/src/commands/legacy-compat.ts
@@ -631,7 +631,7 @@ async function runLegacyInsertInlineSpecial(
context: CommandContext,
): Promise {
const commandSpec = COMMAND_BY_KIND[kind];
- const { parsed, help } = parseOperationArgs(commandSpec.operationId, tokens, {
+ const { parsed, args, help } = parseOperationArgs(commandSpec.operationId, tokens, {
commandName: `insert ${kind === 'tab' ? 'tab' : 'line-break'}`,
});
@@ -655,8 +655,7 @@ async function runLegacyInsertInlineSpecial(
const { doc } = resolveDocArg(parsed, `insert ${COMMAND_BY_KIND[kind].label}`);
const outPath = getStringOption(parsed, 'out');
const force = getBooleanOption(parsed, 'force');
- const expectedRevisionRaw = parsed.options.expectedRevision;
- const expectedRevision = typeof expectedRevisionRaw === 'number' ? expectedRevisionRaw : undefined;
+ const expectedRevision = typeof args.expectedRevision === 'number' ? args.expectedRevision : undefined;
const commandName = kind === 'tab' ? 'insert tab' : 'insert line-break';
const input = parsed.options as Record;
diff --git a/apps/cli/src/commands/preset.ts b/apps/cli/src/commands/preset.ts
new file mode 100644
index 0000000000..b91774badf
--- /dev/null
+++ b/apps/cli/src/commands/preset.ts
@@ -0,0 +1,325 @@
+/**
+ * `preset.*` command runners — CLI/SDK-only operations that proxy the Node SDK
+ * preset registry (see `lib/preset-ops.ts`).
+ *
+ * Six commands:
+ * - `preset list` — registered preset ids + the default
+ * - `preset get-catalog` — full catalog for a preset
+ * - `preset get-tools` — provider-shaped tool array + cache strategy
+ * - `preset get-system-prompt`— preset's SDK system prompt
+ * - `preset get-mcp-prompt` — preset's MCP system prompt
+ * - `preset dispatch` — route a tool call into the preset against the
+ * live session-bound `editor.doc`
+ *
+ * The first five are session-less reads (no session resolution, no metadata
+ * touch). `preset dispatch` mirrors the `execute_code` command: bind to the
+ * active session, run the dispatch against `editor.doc`, then if the doc
+ * mutated, mark the session dirty and bump the revision.
+ */
+
+import { getBooleanOption, getStringOption, resolveJsonInput, type ParsedArgs } from '../lib/args';
+import { assertExpectedRevision, markContextUpdated, withActiveContext, writeContextMetadata } from '../lib/context';
+import { exportToPath, openSessionDocument, type EditorWithDoc, type OpenedDocument } from '../lib/document';
+import { CliError } from '../lib/errors';
+import { parseOperationArgs } from '../lib/operation-args';
+import {
+ runPresetDispatch,
+ runPresetGetCatalog,
+ runPresetGetMcpPrompt,
+ runPresetGetSystemPrompt,
+ runPresetGetTools,
+ runPresetList,
+} from '../lib/preset-ops';
+import { syncCollaborativeSessionSnapshot } from '../lib/session-collab';
+import type { CliOperationId } from '../cli';
+import type { CommandContext, CommandExecution } from '../lib/types';
+
+// Comma-separated so both the CLI flag form and the JSON-RPC (Python) form
+// carry exclusions as one plain string; action/tool names contain no commas.
+function parseCsvOption(parsed: ParsedArgs, name: string): readonly string[] | undefined {
+ const value = getStringOption(parsed, name);
+ if (typeof value !== 'string') return undefined;
+ const entries = value
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+ return entries.length > 0 ? entries : undefined;
+}
+
+const TOOL_PROVIDERS = ['openai', 'anthropic', 'vercel', 'generic'] as const;
+type ToolProvider = (typeof TOOL_PROVIDERS)[number];
+
+function isToolProvider(value: unknown): value is ToolProvider {
+ return typeof value === 'string' && (TOOL_PROVIDERS as readonly string[]).includes(value);
+}
+
+function presetFromArg(parsed: ParsedArgs): string | undefined {
+ const raw = getStringOption(parsed, 'preset');
+ if (typeof raw !== 'string' || raw.length === 0) return undefined;
+ return raw;
+}
+
+// ---------------------------------------------------------------------------
+// preset list
+// ---------------------------------------------------------------------------
+
+export async function runPresetListCommand(tokens: string[], _context: CommandContext): Promise {
+ const { help } = parseOperationArgs('doc.preset.list' as CliOperationId, tokens, { commandName: 'preset list' });
+ if (help) {
+ return {
+ command: 'preset list',
+ data: { usage: ['superdoc preset list'] },
+ pretty: 'Usage: superdoc preset list',
+ };
+ }
+ const data = runPresetList();
+ return {
+ command: 'preset list',
+ data,
+ pretty: `presets: ${data.presets.join(', ')} (default: ${data.defaultPreset})`,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// preset get-catalog
+// ---------------------------------------------------------------------------
+
+export async function runPresetGetCatalogCommand(
+ tokens: string[],
+ _context: CommandContext,
+): Promise {
+ const { parsed, help } = parseOperationArgs('doc.preset.getCatalog' as CliOperationId, tokens, {
+ commandName: 'preset get-catalog',
+ });
+ if (help) {
+ return {
+ command: 'preset get-catalog',
+ data: { usage: ['superdoc preset get-catalog [--preset ]'] },
+ pretty: 'Usage: superdoc preset get-catalog [--preset ]',
+ };
+ }
+ const catalog = await runPresetGetCatalog(presetFromArg(parsed));
+ return {
+ command: 'preset get-catalog',
+ data: catalog as unknown as Record,
+ pretty: `catalog: ${catalog.toolCount} tool(s), contractVersion=${catalog.contractVersion}`,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// preset get-tools
+// ---------------------------------------------------------------------------
+
+export async function runPresetGetToolsCommand(tokens: string[], _context: CommandContext): Promise {
+ const { parsed, help } = parseOperationArgs('doc.preset.getTools' as CliOperationId, tokens, {
+ commandName: 'preset get-tools',
+ });
+ if (help) {
+ return {
+ command: 'preset get-tools',
+ data: {
+ usage: [
+ 'superdoc preset get-tools --provider