From 447ca024f0c40e912654f0f4c531bfb35df6260a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 19:40:39 -0700 Subject: [PATCH 1/3] fix(doc-api): gate textStyle attrs and sync reference coverage --- .../reference/_generated-manifest.json | 2 +- .../capabilities-adapter.test.ts | 40 ++++++++++++++++- .../capabilities-adapter.ts | 9 +++- .../format-adapter.test.ts | 41 ++++++++++++++++++ .../plan-engine/plan-wrappers.ts | 14 +++++- .../tests/formatting/inline-formatting.ts | 43 +++++++++++++++++++ 6 files changed, 144 insertions(+), 5 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 61571a90d6..cb498a211a 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -962,5 +962,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "278f3d13c4cb49be6d084639559a5de63cd623606bc046c4abea69815fcbbe1c" + "sourceHash": "42294181d9125c3dfb3525be01eb6c645c3a18d511bd95d678d6661920490721" } diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index 29d0484317..ecb7ce0ec6 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -370,7 +370,18 @@ describe('getDocumentApiCapabilities', () => { underline: { create: vi.fn(() => ({ type: 'underline' })) }, strike: { create: vi.fn(() => ({ type: 'strike' })) }, highlight: { create: vi.fn(() => ({ type: 'highlight' })) }, - textStyle: { create: vi.fn(() => ({ type: 'textStyle' })) }, + textStyle: { + create: vi.fn(() => ({ type: 'textStyle' })), + attrs: { + color: { default: null }, + fontSize: { default: null }, + fontFamily: { default: null }, + letterSpacing: { default: null }, + vertAlign: { default: null }, + position: { default: null }, + textTransform: { default: null }, + }, + }, [TrackFormatMarkName]: { create: vi.fn(() => ({ type: TrackFormatMarkName })) }, ...overrides.marks, }, @@ -401,6 +412,33 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.format.supportedInlineProperties.bold.available).toBe(true); }); + it('reports a textStyle-backed property as unavailable when its attr is missing from textStyle (SD-2074)', () => { + const capabilities = getDocumentApiCapabilities( + makeFormatEditor({ + marks: { + textStyle: { + create: vi.fn(() => ({ type: 'textStyle' })), + attrs: { + color: { default: null }, + fontSize: { default: null }, + fontFamily: { default: null }, + vertAlign: { default: null }, + position: { default: null }, + textTransform: { default: null }, + // letterSpacing deliberately omitted — simulates missing LetterSpacing extension + }, + }, + }, + }), + ); + expect(capabilities.format.supportedInlineProperties.letterSpacing.available).toBe(false); + expect(capabilities.operations['format.letterSpacing'].available).toBe(false); + expect(capabilities.operations['format.letterSpacing'].reasons).toContain('OPERATION_UNAVAILABLE'); + // Other textStyle-backed properties remain available + expect(capabilities.format.supportedInlineProperties.color.available).toBe(true); + expect(capabilities.format.supportedInlineProperties.fontSize.available).toBe(true); + }); + it('reports run-attribute properties as unavailable when the run node is missing', () => { const capabilities = getDocumentApiCapabilities(makeFormatEditor({ nodes: { run: undefined } })); expect(capabilities.format.supportedInlineProperties.rFonts.available).toBe(false); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 710b9ddbbe..2dab024db0 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -340,8 +340,13 @@ function getInlineAliasKey(operationId: OperationId): InlineRunPatchKey | undefi function isInlinePropertyAvailable(editor: Editor, property: InlinePropertyRegistryEntry): boolean { if (property.storage === 'mark') { if (property.carrier.storage !== 'mark') return false; - const markName = property.carrier.markName === 'textStyle' ? 'textStyle' : property.carrier.markName; - return hasMarkCapability(editor, markName); + const markName = property.carrier.markName; + if (!hasMarkCapability(editor, markName)) return false; + if (markName === 'textStyle' && property.carrier.textStyleAttr) { + const markAttrs = editor.schema.marks.textStyle?.attrs; + if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, property.carrier.textStyleAttr)) return false; + } + return true; } return Boolean(editor.schema?.nodes?.run); } diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts index 1a01af9123..020a93ee94 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts @@ -8,6 +8,7 @@ import { formatUnderlineAdapter, formatStrikethroughAdapter, } from './format-adapter.js'; +import { styleApplyWrapper } from './plan-engine/plan-wrappers.js'; type NodeOptions = { attrs?: Record; @@ -386,3 +387,43 @@ describe('formatStrikethroughAdapter', () => { expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); }); }); + +// --------------------------------------------------------------------------- +// SD-2074 regression: format.letterSpacing false-success when textStyle mark +// exists but the letterSpacing attr is not registered (LetterSpacing extension +// absent). +// --------------------------------------------------------------------------- + +describe('styleApplyWrapper — textStyle attr gating (SD-2074)', () => { + it('throws CAPABILITY_UNAVAILABLE for letterSpacing when its attr is missing from textStyle', () => { + const { editor } = makeEditor(); + // Add a textStyle mark with attrs that do NOT include letterSpacing, + // simulating an editor where the LetterSpacing extension is not loaded. + (editor.schema as Record).marks = { + ...editor.schema?.marks, + textStyle: { + create: vi.fn(() => ({ type: 'textStyle' })), + attrs: { + color: { default: null }, + fontSize: { default: null }, + fontFamily: { default: null }, + vertAlign: { default: null }, + position: { default: null }, + textTransform: { default: null }, + // letterSpacing deliberately omitted + }, + }, + }; + + expect(() => + styleApplyWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + inline: { letterSpacing: 10 }, + }, + { changeMode: 'direct' }, + ), + ).toThrow(/requires the "letterSpacing" attribute on the textStyle mark/); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 2ed3ba62bd..284510418a 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -443,6 +443,7 @@ function noOpFailure(resolution: TextMutationResolution, operation: string): Tex function ensureInlinePropertyCapabilities(editor: Editor, keys: readonly InlineRunPatchKey[]): void { let requiresTextStyle = false; let requiresRunNode = false; + const requiredTextStyleAttrs: string[] = []; for (const key of keys) { const entry = INLINE_PROPERTY_BY_KEY[key]; @@ -453,6 +454,7 @@ function ensureInlinePropertyCapabilities(editor: Editor, keys: readonly InlineR if (carrier.storage !== 'mark') continue; if (carrier.markName === 'textStyle') { requiresTextStyle = true; + if (carrier.textStyleAttr) requiredTextStyleAttrs.push(carrier.textStyleAttr); continue; } requireSchemaMark(editor, carrier.markName, 'format.apply'); @@ -463,7 +465,17 @@ function ensureInlinePropertyCapabilities(editor: Editor, keys: readonly InlineR } if (requiresTextStyle) { - requireSchemaMark(editor, 'textStyle', 'format.apply'); + const markType = requireSchemaMark(editor, 'textStyle', 'format.apply'); + const markAttrs = markType.attrs; + for (const attr of requiredTextStyleAttrs) { + if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, attr)) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + `format.apply requires the "${attr}" attribute on the textStyle mark.`, + { reason: 'missing_mark_attribute', markName: 'textStyle', attribute: attr }, + ); + } + } } if (requiresRunNode && !editor.state.schema.nodes.run) { diff --git a/tests/doc-api-stories/tests/formatting/inline-formatting.ts b/tests/doc-api-stories/tests/formatting/inline-formatting.ts index c0657856de..0e738c9a9b 100644 --- a/tests/doc-api-stories/tests/formatting/inline-formatting.ts +++ b/tests/doc-api-stories/tests/formatting/inline-formatting.ts @@ -1,6 +1,22 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; import { unwrap, useStoryHarness } from '../harness'; +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + /** * End-to-end story tests for all inline formatting operations. * @@ -178,6 +194,33 @@ describe('document-api story: inline formatting', () => { await saveResult(sid, 'color.docx'); }); + it('letterSpacing: applies tracking and persists run spacing in exported DOCX', async () => { + const sid = `letterSpacing-${Date.now()}`; + const probeText = 'TrackingProbe123'; + const target = await setupFormattableText(sid, probeText); + + const result = unwrap( + await client.doc.format.letterSpacing({ + sessionId: sid, + blockId: target.blockId, + start: target.range.start, + end: target.range.end, + value: 0.5, + }), + ); + expect(result.receipt?.success).toBe(true); + + const docPath = outPath('letterSpacing.docx'); + await client.doc.save({ sessionId: sid, out: docPath }); + + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const runRegex = new RegExp( + `[\\s\\S]*?]*\\bw:val="10"[\\s\\S]*?]*>${escapeForRegex(probeText)}[\\s\\S]*?<\\/w:r>`, + ); + + expect(documentXml).toMatch(runRegex); + }); + // --------------------------------------------------------------------------- // format.paragraph.setAlignment (paragraph-level) // --------------------------------------------------------------------------- From 89a2d2d84529c3a245a5e16f098f4bacb7c1d6c0 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 21:30:00 -0700 Subject: [PATCH 2/3] chore: type fix --- .../src/document-api-adapters/capabilities-adapter.ts | 2 +- .../src/document-api-adapters/plan-engine/plan-wrappers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 2dab024db0..871e8db3f7 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -343,7 +343,7 @@ function isInlinePropertyAvailable(editor: Editor, property: InlinePropertyRegis const markName = property.carrier.markName; if (!hasMarkCapability(editor, markName)) return false; if (markName === 'textStyle' && property.carrier.textStyleAttr) { - const markAttrs = editor.schema.marks.textStyle?.attrs; + const markAttrs = editor.schema.marks.textStyle?.spec.attrs; if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, property.carrier.textStyleAttr)) return false; } return true; diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 284510418a..18333a1391 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -466,7 +466,7 @@ function ensureInlinePropertyCapabilities(editor: Editor, keys: readonly InlineR if (requiresTextStyle) { const markType = requireSchemaMark(editor, 'textStyle', 'format.apply'); - const markAttrs = markType.attrs; + const markAttrs = markType.spec.attrs; for (const attr of requiredTextStyleAttrs) { if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, attr)) { throw new DocumentApiAdapterError( From 72a0888a5d8bb86c86fe32bdf20e821512cb74b9 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 21:36:21 -0700 Subject: [PATCH 3/3] fix(doc-api): gate textStyle attrs in raw format mutation plans --- .../capabilities-adapter.ts | 6 +- .../plan-engine/executor.test.ts | 124 ++++++++++++++++++ .../plan-engine/inline-property-guards.ts | 101 ++++++++++++++ .../plan-engine/plan-wrappers.ts | 62 ++------- .../plan-engine/preview-parity.test.ts | 110 ++++++++++++++++ .../plan-engine/register-executors.ts | 25 +++- 6 files changed, 370 insertions(+), 58 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/inline-property-guards.ts diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 871e8db3f7..584c403ac6 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -343,7 +343,11 @@ function isInlinePropertyAvailable(editor: Editor, property: InlinePropertyRegis const markName = property.carrier.markName; if (!hasMarkCapability(editor, markName)) return false; if (markName === 'textStyle' && property.carrier.textStyleAttr) { - const markAttrs = editor.schema.marks.textStyle?.spec.attrs; + const textStyleMark = editor.schema.marks.textStyle as { + spec?: { attrs?: Record }; + attrs?: Record; + }; + const markAttrs = textStyleMark?.spec?.attrs ?? textStyleMark?.attrs; if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, property.carrier.textStyleAttr)) return false; } return true; diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts index 351eb950bf..e0d1ee55b6 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts @@ -199,6 +199,92 @@ function createTestMark(name: string, attrs: Record = {}) { }; } +function makeTextStylePlanEditor(textStyleAttrNames: string[]): { + editor: Editor; + tr: { + addMark: ReturnType; + removeMark: ReturnType; + setMeta: ReturnType; + replaceWith: ReturnType; + delete: ReturnType; + insert: ReturnType; + mapping: { map: (pos: number) => number }; + docChanged: boolean; + doc: { + nodesBetween: ReturnType; + nodeAt: ReturnType; + textBetween: ReturnType; + textContent: string; + resolve: ReturnType; + }; + }; + dispatch: ReturnType; +} { + const textStyleAttrs = Object.fromEntries(textStyleAttrNames.map((name) => [name, { default: null }])); + const textStyleCreate = vi.fn((input: Record = {}) => + createTestMark( + 'textStyle', + Object.fromEntries( + Object.entries(input).filter(([key]) => Object.prototype.hasOwnProperty.call(textStyleAttrs, key)), + ), + ), + ); + + const textNode = { isText: true, nodeSize: 5, marks: [] as unknown[] }; + const doc = { + nodesBetween: vi.fn((_from: number, _to: number, callback: (node: typeof textNode, pos: number) => void) => { + callback(textNode, 1); + }), + nodeAt: vi.fn(() => null), + textBetween: vi.fn(() => 'Hello'), + textContent: 'Hello', + resolve: vi.fn(() => ({ marks: () => [] })), + }; + + const tr = { + replaceWith: vi.fn(), + delete: vi.fn(), + insert: vi.fn(), + addMark: vi.fn(), + removeMark: vi.fn(), + setMeta: vi.fn(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + doc, + }; + tr.replaceWith.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.insert.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + tr.removeMark.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + + const dispatch = vi.fn(); + const textStyle = { + spec: { attrs: textStyleAttrs }, + attrs: textStyleAttrs, + create: textStyleCreate, + }; + + const editor = { + state: { + doc, + tr, + schema: { + marks: { textStyle }, + nodes: {}, + }, + }, + schema: { + marks: { textStyle }, + nodes: {}, + }, + dispatch, + } as unknown as Editor; + + return { editor, tr, dispatch }; +} + describe('executeTextInsert: setMarks tri-state directives', () => { it('maps on/off/clear to canonical mark emission', () => { const boldCreate = vi.fn((attrs?: Record | null) => @@ -1998,3 +2084,41 @@ describe('executeSpanStyleApply: collapsed-range no-op guard', () => { expect(tr.removeMark).not.toHaveBeenCalled(); }); }); + +describe('executeCompiledPlan: format.apply textStyle attr gating', () => { + it('throws CAPABILITY_UNAVAILABLE for caps when textStyle lacks textTransform', () => { + const { editor, tr, dispatch } = makeTextStylePlanEditor([ + 'color', + 'fontSize', + 'fontFamily', + 'letterSpacing', + 'vertAlign', + 'position', + ]); + + const step: StyleApplyStep = { + id: 'step-format-caps', + op: 'format.apply', + where: { by: 'target', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } } as any, + args: { inline: { caps: true } }, + }; + const compiled: CompiledPlan = { + mutationSteps: [{ step, targets: [makeTarget({ stepId: step.id, op: step.op })] }], + assertSteps: [], + compiledRevision: '0', + }; + + expect(() => executeCompiledPlan(editor, compiled)).toThrow(PlanError); + try { + executeCompiledPlan(editor, compiled); + } catch (error) { + expect(error).toBeInstanceOf(PlanError); + const planErr = error as PlanError; + expect(planErr.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(planErr.message).toContain('textTransform'); + } + + expect(tr.addMark).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/inline-property-guards.ts b/packages/super-editor/src/document-api-adapters/plan-engine/inline-property-guards.ts new file mode 100644 index 0000000000..6eb18e7ab3 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/inline-property-guards.ts @@ -0,0 +1,101 @@ +import type { InlineRunPatchKey } from '@superdoc/document-api'; +import { INLINE_PROPERTY_BY_KEY } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; + +export interface InlinePropertyGuardIssue { + code: 'CAPABILITY_UNAVAILABLE'; + message: string; + details?: Record; +} + +function getSchemaMarks(editor: Editor): Record { + return (editor.schema?.marks ?? editor.state.schema?.marks ?? {}) as Record; +} + +function getSchemaNodes(editor: Editor): Record { + return (editor.state.schema?.nodes ?? editor.schema?.nodes ?? {}) as Record; +} + +function getMarkAttrs(markType: unknown): Record | undefined { + if (!markType || typeof markType !== 'object') return undefined; + + const specAttrs = (markType as { spec?: { attrs?: Record } }).spec?.attrs; + if (specAttrs && typeof specAttrs === 'object') return specAttrs; + + const attrs = (markType as { attrs?: Record }).attrs; + if (attrs && typeof attrs === 'object') return attrs; + + return undefined; +} + +export function getInlinePropertyCapabilityIssue( + editor: Editor, + keys: readonly InlineRunPatchKey[], + operationName = 'format.apply', +): InlinePropertyGuardIssue | undefined { + const schemaMarks = getSchemaMarks(editor); + const requiredTextStyleAttrs = new Set(); + let requiresRunNode = false; + + for (const key of keys) { + const entry = INLINE_PROPERTY_BY_KEY[key]; + if (!entry) continue; + + if (entry.storage === 'mark') { + const carrier = entry.carrier; + if (carrier.storage !== 'mark') continue; + + if (!schemaMarks[carrier.markName]) { + return { + code: 'CAPABILITY_UNAVAILABLE', + message: `${operationName} requires the "${carrier.markName}" mark.`, + details: { reason: 'missing_mark', markName: carrier.markName }, + }; + } + + if (carrier.markName === 'textStyle' && carrier.textStyleAttr) { + requiredTextStyleAttrs.add(carrier.textStyleAttr); + } + + continue; + } + + requiresRunNode = true; + } + + if (requiredTextStyleAttrs.size > 0) { + const markAttrs = getMarkAttrs(schemaMarks.textStyle); + for (const attr of requiredTextStyleAttrs) { + if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, attr)) { + return { + code: 'CAPABILITY_UNAVAILABLE', + message: `${operationName} requires the "${attr}" attribute on the textStyle mark.`, + details: { reason: 'missing_mark_attribute', markName: 'textStyle', attribute: attr }, + }; + } + } + } + + if (requiresRunNode && !getSchemaNodes(editor).run) { + return { + code: 'CAPABILITY_UNAVAILABLE', + message: `${operationName} requires a run node in the schema.`, + }; + } + + return undefined; +} + +export function getTrackedInlinePropertySupportIssue( + keys: readonly InlineRunPatchKey[], + operationName = 'format.apply', +): InlinePropertyGuardIssue | undefined { + const unsupportedTrackedKeys = keys.filter((key) => INLINE_PROPERTY_BY_KEY[key]?.tracked === false); + if (unsupportedTrackedKeys.length === 0) return undefined; + + return { + code: 'CAPABILITY_UNAVAILABLE', + message: `${operationName} tracked mode is not available for: ${unsupportedTrackedKeys.join(', ')}`, + details: { keys: unsupportedTrackedKeys, changeMode: 'tracked' }, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 18333a1391..b2f77d84ae 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -56,12 +56,7 @@ import { type ResolvedWrite, } from '../helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from '../helpers/text-mutation-resolution.js'; -import { - ensureTrackedCapability, - requireEditorCommand, - requireSchemaMark, - rejectTrackedMode, -} from '../helpers/mutation-helpers.js'; +import { ensureTrackedCapability, requireEditorCommand, rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { TrackFormatMarkName } from '../../extensions/track-changes/constants.js'; import { applyDirectMutationMeta, applyTrackedMutationMeta } from '../helpers/transaction-meta.js'; import { markdownToPmFragment } from '../../core/helpers/markdown/markdownToPmContent.js'; @@ -80,6 +75,7 @@ import { type BlockCandidate, type BlockIndex, } from '../helpers/node-address-resolver.js'; +import { getInlinePropertyCapabilityIssue, getTrackedInlinePropertySupportIssue } from './inline-property-guards.js'; // --------------------------------------------------------------------------- // Helpers @@ -441,57 +437,15 @@ function noOpFailure(resolution: TextMutationResolution, operation: string): Tex } function ensureInlinePropertyCapabilities(editor: Editor, keys: readonly InlineRunPatchKey[]): void { - let requiresTextStyle = false; - let requiresRunNode = false; - const requiredTextStyleAttrs: string[] = []; - - for (const key of keys) { - const entry = INLINE_PROPERTY_BY_KEY[key]; - if (!entry) continue; - - if (entry.storage === 'mark') { - const carrier = entry.carrier; - if (carrier.storage !== 'mark') continue; - if (carrier.markName === 'textStyle') { - requiresTextStyle = true; - if (carrier.textStyleAttr) requiredTextStyleAttrs.push(carrier.textStyleAttr); - continue; - } - requireSchemaMark(editor, carrier.markName, 'format.apply'); - continue; - } - - requiresRunNode = true; - } - - if (requiresTextStyle) { - const markType = requireSchemaMark(editor, 'textStyle', 'format.apply'); - const markAttrs = markType.spec.attrs; - for (const attr of requiredTextStyleAttrs) { - if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, attr)) { - throw new DocumentApiAdapterError( - 'CAPABILITY_UNAVAILABLE', - `format.apply requires the "${attr}" attribute on the textStyle mark.`, - { reason: 'missing_mark_attribute', markName: 'textStyle', attribute: attr }, - ); - } - } - } - - if (requiresRunNode && !editor.state.schema.nodes.run) { - throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', 'format.apply requires a run node in the schema.'); - } + const issue = getInlinePropertyCapabilityIssue(editor, keys); + if (!issue) return; + throw new DocumentApiAdapterError(issue.code, issue.message, issue.details); } function ensureTrackedInlinePropertySupport(keys: readonly InlineRunPatchKey[]): void { - const unsupportedTrackedKeys = keys.filter((key) => INLINE_PROPERTY_BY_KEY[key]?.tracked === false); - if (unsupportedTrackedKeys.length === 0) return; - - throw new DocumentApiAdapterError( - 'CAPABILITY_UNAVAILABLE', - `format.apply tracked mode is not available for: ${unsupportedTrackedKeys.join(', ')}`, - { keys: unsupportedTrackedKeys, changeMode: 'tracked' }, - ); + const issue = getTrackedInlinePropertySupportIssue(keys); + if (!issue) return; + throw new DocumentApiAdapterError(issue.code, issue.message, issue.details); } /** @deprecated Legacy wrapper. New code routes through selectionMutationWrapper. */ diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/preview-parity.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/preview-parity.test.ts index 78a3823203..e34b86b37d 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/preview-parity.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/preview-parity.test.ts @@ -188,6 +188,83 @@ function makeCompiledPlan(overrides: Partial = {}): CompiledPlan { }; } +function makeTextStyleEditor(textStyleAttrNames: string[]): { + editor: Editor; + dispatch: ReturnType; + tr: { + addMark: ReturnType; + removeMark: ReturnType; + }; +} { + const textStyleAttrs = Object.fromEntries(textStyleAttrNames.map((name) => [name, { default: null }])); + const textStyle = { + spec: { attrs: textStyleAttrs }, + attrs: textStyleAttrs, + create: vi.fn((input: Record = {}) => ({ + type: { name: 'textStyle' }, + attrs: Object.fromEntries( + Object.entries(input).filter(([key]) => Object.prototype.hasOwnProperty.call(textStyleAttrs, key)), + ), + eq: (other: any) => JSON.stringify(other?.attrs) === JSON.stringify(input), + })), + }; + + const textNode = { isText: true, nodeSize: 5, marks: [] as unknown[] }; + const doc = { + textContent: 'Hello', + textBetween: vi.fn(() => 'Hello'), + nodesBetween: vi.fn((_from: number, _to: number, callback: (node: typeof textNode, pos: number) => void) => { + callback(textNode, 1); + }), + descendants: vi.fn(), + nodeAt: vi.fn(() => null), + }; + const tr = { + replaceWith: vi.fn(), + delete: vi.fn(), + insert: vi.fn(), + addMark: vi.fn(), + removeMark: vi.fn(), + setMeta: vi.fn(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + doc: { + resolve: () => ({ marks: () => [] }), + textContent: 'Hello', + descendants: vi.fn(), + textBetween: vi.fn(() => 'Hello'), + nodesBetween: doc.nodesBetween, + nodeAt: doc.nodeAt, + }, + }; + tr.replaceWith.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.insert.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + tr.removeMark.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + + const dispatch = vi.fn(); + + const editor = { + state: { + doc, + tr, + schema: { + marks: { textStyle }, + nodes: {}, + }, + }, + schema: { + marks: { textStyle }, + nodes: {}, + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, tr }; +} + // --------------------------------------------------------------------------- // T6.1 — Preview does not dispatch or change revision // --------------------------------------------------------------------------- @@ -382,4 +459,37 @@ describe('previewPlan: success/failure shape parity', () => { // The mutation step + assert step should be in results expect(result.steps.length).toBeGreaterThan(0); }); + + it('execute-phase capability failures are reported when textStyle attrs are missing', () => { + const { editor, dispatch, tr } = makeTextStyleEditor([ + 'color', + 'fontSize', + 'fontFamily', + 'vertAlign', + 'position', + 'textTransform', + ]); + const step: StyleApplyStep = { + id: 'step-letter-spacing', + op: 'format.apply', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { inline: { letterSpacing: 0.5 } }, + }; + + mockedDeps.compilePlan.mockReturnValue( + makeCompiledPlan({ + mutationSteps: [{ step, targets: [makeTarget({ stepId: step.id, op: step.op })] }], + }), + ); + + const result = previewPlan(editor, { steps: [step] }); + + expect(result.valid).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures![0].code).toBe('CAPABILITY_UNAVAILABLE'); + expect(result.failures![0].phase).toBe('execute'); + expect(result.failures![0].message).toContain('letterSpacing'); + expect(tr.addMark).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts index 49f746843f..d616cb4c8d 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts @@ -28,6 +28,7 @@ import type { TableStepData, TableMutationResult, MutationOptions, + InlineRunPatchKey, } from '@superdoc/document-api'; import type { CompiledTarget, @@ -38,6 +39,7 @@ import type { } from './executor-registry.types.js'; import { registerStepExecutor } from './executor-registry.js'; import { planError } from './errors.js'; +import { getInlinePropertyCapabilityIssue, getTrackedInlinePropertySupportIssue } from './inline-property-guards.js'; /** Safely extract blockId from a target (only present on range targets). */ function targetBlockId(t: CompiledTarget | undefined): string { @@ -260,6 +262,21 @@ function executeTextStep( return { stepId: step.id, op: step.op, effect, matchCount: targets.length, data }; } +function ensureFormatStepCapabilities(ctx: ExecuteContext, step: StyleApplyStep): void { + const inlineKeys = Object.keys(step.args.inline) as InlineRunPatchKey[]; + const capabilityIssue = getInlinePropertyCapabilityIssue(ctx.editor, inlineKeys, step.op); + if (capabilityIssue) { + throw planError(capabilityIssue.code, capabilityIssue.message, step.id, capabilityIssue.details); + } + + if (ctx.changeMode !== 'tracked') return; + + const trackedIssue = getTrackedInlinePropertySupportIssue(inlineKeys, step.op); + if (trackedIssue) { + throw planError(trackedIssue.code, trackedIssue.message, step.id, trackedIssue.details); + } +} + // --------------------------------------------------------------------------- // Table adapter dispatch — enables mutations.apply with raw step args // --------------------------------------------------------------------------- @@ -388,14 +405,16 @@ export function registerBuiltInExecutors(): void { }); registerStepExecutor('format.apply', { - execute: (ctx, targets, step) => - executeTextStep( + execute: (ctx, targets, step) => { + ensureFormatStepCapabilities(ctx, step as StyleApplyStep); + return executeTextStep( 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), - ), + ); + }, }); registerStepExecutor('create.paragraph', {