diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json index 275ce6fe44..f46533445e 100644 --- a/apps/docs/config/routes.json +++ b/apps/docs/config/routes.json @@ -414,6 +414,7 @@ "/document-api/reference/selection/current/", "/document-api/reference/styles/", "/document-api/reference/styles/apply/", + "/document-api/reference/styles/create/", "/document-api/reference/styles/get-catalog/", "/document-api/reference/styles/paragraph/", "/document-api/reference/styles/paragraph/clear-style/", diff --git a/apps/docs/tests/export.test.mjs b/apps/docs/tests/export.test.mjs index 76ac0d7226..a1546cb989 100644 --- a/apps/docs/tests/export.test.mjs +++ b/apps/docs/tests/export.test.mjs @@ -1085,7 +1085,7 @@ test('exports the searchable reference experience from contract data', async () const namespaceText = namespace.replaceAll('', ''); const operationText = operation.replaceAll('', ''); - assert.match(landingText, /Search all 427 operations in contract 0\.1\.0/); + assert.match(landingText, /Search all 428 operations in contract 0\.1\.0/); assert.match(landing, /Search operation names, paths, and descriptions/); assert.match(landing, /contentControls/); assert.match(namespaceText, /55 operations/); diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 5b74dc9caf..bc40bef14e 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -1491,11 +1491,13 @@ describe('document-api contract catalog', () => { // styles.apply + all sections.set* / sections.clear* mutations expect(historyUnsafeOps).toContain('styles.apply'); + expect(historyUnsafeOps).toContain('styles.create'); for (const id of historyUnsafeOps) { expect( id.startsWith('sections.') || id.startsWith('headerFooters.') || id === 'styles.apply' || + id === 'styles.create' || id === 'templates.apply' || id === 'tables.setDefaultStyle' || id === 'tables.clearDefaultStyle' || diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index d43662eb1b..d2586d22d4 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -1264,6 +1264,34 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'styles/apply.mdx', referenceGroup: 'styles', }, + 'styles.create': { + memberPath: 'styles.create', + description: + 'Define or redefine a named paragraph or character style in the Style Definitions part. Replaces the definition rather than merging into it, and decides conflicts on both styleId and name, because Word keys its Styles gallery on the name. Linked style pairs and table/numbering styles are out of scope.', + expectedResult: + 'Returns a StylesCreateReceipt reporting whether the style was created or redefined, with per-channel before/after state for the paragraph and run properties.', + requiresDocumentContext: true, + metadata: mutationOperation({ + // Conditional, not idempotent: under the default conflictPolicy 'fail' a + // second identical call fails, and only 'replace' makes it repeatable. + // Publishing 'idempotent' would invite an orchestrator to replay the call + // after a transport timeout and take a hard conflict, or clobber a style + // edited in between. + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + // No receipt failures are declared: this contract ships without an + // adapter, so there is no code the host can currently produce. Codes move + // here from `throws` when the engine side lands. + possibleFailureCodes: NONE_FAILURES, + throws: ['INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'], + // Writes word/styles.xml outside the document history, exactly as + // styles.apply does. + historyUnsafe: true, + }), + referenceDocPath: 'styles/create.mdx', + referenceGroup: 'styles', + }, 'styles.getCatalog': { memberPath: 'styles.getCatalog', description: diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 1871299047..c399f269e6 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -53,6 +53,9 @@ import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt, + StylesCreateInput, + StylesCreateOptions, + StylesCreateReceipt, StylesGetCatalogInput, StylesGetCatalogResult, } from '../styles/index.js'; @@ -748,6 +751,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { }; // --- styles.* --- 'styles.apply': { input: StylesApplyInput; options: StylesApplyOptions; output: StylesApplyReceipt }; + 'styles.create': { input: StylesCreateInput; options: StylesCreateOptions; output: StylesCreateReceipt }; 'styles.getCatalog': { input: StylesGetCatalogInput | undefined; options: never; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 14815357ea..d6bc95ca0b 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -5108,6 +5108,101 @@ const operationSchemas: Record = { failure: stylesFailureSchema, }; })(), + 'styles.create': (() => { + // Derived from PROPERTY_REGISTRY under the `style` scope: the run channel + // carries four properties Word allows on a named style and forbids in + // docDefaults, so this schema is deliberately wider than styles.apply's. + const commonProperties = { + id: { type: 'string', minLength: 1 }, + name: { type: 'string', minLength: 1 }, + basedOn: { type: ['string', 'null'], minLength: 1 }, + // `pattern` mirrors the validator: w:aliases is one comma-delimited + // value, so an alias with a comma reads back as two. + aliases: { ...arraySchema({ type: 'string', minLength: 1, pattern: '^[^,]+$' }), uniqueItems: true }, + priority: { type: ['integer', 'null'] }, + qFormat: { type: 'boolean' }, + hidden: { type: 'boolean' }, + semiHidden: { type: 'boolean' }, + unhideWhenUsed: { type: 'boolean' }, + locked: { type: 'boolean' }, + custom: { type: 'boolean' }, + conflictPolicy: { enum: ['fail', 'replace'] }, + }; + const paragraphInputSchema = objectSchema( + { + ...commonProperties, + type: { const: 'paragraph' }, + next: { type: ['string', 'null'], minLength: 1 }, + paragraph: buildPatchSchema('paragraph', 'style'), + run: buildPatchSchema('run', 'style'), + }, + ['id', 'name', 'type'], + ); + const characterInputSchema = objectSchema( + { + ...commonProperties, + type: { const: 'character' }, + run: buildPatchSchema('run', 'style'), + }, + ['id', 'name', 'type'], + ); + const resolutionSchema = objectSchema( + { + scope: { const: 'style' }, + id: { type: 'string', minLength: 1 }, + type: { enum: ['paragraph', 'character'] }, + xmlPart: { type: 'string' }, + xmlPath: { const: 'w:styles/w:style' }, + }, + ['scope', 'id', 'type', 'xmlPart', 'xmlPath'], + ); + // Per channel, unlike styles.apply: one w:style carries both, and + // `borders` means a different shape on each. + const channelStateSchema = objectSchema( + { + paragraph: { oneOf: [buildStateSchema('style', 'paragraph'), { type: 'null' }] }, + run: { oneOf: [buildStateSchema('style', 'run'), { type: 'null' }] }, + }, + ['paragraph', 'run'], + ); + const successSchema = objectSchema( + { + success: { const: true }, + changed: { type: 'boolean' }, + created: { type: 'boolean' }, + resolution: resolutionSchema, + dryRun: { type: 'boolean' }, + before: { oneOf: [channelStateSchema, { type: 'null' }] }, + after: channelStateSchema, + }, + ['success', 'changed', 'created', 'resolution', 'dryRun', 'before', 'after'], + ); + const failureSchema = objectSchema( + { + success: { const: false }, + failure: objectSchema( + { + // Not an enum derived from possibleFailureCodes: that list is empty + // while the operation ships without an adapter, and `enum: []` + // fails to compile in Ajv — taking the whole `output` oneOf with + // it, so a consumer could not validate even a success receipt. + // styles.apply publishes the same open shape. + code: { type: 'string' }, + message: { type: 'string' }, + details: {}, + }, + ['code', 'message'], + ), + }, + ['success', 'failure'], + ); + return { + input: { oneOf: [paragraphInputSchema, characterInputSchema] }, + output: { oneOf: [successSchema, failureSchema] }, + success: successSchema, + failure: failureSchema, + }; + })(), 'styles.getCatalog': (() => { const catalogViews = ['quickGallery', 'recommended', 'currentDocument', 'all', 'inUse']; const catalogFilterTypes = ['paragraph', 'character', 'linked', 'table', 'numbering']; diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index ad9ee541b9..2b2752f373 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -1579,6 +1579,83 @@ describe('createDocumentApi', () => { expect(capturedCode).toBe('CAPABILITY_UNAVAILABLE'); }); + /** + * The namespace gate above cannot reach `styles.create`: `adapters.styles` is + * present, only its optional `create` hook is not. Without the second gate a + * caller reading the snapshot selects an operation whose only possible answer + * is `CAPABILITY_UNAVAILABLE`. + */ + describe('hook-gated capabilities', () => { + // A fresh adapter per API: capFn mutates the snapshot in place, and the + // helper hands out the same `operations` object on every call. + function makeStylesApi(styles: unknown) { + return createDocumentApi({ + capabilities: makeCapabilitiesAdapter({ + operations: { + 'styles.apply': { available: true, tracked: false, dryRun: true }, + 'styles.create': { available: true, tracked: false, dryRun: true }, + } as unknown as DocumentApiCapabilities['operations'], + }), + styles, + } as unknown as DocumentApiAdapters); + } + + it('masks styles.create when the styles adapter has no create hook', () => { + const capabilities = makeStylesApi({ apply: () => undefined }).capabilities(); + + expect(capabilities.operations['styles.create']).toMatchObject({ + available: false, + tracked: false, + dryRun: false, + reasons: ['OPERATION_UNAVAILABLE'], + }); + }); + + it('leaves the sibling operation, whose hook is present, untouched', () => { + const capabilities = makeStylesApi({ apply: () => undefined }).capabilities(); + + expect(capabilities.operations['styles.apply']).toMatchObject({ available: true, dryRun: true }); + expect(capabilities.operations['styles.apply'].reasons).toBeUndefined(); + }); + + it('reports styles.create as the engine did once the hook is supplied', () => { + const capabilities = makeStylesApi({ apply: () => undefined, create: () => undefined }).capabilities(); + + expect(capabilities.operations['styles.create']).toMatchObject({ available: true, dryRun: true }); + expect(capabilities.operations['styles.create'].reasons).toBeUndefined(); + }); + + it('adds no entry for an engine whose snapshot predates the operation', () => { + const api = createDocumentApi({ + capabilities: makeCapabilitiesAdapter({ + operations: { + 'styles.apply': { available: true, tracked: false, dryRun: true }, + } as unknown as DocumentApiCapabilities['operations'], + }), + styles: { apply: () => undefined }, + } as unknown as DocumentApiAdapters); + + // An absent entry already says unavailable; inventing one would claim the + // engine reported something it did not. + expect(api.capabilities().operations['styles.create']).toBeUndefined(); + }); + + it('survives a host that omits the styles adapter entirely', () => { + const api = createDocumentApi({ + capabilities: makeCapabilitiesAdapter({ + operations: { + 'styles.create': { available: true, tracked: false, dryRun: true }, + } as unknown as DocumentApiCapabilities['operations'], + }), + } as unknown as DocumentApiAdapters); + + expect(api.capabilities().operations['styles.create']).toMatchObject({ + available: false, + reasons: ['OPERATION_UNAVAILABLE'], + }); + }); + }); + describe('insert target validation', () => { function makeApi() { return createDocumentApi({ diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index e1029a7c59..ec16781020 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -125,10 +125,15 @@ import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt, + StylesCreateAdapter, + StylesCreateApi, + StylesCreateInput, + StylesCreateOptions, + StylesCreateReceipt, StylesGetCatalogInput, StylesGetCatalogResult, } from './styles/index.js'; -import { executeStylesApply, executeStylesGetCatalog } from './styles/index.js'; +import { executeStylesApply, executeStylesCreate, executeStylesGetCatalog } from './styles/index.js'; import type { TemplatesAdapter, TemplatesApi, @@ -1163,6 +1168,33 @@ export { executeStylesGetCatalog, validateStylesGetCatalogInput, } from './styles/index.js'; +export type { + StylesScope, + StyleRunPatch, + StyleConflictPolicy, + StyleChannelState, + StylesCreateAdapter, + StylesCreateApi, + StylesCreateInput, + StylesCreateParagraphInput, + StylesCreateCharacterInput, + StylesCreateOptions, + NormalizedStylesCreateOptions, + StylesCreateResolution, + StylesCreateReceipt, + StylesCreateReceiptSuccess, + StylesCreateReceiptFailure, +} from './styles/index.js'; +export { + STYLE_EXCLUDED_KEYS, + EXCLUDED_KEYS_BY_SCOPE, + SCOPE_LABEL, + STYLE_XML_PATH, + executeStylesCreate, + validateStylesCreateInput, + validateStylesCreateOptions, + validatePatchObject, +} from './styles/index.js'; export type { TemplatesAdapter, TemplatesApi, @@ -1900,7 +1932,7 @@ export interface DocumentApi { /** * Stylesheet operations (docDefaults, style definitions, paragraph style references). */ - styles: StylesApi & { paragraph: ParagraphStylesApi }; + styles: StylesApi & StylesCreateApi & { paragraph: ParagraphStylesApi }; /** * Template/substrate operations (apply detected DOCX substrate from a source package). */ @@ -2078,7 +2110,7 @@ export interface DocumentApiAdapters { comments: CommentsAdapter; write: WriteAdapter; selectionMutation: SelectionMutationAdapter; - styles: StylesAdapter; + styles: StylesAdapter & Partial; templates: TemplatesAdapter; trackChanges: TrackChangesAdapter; create: CreateAdapter; @@ -2249,6 +2281,24 @@ const ADAPTER_GATED_PREFIXES = [ 'authorities', 'export', ] as const; + +/** + * Operations gated on one optional *method* of a namespace adapter that is + * itself present, so {@link ADAPTER_GATED_PREFIXES} cannot reach them. + * + * Without this the snapshot advertises an operation whose only possible answer + * is `CAPABILITY_UNAVAILABLE`, and a caller selecting capabilities from it + * picks an operation that cannot run. Only operations this package adds are + * listed: changing what an already-shipped operation advertises is a separate + * change, not a side effect of this one. + */ +const HOOK_GATED_OPERATIONS: ReadonlyArray<{ + readonly operationId: OperationId; + readonly hasHook: (adapters: DocumentApiAdapters) => boolean; + // Optional chaining despite the required type: the namespace loop above + // tolerates a missing adapter, and a JavaScript host can pass one. +}> = [{ operationId: 'styles.create', hasHook: (a) => typeof a.styles?.create === 'function' }]; + export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { const rawCapFn = () => executeCapabilities(adapters.capabilities); const capFn = (): DocumentApiCapabilities => { @@ -2266,6 +2316,18 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { cap.reasons = [...(cap.reasons ?? []), 'NAMESPACE_UNAVAILABLE']; } } + // Then the same gate one level down, for a hook rather than a namespace. + for (const { operationId, hasHook } of HOOK_GATED_OPERATIONS) { + if (hasHook(adapters)) continue; + // An engine older than the operation reports no entry for it at all, + // which already says unavailable; there is nothing to correct. + const cap = caps.operations[operationId]; + if (!cap) continue; + cap.available = false; + cap.tracked = false; + cap.dryRun = false; + cap.reasons = [...(cap.reasons ?? []), 'OPERATION_UNAVAILABLE']; + } return caps; }; const capabilities: CapabilitiesApi = Object.assign(capFn, { @@ -2439,6 +2501,9 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt { return executeStylesApply(adapters.styles, input, options); }, + create(input: StylesCreateInput, options?: StylesCreateOptions): StylesCreateReceipt { + return executeStylesCreate(adapters.styles, input, options); + }, getCatalog(input?: StylesGetCatalogInput): StylesGetCatalogResult { return executeStylesGetCatalog(adapters.styles, input); }, diff --git a/packages/document-api/src/inline-semantics/token-sets.ts b/packages/document-api/src/inline-semantics/token-sets.ts index a8b81f890b..cde79ff063 100644 --- a/packages/document-api/src/inline-semantics/token-sets.ts +++ b/packages/document-api/src/inline-semantics/token-sets.ts @@ -91,3 +91,38 @@ export type StThemeColorValue = (typeof ST_THEME_COLOR_VALUES)[number]; /** Runtime set for O(1) ST_ThemeColor validation. */ export const ST_THEME_COLOR_VALUE_SET: ReadonlySet = new Set(ST_THEME_COLOR_VALUES); + +// --------------------------------------------------------------------------- +// ST_HighlightColor (w:highlight) +// --------------------------------------------------------------------------- + +/** + * Accepted `w:val` values for `w:highlight`. A closed enumeration, unlike a + * colour value: anything outside it produces a document Word offers to repair. + * + * No `StHighlightValue` alias beside it, unlike the sets above: + * {@link SDHighlightColor} in `types/sd-props.ts` is already derived from this + * list, and a second name for one union is a second thing to keep in step. + */ +export const ST_HIGHLIGHT_VALUES = [ + 'black', + 'blue', + 'cyan', + 'green', + 'magenta', + 'red', + 'yellow', + 'white', + 'darkBlue', + 'darkCyan', + 'darkGreen', + 'darkMagenta', + 'darkRed', + 'darkYellow', + 'darkGray', + 'lightGray', + 'none', +] as const; + +/** Runtime set for O(1) ST_HighlightColor validation. */ +export const ST_HIGHLIGHT_VALUE_SET: ReadonlySet = new Set(ST_HIGHLIGHT_VALUES); diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index a059e8e642..0c2b6463f8 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -6,7 +6,7 @@ import type { FindAdapter } from '../find/find.js'; import type { GetNodeAdapter } from '../get-node/get-node.js'; import type { WriteAdapter } from '../write/write.js'; import type { SelectionMutationAdapter } from '../selection-mutation.js'; -import type { StylesAdapter } from '../styles/index.js'; +import type { StylesAdapter, StylesCreateAdapter } from '../styles/index.js'; import type { TemplatesAdapter, TemplatesApplyReceipt } from '../templates/index.js'; import type { TrackChangesAdapter } from '../track-changes/track-changes.js'; import type { CreateAdapter } from '../create/create.js'; @@ -122,7 +122,22 @@ function makeAdapters() { const selectionMutationAdapter: SelectionMutationAdapter = { execute: mock(selectionMutationReceipt), }; - const stylesAdapter: StylesAdapter = { + const stylesAdapter: StylesAdapter & Partial = { + create: mock(() => ({ + success: true as const, + changed: true, + created: true, + resolution: { + scope: 'style' as const, + id: 'Kushya', + type: 'paragraph' as const, + xmlPart: 'word/styles.xml' as const, + xmlPath: 'w:styles/w:style' as const, + }, + dryRun: false, + before: null, + after: { paragraph: {}, run: { bold: 'on' as const } }, + })), apply: mock(() => ({ success: true as const, changed: true, @@ -382,6 +397,15 @@ describe('invoke', () => { expect(invoked).toEqual(direct); }); + it('styles.create: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { id: 'Kushya', name: 'Kushya', type: 'paragraph' as const, run: { rtl: true } }; + const direct = api.styles.create(input); + const invoked = api.invoke({ operationId: 'styles.create', input }); + expect(invoked).toEqual(direct); + }); + it('insert: invoke returns same result as direct call', () => { const { adapters } = makeAdapters(); const api = createDocumentApi(adapters); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 9a387f3bb3..dc8b3f899d 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -113,6 +113,7 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'format.paragraph.setNumbering': (input, options) => api.format.paragraph.setNumbering(input, options), // --- styles.* --- 'styles.apply': (input, options) => api.styles.apply(input, options), + 'styles.create': (input, options) => api.styles.create(input, options), 'styles.getCatalog': (input) => api.styles.getCatalog(input), // --- templates.* --- 'templates.apply': (input, options) => api.templates.apply(input, options), diff --git a/packages/document-api/src/styles/create.test.ts b/packages/document-api/src/styles/create.test.ts new file mode 100644 index 0000000000..4d1f00ec2b --- /dev/null +++ b/packages/document-api/src/styles/create.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { + executeStylesCreate, + type StylesCreateAdapter, + type StylesCreateInput, + type StylesCreateReceipt, + type NormalizedStylesCreateOptions, +} from './create.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { STYLE_XML_PATH } from './create.js'; +import { XML_PATH_BY_CHANNEL } from './registry.js'; +import { buildInternalContractSchemas } from '../contract/schemas.js'; + +type JsonSchemaNode = Record & { + oneOf?: unknown; + properties?: unknown; + enum?: unknown; + const?: unknown; +}; + +function okReceipt(dryRun: boolean): StylesCreateReceipt { + return { + success: true, + changed: true, + created: true, + resolution: { + scope: 'style', + id: 'Kushya', + type: 'paragraph', + xmlPart: 'word/styles.xml', + xmlPath: 'w:styles/w:style', + }, + dryRun, + before: null, + after: { paragraph: {}, run: {} }, + }; +} + +function makeAdapter(): StylesCreateAdapter & { create: ReturnType } { + return { + create: mock((_input: StylesCreateInput, options: NormalizedStylesCreateOptions) => okReceipt(options.dryRun)), + }; +} + +const MINIMAL: StylesCreateInput = { id: 'Kushya', name: 'Kushya', type: 'paragraph' }; + +function expectValidationError(fn: () => unknown, code: string, messagePattern?: RegExp): void { + try { + fn(); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + const e = err as DocumentApiValidationError; + expect(e.code).toBe(code); + if (messagePattern) expect(e.message).toMatch(messagePattern); + return; + } + throw new Error(`Expected ${code} to be thrown.`); +} + +describe('executeStylesCreate contract', () => { + it('routes a valid definition to the adapter with normalized options', () => { + const adapter = makeAdapter(); + const receipt = executeStylesCreate(adapter, MINIMAL, { expectedRevision: '7' }); + + expect(adapter.create).toHaveBeenCalledTimes(1); + const [input, options] = adapter.create.mock.calls[0]; + expect(input).toEqual(MINIMAL); + expect(options).toEqual({ dryRun: false, expectedRevision: '7' }); + expect(receipt.success).toBe(true); + }); + + it('defaults dryRun to false and leaves expectedRevision undefined', () => { + const adapter = makeAdapter(); + executeStylesCreate(adapter, MINIMAL); + expect(adapter.create.mock.calls[0][1]).toEqual({ dryRun: false, expectedRevision: undefined }); + }); + + it('passes dryRun through to the adapter and back on the receipt', () => { + const adapter = makeAdapter(); + const receipt = executeStylesCreate(adapter, MINIMAL, { dryRun: true }); + expect(adapter.create.mock.calls[0][1].dryRun).toBe(true); + expect(receipt.success && receipt.dryRun).toBe(true); + }); + + it('accepts every optional field on a paragraph style', () => { + const adapter = makeAdapter(); + executeStylesCreate(adapter, { + id: 'Kushya', + name: 'Kushya', + type: 'paragraph', + basedOn: 'Normal', + next: 'Terutz', + aliases: ['Question'], + priority: 21, + qFormat: true, + hidden: false, + semiHidden: false, + unhideWhenUsed: true, + locked: false, + custom: true, + conflictPolicy: 'replace', + paragraph: { keepNext: true, rightToLeft: true }, + run: { bold: true, rtl: true, cs: true, highlight: 'yellow' }, + }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); + + it('accepts basedOn: null as an explicit "based on nothing"', () => { + const adapter = makeAdapter(); + executeStylesCreate(adapter, { ...MINIMAL, basedOn: null }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); + + it('accepts a next that names the style being created', () => { + const adapter = makeAdapter(); + executeStylesCreate(adapter, { id: 'Kushya', name: 'Kushya', type: 'paragraph', next: 'Kushya' }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); +}); + +describe('executeStylesCreate: capability', () => { + it('fails closed when the engine provides no create hook', () => { + expectValidationError(() => executeStylesCreate({}, MINIMAL), 'CAPABILITY_UNAVAILABLE', /styles\.create/); + expectValidationError(() => executeStylesCreate(undefined, MINIMAL), 'CAPABILITY_UNAVAILABLE'); + // A host that supplies the key with the wrong type must still fail closed + // rather than crash: failing closed is this operation's whole behaviour today. + expectValidationError(() => executeStylesCreate({ create: 'x' } as never, MINIMAL), 'CAPABILITY_UNAVAILABLE'); + }); + + it('validates input before reporting the capability as unavailable', () => { + // A malformed call must be reported as malformed. Reversing these would + // tell a caller to change runtime when the fix is to change the argument. + expectValidationError(() => executeStylesCreate({}, { id: '', name: 'x', type: 'paragraph' }), 'INVALID_INPUT'); + }); +}); + +describe('executeStylesCreate validation: identity', () => { + it('rejects a non-object input', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, null as never), 'INVALID_INPUT'); + expectValidationError(() => executeStylesCreate(adapter, 'Kushya' as never), 'INVALID_INPUT'); + }); + + it('rejects an unknown field by name', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, styleId: 'Kushya' } as never), + 'INVALID_INPUT', + /styleId/, + ); + }); + + it('rejects a missing or empty id and name', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { name: 'x', type: 'paragraph' } as never), + 'INVALID_INPUT', + /id/, + ); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, id: '' }), 'INVALID_INPUT', /id/); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, name: '' }), 'INVALID_INPUT', /name/); + }); + + it('accepts an id containing a space, as setStyleRef does', () => { + // w:styleId is an ST_String and real documents carry ids with spaces. A + // stricter rule here would let setStyleRef apply a style this cannot author. + const adapter = makeAdapter(); + executeStylesCreate(adapter, { id: 'Body Text', name: 'Body Text', type: 'paragraph' }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); + + it('rejects a type outside paragraph | character', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, type: 'table' } as never), + 'INVALID_INPUT', + /type/, + ); + }); + + it('rejects a non-integer or non-null priority', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, priority: 1.5 }), + 'INVALID_INPUT', + /priority/, + ); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, priority: '1' as never }), 'INVALID_INPUT'); + }); + + it('accepts a priority outside Word’s 0..99 UI band, and null', () => { + // w:uiPriority is an ST_DecimalNumber; the catalogue reads it back as + // `number | null`. Capping it here would reject what getCatalog returns. + const adapter = makeAdapter(); + executeStylesCreate(adapter, { ...MINIMAL, priority: 4000 }); + executeStylesCreate(adapter, { ...MINIMAL, priority: null }); + expect(adapter.create).toHaveBeenCalledTimes(2); + }); + + // Driven off the constant so the assertion cannot drift from it: pinning one + // flag made the whole loop look covered while five of its six were not. + for (const flag of ['qFormat', 'hidden', 'semiHidden', 'unhideWhenUsed', 'locked', 'custom'] as const) { + it(`rejects a non-boolean ${flag}`, () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, [flag]: 'yes' } as never), + 'INVALID_INPUT', + new RegExp(flag), + ); + }); + } + + it('rejects a non-string or empty basedOn', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, basedOn: '' }), 'INVALID_INPUT', /basedOn/); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, basedOn: 123 as never }), + 'INVALID_INPUT', + /basedOn/, + ); + }); + + it('rejects an empty or non-string next', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, next: '' }), 'INVALID_INPUT', /next/); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, next: 5 as never }), + 'INVALID_INPUT', + /next/, + ); + }); + + it('rejects a conflictPolicy outside fail | replace', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, conflictPolicy: 'merge' as never }), + 'INVALID_INPUT', + /conflictPolicy/, + ); + }); +}); + +describe('executeStylesCreate validation: aliases', () => { + it('rejects a non-array', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, aliases: 'Question' as never }), + 'INVALID_INPUT', + ); + }); + + it('rejects an empty alias', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, aliases: [''] }), 'INVALID_INPUT'); + }); + + it('rejects an alias containing a comma', () => { + // w:aliases is one comma-delimited value, so this would read back as two. + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, aliases: ['Question, Answer'] }), + 'INVALID_INPUT', + /comma/, + ); + }); + + it('rejects a duplicate alias', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, aliases: ['Q', 'Q'] }), 'INVALID_INPUT'); + }); + + it('accepts an alias equal to the style name', () => { + // Redundant, not corrupting — and JSON Schema cannot compare sibling + // fields, so rejecting it here would put the validator and the published + // schema into disagreement over an input that harms nothing. + const adapter = makeAdapter(); + executeStylesCreate(adapter, { ...MINIMAL, aliases: ['Kushya'] }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); +}); + +describe('executeStylesCreate validation: character styles carry no paragraph surface', () => { + const CHAR: StylesCreateInput = { id: 'Emphasis2', name: 'Emphasis 2', type: 'character' }; + + it('accepts run properties', () => { + const adapter = makeAdapter(); + executeStylesCreate(adapter, { ...CHAR, run: { bold: true, rtl: true } }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); + + it('rejects next', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...CHAR, next: 'Normal' } as never), + 'INVALID_INPUT', + /next/, + ); + }); + + it('rejects paragraph properties', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...CHAR, paragraph: { keepNext: true } } as never), + 'INVALID_INPUT', + /paragraph/, + ); + }); +}); + +describe('executeStylesCreate validation: patch surface', () => { + it('accepts the four run properties docDefaults forbids', () => { + // The whole point of the `style` scope: a right-to-left style is not + // expressible without w:rtl. + const adapter = makeAdapter(); + executeStylesCreate(adapter, { ...MINIMAL, run: { rtl: true, cs: true, oMath: false, highlight: 'cyan' } }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); + + it('still rejects revision-tracking and self-referential keys', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, run: { rPrChange: {} } as never }), + 'INVALID_INPUT', + ); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, paragraph: { pStyle: 'Normal' } as never }), + 'INVALID_INPUT', + ); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, paragraph: { sectPr: {} } as never }), + 'INVALID_INPUT', + ); + }); + + it('names the offending field as run or paragraph, not as patch', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, run: { nope: true } as never }), + 'INVALID_INPUT', + /^Unknown run key/, + ); + // The "Allowed keys" list is scope-filtered, so it must not offer a key + // this scope would then reject. + try { + executeStylesCreate(adapter, { ...MINIMAL, run: { nope: true } as never }); + } catch (err) { + expect((err as Error).message).not.toContain('rStyle'); + } + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, paragraph: { nope: true } as never }), + 'INVALID_INPUT', + /^Unknown paragraph key/, + ); + }); + + it('reports a cross-channel key against the channel that owns it', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, run: { keepNext: true } as never }), + 'INVALID_INPUT', + /paragraph-channel property/, + ); + }); + + it('validates patch values against the registry schema', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, run: { bold: 'yes' as never } }), + 'INVALID_INPUT', + ); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, paragraph: { outlineLvl: 'one' as never } }), + 'INVALID_INPUT', + ); + }); + + it('rejects an empty patch object on either channel', () => { + // The published schema says minProperties: 1. A caller pre-validating + // against the contract would reject what the library accepted. + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, run: {} }), 'INVALID_INPUT', /run/); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, paragraph: {} }), + 'INVALID_INPUT', + /paragraph/, + ); + }); + + it('rejects a highlight value outside ST_HighlightColor', () => { + // A free string here writes an invalid w:highlight into styles.xml, which + // Word reports as a damaged document rather than as a rejected call. + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, run: { highlight: '#FFFF00' as never } }), + 'INVALID_INPUT', + ); + expectValidationError( + () => executeStylesCreate(adapter, { ...MINIMAL, run: { highlight: 'purple' as never } }), + 'INVALID_INPUT', + ); + executeStylesCreate(adapter, { ...MINIMAL, run: { highlight: 'yellow' } }); + expect(adapter.create).toHaveBeenCalledTimes(1); + }); + + it('rejects a non-object patch on either channel', () => { + // Without the guard these reach Object.keys(null) and throw a raw + // TypeError across the API boundary instead of a validation error. + const adapter = makeAdapter(); + for (const bad of [null, 'x', []] as never[]) { + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, run: bad }), 'INVALID_INPUT'); + expectValidationError(() => executeStylesCreate(adapter, { ...MINIMAL, paragraph: bad }), 'INVALID_INPUT'); + } + }); + + it('reports an excluded key against the style scope, not against docDefaults', () => { + // The message names the destination. Saying "docDefaults" here would tell + // the caller they violated a restriction that does not apply to them. + const adapter = makeAdapter(); + try { + executeStylesCreate(adapter, { ...MINIMAL, run: { rStyle: 'Emphasis' } as never }); + throw new Error('Expected a validation error.'); + } catch (err) { + const e = err as DocumentApiValidationError; + expect(e.code).toBe('INVALID_INPUT'); + expect(e.message).toContain('a named Word style'); + expect(e.message).not.toContain('docDefaults'); + expect((e as unknown as { details: Record }).details?.reason).toBe('excluded_style_key'); + } + }); +}); + +describe('executeStylesCreate validation: options', () => { + it('rejects an unknown options key', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesCreate(adapter, MINIMAL, { force: true } as never), + 'INVALID_INPUT', + /force/, + ); + }); + + it('rejects a non-boolean dryRun and a non-string expectedRevision', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, MINIMAL, { dryRun: 'yes' as never }), 'INVALID_INPUT'); + expectValidationError( + () => executeStylesCreate(adapter, MINIMAL, { expectedRevision: 7 as never }), + 'INVALID_INPUT', + ); + }); + + it('rejects options before reaching the adapter', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesCreate(adapter, MINIMAL, { force: true } as never), 'INVALID_INPUT'); + expect(adapter.create).not.toHaveBeenCalled(); + }); +}); + +/** + * `schemas.ts` writes its XML paths as literals rather than referencing the + * exported constants — `styles.apply` does the same two entries above this one. + * That is deliberate for a published wire format: a schema that follows a + * constant changes the contract whenever the constant moves, silently. What it + * must not do is drift from the type, and `StylesCreateResolution.xmlPath` is + * pinned to `STYLE_XML_PATH`, so this asserts the two still agree. + */ +describe('published xmlPath matches the exported constant', () => { + const schemas = buildInternalContractSchemas().operations; + + function resolutionProperties(operationId: 'styles.create' | 'styles.apply'): Record { + const output = schemas[operationId].output as JsonSchemaNode; + const success = (output.oneOf as JsonSchemaNode[])[0]; + const resolution = (success.properties as Record).resolution; + return resolution.properties as Record; + } + + it('pins styles.create to STYLE_XML_PATH', () => { + expect(resolutionProperties('styles.create').xmlPath.const).toBe(STYLE_XML_PATH); + }); + + it('pins styles.apply to XML_PATH_BY_CHANNEL, which the same file spells out', () => { + const published = resolutionProperties('styles.apply').xmlPath.enum as string[]; + + expect([...published].sort()).toEqual([XML_PATH_BY_CHANNEL.run, XML_PATH_BY_CHANNEL.paragraph].sort()); + }); +}); diff --git a/packages/document-api/src/styles/create.ts b/packages/document-api/src/styles/create.ts new file mode 100644 index 0000000000..00f92340f8 --- /dev/null +++ b/packages/document-api/src/styles/create.ts @@ -0,0 +1,452 @@ +/** + * `styles.create`: define or redefine a named style in `word/styles.xml`. + * + * ## Why the operation exists + * + * Nothing in the Document API brings a named style into existence. + * `styles.apply` writes `w:docDefaults` and is validated as such + * (`target.scope must be "docDefaults"`); `styles.paragraph.setStyle` and + * `setStyleRef` apply a style that is *already* in the document, by `styleId` + * or by one of four semantic roles; `styles.getCatalog` reads. So a caller who + * wants a "Question" or a "Quote" style has one route left: synthesize a whole + * `.docx` and hand it to `templates.apply` — an operation whose input is + * `{ source, bodyPolicy }` with no scope selector, so which parts of the + * document it also adopts is decided by the shape of the synthesized package + * rather than by the caller's request. + * + * `styles.create` is the missing half of `styles.getCatalog`: what the + * catalogue can describe, this can author. + * + * ## Adoption semantics + * + * - The definition **replaces** the element. `w:pPr` and `w:rPr` end up + * carrying exactly the keys the input carried; an absent key is absent from + * the style, not inherited from a previous definition of the same id. This is + * why `conflictPolicy` has no `merge`: the patch types have no way to say + * "remove this property" (no `null`, no `'inherit'`), and the registry + * attaches a per-property merge strategy on a second, independent axis, so a + * merge would have two answers for `borders` and no answer at all for + * removal. + * - `conflictPolicy` is decided on **both** `id` and `name`. Word keys its + * Styles gallery on `w:name`, not on `w:styleId`: two styles with distinct + * ids and one name are two identically-labelled gallery entries. A name that + * collides with a latent style is worse than cosmetic — Word resolves the + * name against `w:latentStyles` and can inherit `w:semiHidden` from it, so + * the call succeeds and the style never appears. + * - Linked styles (`w:link`) are out of scope. A linked style is a *pair*, + * each half naming the other; one call cannot satisfy the first half's + * reference, and there is no atomic pair form here. The catalogue keeps + * reporting `type: 'linked'` on read. + * + * ## What this module does not decide + * + * Whether `basedOn` / `next` resolve, whether the existing style is locked, + * whether `word/styles.xml` is present at all — all need the document, so they + * are the adapter's to report as receipt failures. The one exception is stated + * rather than left implicit: a `next` pointing at **the id being created** is + * legal, and must not be reported as an unresolved reference. + * + * Engine-agnostic contract + execution entry point. No ProseMirror/converter + * imports. + */ + +import { DocumentApiValidationError } from '../errors.js'; +import { assertNoUnknownFields, isRecord } from '../validation-primitives.js'; +import type { ReceiptFailure } from '../types/receipt.js'; +import type { SDHighlightColor } from '../types/sd-props.js'; +import type { StylesParagraphPatch, StylesRunPatch, StylesStateMap } from './apply.js'; +import { validatePatchObject } from './validation.js'; + +/** + * Run properties of a named style. + * + * A superset of {@link StylesRunPatch}: the four properties Word forbids in + * `w:docDefaults` and allows on a `w:style` are reachable here and nowhere + * else. `rtl` in particular is the property that makes a run right-to-left, so + * without it no right-to-left style could be authored through this API. + */ +export interface StyleRunPatch extends StylesRunPatch { + /** `w:cs` — treat the run as complex-script. */ + cs?: boolean; + /** `w:rtl` — right-to-left run. */ + rtl?: boolean; + /** `w:oMath` — the run is part of an equation. */ + oMath?: boolean; + /** `w:highlight` — one of the closed `ST_HighlightColor` tokens. */ + highlight?: SDHighlightColor; +} + +/** What to do when a style with this `id`, or this `name`, already exists. */ +export type StyleConflictPolicy = 'fail' | 'replace'; + +interface StylesCreateCommon { + /** `w:styleId`. Named `id` to match {@link StyleCatalogItem.id} on read. */ + id: string; + /** `w:name`. */ + name: string; + /** `w:basedOn`. `null` states "based on nothing" explicitly. */ + basedOn?: string | null; + /** `w:aliases`, one entry per alias. */ + aliases?: string[]; + /** `w:uiPriority`. Named `priority` to match the catalogue on read. */ + priority?: number | null; + /** `w:qFormat` — offer the style in Word's quick gallery. */ + qFormat?: boolean; + /** `w:hidden`. */ + hidden?: boolean; + /** `w:semiHidden`. */ + semiHidden?: boolean; + /** `w:unhideWhenUsed`. */ + unhideWhenUsed?: boolean; + /** `w:locked`. */ + locked?: boolean; + /** + * `w:customStyle`. Defaults to `true`, which is what a style authored + * through this API is. Setting it `false` on a style whose name is not a + * built-in makes Word treat the name as one and re-label or hide the style. + */ + custom?: boolean; + /** Defaults to `'fail'`. */ + conflictPolicy?: StyleConflictPolicy; +} + +export interface StylesCreateParagraphInput extends StylesCreateCommon { + type: 'paragraph'; + /** `w:next`. May name the style being created. */ + next?: string | null; + /** `w:style/w:pPr`. */ + paragraph?: StylesParagraphPatch; + /** `w:style/w:rPr`. */ + run?: StyleRunPatch; +} + +export interface StylesCreateCharacterInput extends StylesCreateCommon { + type: 'character'; + /** A character style has no following-paragraph style. */ + next?: never; + /** A character style has no paragraph properties. */ + paragraph?: never; + /** `w:style/w:rPr`. */ + run?: StyleRunPatch; +} + +export type StylesCreateInput = StylesCreateParagraphInput | StylesCreateCharacterInput; + +export interface StylesCreateOptions { + dryRun?: boolean; + expectedRevision?: string; +} + +export interface NormalizedStylesCreateOptions { + dryRun: boolean; + expectedRevision: string | undefined; +} + +export interface StylesCreateResolution { + scope: 'style'; + id: string; + type: 'paragraph' | 'character'; + xmlPart: string; + /** + * Literal, not `string`: `styles.apply` pins its two paths in the published + * schema and exports them as constants for the adapter to bind to. Without + * the narrowing an adapter could report the more specific + * `w:styles/w:style[@w:styleId='X']`, type-check, and then fail the schema. + */ + xmlPath: typeof STYLE_XML_PATH; +} + +/** The `w:style` element path. Predicate-free, like `XML_PATH_BY_CHANNEL`. */ +export const STYLE_XML_PATH = 'w:styles/w:style'; + +/** + * Per-channel state, unlike {@link StylesApplyReceipt}'s flat map. + * + * `styles.apply` can be flat because `resolution.channel` says which channel + * the map describes. One `w:style` carries both at once, and `snapToGrid`, + * `shading` and `borders` exist on both — `borders` with genuinely different + * shapes (`w:bdr`, one border, against `w:pBdr`, six edges). Folded together + * they would be indistinguishable. + */ +export interface StyleChannelState { + paragraph: StylesStateMap | null; + run: StylesStateMap | null; +} + +export interface StylesCreateReceiptSuccess { + success: true; + /** `false` when the definition already matched the input. */ + changed: boolean; + /** + * `false` when an existing style was redefined under `conflictPolicy: + * 'replace'`. `created: true` implies `changed: true`. Under `dryRun` it + * describes what the call would have done. + * + * `ReceiptSuccess.inserted` / `updated` are deliberately not used: they take + * `EntityAddress`, and a style definition is not addressable as a document + * entity. + */ + created: boolean; + resolution: StylesCreateResolution; + dryRun: boolean; + /** `null` when the style did not exist. */ + before: StyleChannelState | null; + after: StyleChannelState; +} + +export interface StylesCreateReceiptFailure { + success: false; + failure: ReceiptFailure; +} + +export type StylesCreateReceipt = StylesCreateReceiptSuccess | StylesCreateReceiptFailure; + +export interface StylesCreateAdapter { + create(input: StylesCreateInput, options: NormalizedStylesCreateOptions): StylesCreateReceipt; +} + +export interface StylesCreateApi { + create(input: StylesCreateInput, options?: StylesCreateOptions): StylesCreateReceipt; +} + +const INPUT_ALLOWED_KEYS: ReadonlySet = new Set([ + 'id', + 'name', + 'type', + 'basedOn', + 'next', + 'aliases', + 'priority', + 'qFormat', + 'hidden', + 'semiHidden', + 'unhideWhenUsed', + 'locked', + 'custom', + 'paragraph', + 'run', + 'conflictPolicy', +]); + +const OPTIONS_ALLOWED_KEYS: ReadonlySet = new Set(['dryRun', 'expectedRevision']); + +const STYLE_TYPES: ReadonlySet = new Set(['paragraph', 'character']); +const CONFLICT_POLICIES: ReadonlySet = new Set(['fail', 'replace']); + +const BOOLEAN_FLAGS = ['qFormat', 'hidden', 'semiHidden', 'unhideWhenUsed', 'locked', 'custom'] as const; + +function normalizeOptions(options?: StylesCreateOptions): NormalizedStylesCreateOptions { + return { + dryRun: options?.dryRun ?? false, + expectedRevision: options?.expectedRevision, + }; +} + +/** + * An empty `run: {}` would ask the adapter to write an empty `w:rPr`, and the + * published schema rejects it (`minProperties: 1`). `styles.apply` rejects an + * empty patch for the same reason; omitting the channel is how you say + * "no properties". + */ +function assertNonEmptyPatch(patch: Record, field: string): void { + if (Object.keys(patch).length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', `${field} must include at least one property.`, { field }); + } +} + +function assertNonEmptyString(value: unknown, field: string): void { + if (typeof value !== 'string' || value.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', `${field} must be a non-empty string.`, { field, value }); + } +} + +/** + * Validates the shape of `styles.create` input. + * + * Deliberately shallow on identifiers: `id` is checked for "non-empty string" + * and nothing more, exactly as `styles.paragraph.setStyleRef` checks the same + * field. `w:styleId` is an `ST_String`, real documents carry ids with spaces, + * and a stricter rule here would mean `setStyleRef` could apply a style that + * `styles.create` cannot express. + */ +export function validateStylesCreateInput(input: unknown): asserts input is StylesCreateInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.create input must be a non-null object.'); + } + + assertNoUnknownFields(input, INPUT_ALLOWED_KEYS, 'styles.create'); + + assertNonEmptyString(input.id, 'id'); + assertNonEmptyString(input.name, 'name'); + + if (typeof input.type !== 'string' || !STYLE_TYPES.has(input.type)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `type must be "paragraph" or "character", got ${JSON.stringify(input.type)}.`, + { field: 'type', value: input.type }, + ); + } + const type = input.type as 'paragraph' | 'character'; + + if (input.basedOn !== undefined && input.basedOn !== null) assertNonEmptyString(input.basedOn, 'basedOn'); + + if (input.next !== undefined) { + if (type !== 'paragraph') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + 'next is only valid on a paragraph style; a character style has no following-paragraph style.', + { field: 'next', value: input.next }, + ); + } + if (input.next !== null) assertNonEmptyString(input.next, 'next'); + } + + validateAliases(input.aliases); + + if (input.priority !== undefined && input.priority !== null && !Number.isInteger(input.priority)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'priority must be an integer or null.', { + field: 'priority', + value: input.priority, + }); + } + + for (const flag of BOOLEAN_FLAGS) { + if (input[flag] !== undefined && typeof input[flag] !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', `${flag} must be a boolean.`, { + field: flag, + value: input[flag], + }); + } + } + + if (input.conflictPolicy !== undefined && !CONFLICT_POLICIES.has(input.conflictPolicy as string)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `conflictPolicy must be "fail" or "replace", got ${JSON.stringify(input.conflictPolicy)}.`, + { field: 'conflictPolicy', value: input.conflictPolicy }, + ); + } + + if (input.paragraph !== undefined) { + if (type !== 'paragraph') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + 'paragraph properties are only valid on a paragraph style.', + { field: 'paragraph', value: input.paragraph }, + ); + } + if (!isRecord(input.paragraph)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'paragraph must be a non-null object.', { + field: 'paragraph', + value: input.paragraph, + }); + } + assertNonEmptyPatch(input.paragraph, 'paragraph'); + validatePatchObject(input.paragraph, 'paragraph', 'style', 'paragraph'); + } + + if (input.run !== undefined) { + if (!isRecord(input.run)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'run must be a non-null object.', { + field: 'run', + value: input.run, + }); + } + assertNonEmptyPatch(input.run, 'run'); + validatePatchObject(input.run, 'run', 'style', 'run'); + } +} + +/** + * `w:aliases` is a single element carrying one comma-delimited string, so an + * alias containing a comma is silently split into two by every consumer that + * parses it — including Word. That is the one alias rule worth enforcing here, + * and it is expressible as a `pattern` in the published schema. + * + * An alias equal to the style's own name is deliberately **not** rejected. It + * is redundant rather than corrupting, and JSON Schema cannot compare sibling + * fields — so the rule would live only in the validator, and a caller + * pre-validating against the published contract (which is what the generated + * agent artifacts are for) would get a green light and then a throw. + */ +function validateAliases(value: unknown): void { + if (value === undefined) return; + + if (!Array.isArray(value)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'aliases must be an array of strings.', { + field: 'aliases', + value, + }); + } + + const seen = new Set(); + value.forEach((alias, index) => { + assertNonEmptyString(alias, `aliases[${index}]`); + const text = alias as string; + if (text.includes(',')) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `aliases[${index}] must not contain a comma: w:aliases is a single comma-delimited value, so "${text}" would be read back as two aliases.`, + { field: `aliases[${index}]`, value: text }, + ); + } + if (seen.has(text)) { + throw new DocumentApiValidationError('INVALID_INPUT', `aliases[${index}] duplicates an earlier alias.`, { + field: `aliases[${index}]`, + value: text, + }); + } + seen.add(text); + }); +} + +export function validateStylesCreateOptions(options: unknown): void { + if (options === undefined || options === null) return; + + if (!isRecord(options)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.create options must be a non-null object.'); + } + + assertNoUnknownFields(options, OPTIONS_ALLOWED_KEYS, 'styles.create options'); + + if (options.dryRun !== undefined && typeof options.dryRun !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'options.dryRun must be a boolean.', { + field: 'options.dryRun', + value: options.dryRun, + }); + } + + if (options.expectedRevision !== undefined && typeof options.expectedRevision !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'options.expectedRevision must be a string.', { + field: 'options.expectedRevision', + value: options.expectedRevision, + }); + } +} + +/** + * Executes `styles.create` using the provided adapter. + * + * Fails closed when the host engine has no `create` hook, the same way + * `executeStylesGetCatalog` does for its optional catalogue hook — input is + * validated first, so a malformed call is reported as malformed rather than as + * an unavailable capability. + */ +export function executeStylesCreate( + adapter: Partial | null | undefined, + input: StylesCreateInput, + options?: StylesCreateOptions, +): StylesCreateReceipt { + validateStylesCreateInput(input); + validateStylesCreateOptions(options); + + if (typeof adapter?.create !== 'function') { + throw new DocumentApiValidationError( + 'CAPABILITY_UNAVAILABLE', + 'styles.create is not available. The host engine has not provided an adapter for this capability.', + { operation: 'styles.create' }, + ); + } + + return adapter.create(input, normalizeOptions(options)); +} diff --git a/packages/document-api/src/styles/index.ts b/packages/document-api/src/styles/index.ts index 3e685a2b05..7fcbb3e194 100644 --- a/packages/document-api/src/styles/index.ts +++ b/packages/document-api/src/styles/index.ts @@ -5,11 +5,14 @@ */ // Registry: types, constants, and property definitions -export type { ValueSchema, StylesChannel, MergeStrategy, PropertyDefinition } from './registry.js'; +export type { ValueSchema, StylesChannel, StylesScope, MergeStrategy, PropertyDefinition } from './registry.js'; export { PROPERTY_REGISTRY, ALLOWED_KEYS_BY_CHANNEL, EXCLUDED_KEYS, + STYLE_EXCLUDED_KEYS, + EXCLUDED_KEYS_BY_SCOPE, + SCOPE_LABEL, XML_PATH_BY_CHANNEL, getPropertyDefinition, ST_VERTICAL_ALIGN_RUN, @@ -48,6 +51,30 @@ export type { } from './apply.js'; export { executeStylesApply } from './apply.js'; +// Create: define or redefine a named style (`styles.create`) +export type { + StyleRunPatch, + StyleConflictPolicy, + StyleChannelState, + StylesCreateParagraphInput, + StylesCreateCharacterInput, + StylesCreateInput, + StylesCreateOptions, + NormalizedStylesCreateOptions, + StylesCreateResolution, + StylesCreateReceiptSuccess, + StylesCreateReceiptFailure, + StylesCreateReceipt, + StylesCreateAdapter, + StylesCreateApi, +} from './create.js'; +export { + STYLE_XML_PATH, + executeStylesCreate, + validateStylesCreateInput, + validateStylesCreateOptions, +} from './create.js'; + // Catalog: read-only style catalogue projection (`styles.getCatalog`) export type { StyleCatalogView, @@ -79,4 +106,4 @@ export { // Validation: exported for adapter use (excluded-key checking, patch key classification) export type { PatchKeyClassification } from './validation.js'; -export { validateValue, classifyPatchKey } from './validation.js'; +export { validateValue, classifyPatchKey, validatePatchObject } from './validation.js'; diff --git a/packages/document-api/src/styles/registry.test.ts b/packages/document-api/src/styles/registry.test.ts index 02c1891f8c..9497459129 100644 --- a/packages/document-api/src/styles/registry.test.ts +++ b/packages/document-api/src/styles/registry.test.ts @@ -1,13 +1,18 @@ import { describe, it, expect } from 'bun:test'; -import { PROPERTY_REGISTRY, EXCLUDED_KEYS } from './registry.js'; +import { PROPERTY_REGISTRY, EXCLUDED_KEYS, STYLE_EXCLUDED_KEYS } from './registry.js'; // --------------------------------------------------------------------------- // SD-2018 coverage gate — machine-checked completeness assertion // --------------------------------------------------------------------------- describe('SD-2018 coverage gate', () => { - const registryKeys = (channel: string) => - PROPERTY_REGISTRY.filter((d) => d.channel === channel) + // SD-2018 enumerated what docDefaults accepts. The registry now also backs + // the `style` scope, which reaches four run properties Word forbids in + // docDefaults, so the gate filters to what this scope can actually reach — + // the set it was written to pin — and the style-only additions are pinned + // separately below. + const registryKeys = (channel: 'run' | 'paragraph') => + PROPERTY_REGISTRY.filter((d) => d.channel === channel && !EXCLUDED_KEYS[channel].has(d.key)) .map((d) => d.key) .sort(); @@ -52,6 +57,19 @@ describe('SD-2018 coverage gate', () => { ); }); + it('run channel reaches exactly four more properties under the style scope', () => { + const styleScopeKeys = PROPERTY_REGISTRY.filter( + (d) => d.channel === 'run' && !STYLE_EXCLUDED_KEYS.run.has(d.key), + ).map((d) => d.key); + expect(styleScopeKeys.filter((key) => EXCLUDED_KEYS.run.has(key)).sort()).toEqual( + ['cs', 'highlight', 'oMath', 'rtl'].sort(), + ); + }); + + it('the paragraph channel is the same set in both scopes', () => { + expect([...STYLE_EXCLUDED_KEYS.paragraph.keys()].sort()).toEqual([...EXCLUDED_KEYS.paragraph.keys()].sort()); + }); + it('paragraph channel contains exactly the SD-2018 property set', () => { expect(registryKeys('paragraph')).toEqual( [ diff --git a/packages/document-api/src/styles/registry.ts b/packages/document-api/src/styles/registry.ts index cf26c74f92..62771fe4e7 100644 --- a/packages/document-api/src/styles/registry.ts +++ b/packages/document-api/src/styles/registry.ts @@ -6,7 +6,7 @@ * from here. */ -import { ST_UNDERLINE_VALUES } from '../inline-semantics/token-sets.js'; +import { ST_HIGHLIGHT_VALUES, ST_UNDERLINE_VALUES } from '../inline-semantics/token-sets.js'; // --------------------------------------------------------------------------- // OOXML Token Constants @@ -19,7 +19,7 @@ export const ST_TEXT_DIRECTION = ['lrTb', 'tbRl', 'btLr', 'lrTbV', 'tbRlV', 'tbL export const ST_TEXTBOX_TIGHT_WRAP = ['none', 'allLines', 'firstAndLastLine', 'firstLineOnly', 'lastLineOnly'] as const; export const ST_TEXT_TRANSFORM = ['uppercase', 'none'] as const; export const ST_JUSTIFICATION = ['left', 'center', 'right', 'justify', 'distribute'] as const; -export { ST_UNDERLINE_VALUES }; +export { ST_HIGHLIGHT_VALUES, ST_UNDERLINE_VALUES }; // --------------------------------------------------------------------------- // Value Schema AST @@ -301,6 +301,18 @@ export const PROPERTY_REGISTRY: PropertyDefinition[] = [ { key: 'eastAsianLayout', channel: 'run', schema: EAST_ASIAN_LAYOUT_SCHEMA, mergeStrategy: 'shallowMerge' }, { key: 'fitText', channel: 'run', schema: FIT_TEXT_SCHEMA, mergeStrategy: 'shallowMerge' }, + // Authored on named styles, not on docDefaults. `EXCLUDED_KEYS.run` keeps + // all four out of the `docDefaults` scope, so `styles.apply` is unchanged; + // they are reachable only through `EXCLUDED_KEYS_BY_SCOPE.style`. Each one + // is a property the style model already reads back off a `w:style` + // (`StyleDefinition.runProperties` → `RunProperties.cs | highlight | oMath + // | rtl` in @superdoc/style-engine), so a style the engine can load was + // until now a style this API could not express. + { key: 'cs', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'oMath', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'rtl', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'highlight', channel: 'run', schema: { kind: 'enum', values: ST_HIGHLIGHT_VALUES }, mergeStrategy: 'replace' }, + // ------------------------------------------------------------------------- // Paragraph channel // ------------------------------------------------------------------------- @@ -422,3 +434,55 @@ export const XML_PATH_BY_CHANNEL: Record = { run: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', paragraph: 'w:styles/w:docDefaults/w:pPrDefault/w:pPr', }; + +// --------------------------------------------------------------------------- +// Scopes +// --------------------------------------------------------------------------- + +/** + * Where in `word/styles.xml` a patch is being written. + * + * The exclusion list is a property of the *destination*, not of the property: + * `w:rtl` is disallowed in `w:docDefaults` and perfectly legal on a named + * `w:style`. Before this split, `EXCLUDED_KEYS` was the docDefaults list and + * the only list, so every caller inherited a restriction that only one of + * them was subject to. + * + * `docDefaults` is unchanged and remains what `styles.apply` uses. + */ +export type StylesScope = 'docDefaults' | 'style'; + +/** + * Keys excluded from a named `w:style`. + * + * Narrower than the docDefaults list on the run channel by exactly the four + * properties the style model reads back off a `w:style` (see the registry + * entries). `w:rPrChange` stays out because it is a revision-tracking wrapper + * around document content, and `w:rStyle` because the style model does not + * carry it on `StyleDefinition.runProperties`. + * + * The paragraph channel is unchanged: every key it excludes — `w:cnfStyle`, + * `w:divId`, `w:pPrChange`, `w:pStyle`, `w:pPr/w:rPr`, `w:sectPr` — is as + * out of place inside `w:style/w:pPr` as it is inside docDefaults. Run + * properties on a style live in `w:style/w:rPr`, which is the `run` channel + * here, not in `w:pPr/w:rPr`. + */ +export const STYLE_EXCLUDED_KEYS: Record> = { + run: new Map([ + ['rPrChange', 'w:rPrChange'], + ['rStyle', 'w:rStyle'], + ]), + paragraph: EXCLUDED_KEYS.paragraph, +}; + +/** Exclusion lists by destination. Defaults everywhere are `docDefaults`. */ +export const EXCLUDED_KEYS_BY_SCOPE: Record>> = { + docDefaults: EXCLUDED_KEYS, + style: STYLE_EXCLUDED_KEYS, +}; + +/** Human-readable destination, for validation messages. */ +export const SCOPE_LABEL: Record = { + docDefaults: 'Word docDefaults', + style: 'a named Word style', +}; diff --git a/packages/document-api/src/styles/schema.test.ts b/packages/document-api/src/styles/schema.test.ts new file mode 100644 index 0000000000..58cd15c0c6 --- /dev/null +++ b/packages/document-api/src/styles/schema.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'bun:test'; +import { buildPatchSchema, buildStateSchema } from './schema.js'; +import { ALLOWED_KEYS_BY_CHANNEL, EXCLUDED_KEYS, EXCLUDED_KEYS_BY_SCOPE, type StylesChannel } from './registry.js'; + +const CHANNELS: StylesChannel[] = ['run', 'paragraph']; + +function schemaKeys(channel: StylesChannel, scope?: 'docDefaults' | 'style'): string[] { + const schema = scope ? buildPatchSchema(channel, scope) : buildPatchSchema(channel); + return Object.keys(schema.properties as Record).sort(); +} + +/** + * The published schema and the validator have to accept the same set. + * + * The registry backs two destinations now, so "in the registry" no longer means + * "accepted here". Without this gate a patch schema would advertise properties + * its own validator rejects, and the mismatch is invisible: nothing else in the + * contract compares the two. + */ +describe('buildPatchSchema: scope filtering', () => { + for (const channel of CHANNELS) { + it(`omits every docDefaults-excluded key from the ${channel} schema`, () => { + const published = schemaKeys(channel, 'docDefaults'); + const leaked = published.filter((key) => EXCLUDED_KEYS[channel].has(key)); + expect(leaked).toEqual([]); + }); + + it(`publishes exactly what the ${channel} validator accepts in docDefaults`, () => { + const accepted = [...ALLOWED_KEYS_BY_CHANNEL[channel]].filter((key) => !EXCLUDED_KEYS[channel].has(key)).sort(); + expect(schemaKeys(channel, 'docDefaults')).toEqual(accepted); + }); + + it(`publishes exactly what the ${channel} validator accepts in a named style`, () => { + const accepted = [...ALLOWED_KEYS_BY_CHANNEL[channel]] + .filter((key) => !EXCLUDED_KEYS_BY_SCOPE.style[channel].has(key)) + .sort(); + expect(schemaKeys(channel, 'style')).toEqual(accepted); + }); + + it(`defaults the ${channel} schema to docDefaults when no scope is given`, () => { + // schemas.ts calls buildPatchSchema(channel) with no scope for + // styles.apply. If the default flipped, styles.apply would publish the + // wider style-scope surface. + expect(schemaKeys(channel)).toEqual(schemaKeys(channel, 'docDefaults')); + }); + } + + it('reaches exactly four more run properties under the style scope', () => { + const extra = schemaKeys('run', 'style').filter((key) => !schemaKeys('run', 'docDefaults').includes(key)); + expect(extra.sort()).toEqual(['cs', 'highlight', 'oMath', 'rtl']); + }); + + it('publishes the same paragraph surface in both scopes', () => { + expect(schemaKeys('paragraph', 'style')).toEqual(schemaKeys('paragraph', 'docDefaults')); + }); +}); + +/** + * The receipt state map is scoped for the same reason the patch schema is. + * These objects carry `additionalProperties: false`, so an unscoped state + * schema does not merely mislead — it loosens receipt validation for every + * contract-driven consumer. + */ +describe('buildStateSchema: scope filtering', () => { + const stateKeys = (scope?: 'docDefaults' | 'style') => + Object.keys((scope ? buildStateSchema(scope) : buildStateSchema()).properties as Record).sort(); + + it('omits every docDefaults-excluded key', () => { + const published = stateKeys('docDefaults'); + for (const channel of CHANNELS) { + expect(published.filter((key) => EXCLUDED_KEYS[channel].has(key))).toEqual([]); + } + }); + + it('defaults to docDefaults, which is what styles.apply publishes', () => { + expect(stateKeys()).toEqual(stateKeys('docDefaults')); + }); + + it('reaches exactly four more keys under the style scope', () => { + const extra = stateKeys('style').filter((key) => !stateKeys('docDefaults').includes(key)); + expect(extra.sort()).toEqual(['cs', 'highlight', 'oMath', 'rtl']); + }); +}); + +describe('buildStateSchema: channel narrowing', () => { + const keys = (scope: 'docDefaults' | 'style', channel?: StylesChannel) => + Object.keys(buildStateSchema(scope, channel).properties as Record).sort(); + + it('reports one channel only when a channel is given', () => { + // The whole reason styles.create splits before/after per channel: three + // keys exist on both, and `borders` means w:bdr on one and w:pBdr on the + // other. A folded map cannot say which it described. + expect(keys('style', 'run')).not.toContain('keepNext'); + expect(keys('style', 'paragraph')).not.toContain('rtl'); + expect(keys('style', 'run')).toContain('rtl'); + }); + + it('folds both channels when no channel is given, as styles.apply needs', () => { + const folded = keys('style'); + for (const key of [...keys('style', 'run'), ...keys('style', 'paragraph')]) { + expect(folded).toContain(key); + } + }); +}); diff --git a/packages/document-api/src/styles/schema.ts b/packages/document-api/src/styles/schema.ts index 6953727e70..b1a704395d 100644 --- a/packages/document-api/src/styles/schema.ts +++ b/packages/document-api/src/styles/schema.ts @@ -4,8 +4,8 @@ * Imports only from registry.ts. Consumed by contract/schemas.ts. */ -import type { ValueSchema, StylesChannel } from './registry.js'; -import { PROPERTY_REGISTRY } from './registry.js'; +import type { ValueSchema, StylesChannel, StylesScope } from './registry.js'; +import { EXCLUDED_KEYS_BY_SCOPE, PROPERTY_REGISTRY } from './registry.js'; type JsonSchema = Record; @@ -57,11 +57,20 @@ export function toJsonSchema(schema: ValueSchema): JsonSchema { // Registry → patch schemas (for contract/schemas.ts) // --------------------------------------------------------------------------- -/** Builds a JSON Schema for the patch object of a given channel. */ -export function buildPatchSchema(channel: StylesChannel): JsonSchema { +/** + * Builds a JSON Schema for the patch object of a given channel and scope. + * + * The scope filter is what keeps the published `styles.apply` schema honest: + * the registry now carries run properties that only a named style accepts, and + * a schema that advertised them on `docDefaults` would describe an input the + * validator rejects. + */ +export function buildPatchSchema(channel: StylesChannel, scope: StylesScope = 'docDefaults'): JsonSchema { + const excluded = EXCLUDED_KEYS_BY_SCOPE[scope][channel]; const properties: Record = {}; for (const def of PROPERTY_REGISTRY) { if (def.channel !== channel) continue; + if (excluded.has(def.key)) continue; properties[def.key] = toJsonSchema(def.schema); } return { @@ -72,11 +81,26 @@ export function buildPatchSchema(channel: StylesChannel): JsonSchema { }; } -/** Builds a JSON Schema for the before/after state map covering all registry keys. */ -export function buildStateSchema(): JsonSchema { +/** + * Builds a JSON Schema for the before/after state map of a given scope. + * + * Scoped for the same reason `buildPatchSchema` is: a receipt cannot report + * state for a property the scope does not accept, and advertising one both + * misleads a contract-driven caller and loosens receipt validation, since these + * objects carry `additionalProperties: false`. + * + * `channel` narrows it further, for a receipt that reports the two channels + * separately. Omitted, both fold into one map — correct only where something + * else in the receipt says which channel it describes, as `styles.apply`'s + * `resolution.channel` does. + */ +export function buildStateSchema(scope: StylesScope = 'docDefaults', channel?: StylesChannel): JsonSchema { + const excluded = EXCLUDED_KEYS_BY_SCOPE[scope]; const properties: Record = {}; for (const def of PROPERTY_REGISTRY) { + if (channel !== undefined && def.channel !== channel) continue; + if (excluded[def.channel].has(def.key)) continue; const schema = def.schema; switch (schema.kind) { case 'boolean': diff --git a/packages/document-api/src/styles/validation.test.ts b/packages/document-api/src/styles/validation.test.ts index 3ebe0a33db..45c1f4f882 100644 --- a/packages/document-api/src/styles/validation.test.ts +++ b/packages/document-api/src/styles/validation.test.ts @@ -4,6 +4,7 @@ import { executeStylesGetCatalog, PROPERTY_REGISTRY, EXCLUDED_KEYS, + classifyPatchKey, type StylesAdapter, type StylesApplyReceipt, type ValueSchema, @@ -126,7 +127,10 @@ function invalidValueForSchema(schema: ValueSchema): unknown { // --------------------------------------------------------------------------- describe('styles.apply validation: registry-driven property acceptance', () => { - for (const def of PROPERTY_REGISTRY) { + // Skips the registry entries docDefaults excludes: since the registry also + // backs the `style` scope, "in the registry" no longer implies "accepted by + // styles.apply". The excluded-key suite below covers those. + for (const def of PROPERTY_REGISTRY.filter((d) => !EXCLUDED_KEYS[d.channel].has(d.key))) { it(`accepts valid ${def.channel}.${def.key} (${def.schema.kind})`, () => { const adapter = makeAdapter(); const value = validValueForSchema(def.schema); @@ -180,7 +184,7 @@ describe('styles.getCatalog validation', () => { // --------------------------------------------------------------------------- describe('styles.apply validation: registry-driven type rejection', () => { - for (const def of PROPERTY_REGISTRY) { + for (const def of PROPERTY_REGISTRY.filter((d) => !EXCLUDED_KEYS[d.channel].has(d.key))) { it(`rejects invalid ${def.channel}.${def.key} type`, () => { const adapter = makeAdapter(); const value = invalidValueForSchema(def.schema); @@ -196,6 +200,107 @@ describe('styles.apply validation: registry-driven type rejection', () => { } }); +// --------------------------------------------------------------------------- +// classifyPatchKey — public export, reachable without a scope argument +// --------------------------------------------------------------------------- + +describe('classifyPatchKey scope default', () => { + // Exported from the package root, so an external caller can reach the + // no-scope overload that no internal caller uses: validatePatchObject always + // passes a scope. If the default ever flipped to 'style', styles.apply would + // silently widen for every consumer that classifies keys itself. + it('classifies against docDefaults when no scope is given', () => { + expect(classifyPatchKey('rtl', 'run')).toEqual({ status: 'excluded', reason: 'w:rtl' }); + expect(classifyPatchKey('rtl', 'run', 'docDefaults')).toEqual({ status: 'excluded', reason: 'w:rtl' }); + }); + + it('classifies the same key as valid under the style scope', () => { + expect(classifyPatchKey('rtl', 'run', 'style')).toEqual({ status: 'valid' }); + expect(classifyPatchKey('highlight', 'run', 'style')).toEqual({ status: 'valid' }); + }); + + it('keeps the other three statuses stable across scopes', () => { + expect(classifyPatchKey('bold', 'run', 'style')).toEqual({ status: 'valid' }); + expect(classifyPatchKey('keepNext', 'run', 'style')).toEqual({ + status: 'cross_channel', + ownerChannel: 'paragraph', + }); + expect(classifyPatchKey('nope', 'run', 'style')).toEqual({ status: 'unknown' }); + }); + + it('never offers a docDefaults-excluded key in the styles.apply "Allowed keys" list', () => { + // The registry now also backs the style scope, so the list has to be + // filtered: unfiltered, styles.apply would answer an unknown key by + // offering rtl, cs, highlight and oMath as valid ones — and then reject + // every one of them. + const adapter = makeAdapter(); + try { + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { nope: true } as never, + }); + throw new Error('Expected a validation error.'); + } catch (err) { + const message = (err as Error).message; + expect(message).toContain('Allowed keys:'); + for (const key of EXCLUDED_KEYS.run.keys()) { + expect(message).not.toContain(`${key},`); + } + } + }); + + it('calls a key excluded on both channels excluded, not cross-channel', () => { + // `highlight` is a run property, but docDefaults excludes it from the run + // channel too. Answering "you sent a run property to the paragraph + // channel" would be technically true and useless: the key is not usable on + // either channel here, and only the excluded branch carries a reason. + for (const key of ['highlight', 'rtl', 'cs', 'oMath']) { + expect(classifyPatchKey(key, 'paragraph', 'docDefaults')).toEqual({ + status: 'excluded', + reason: EXCLUDED_KEYS.run.get(key)!, + }); + } + }); + + it('calls the same keys cross-channel under the style scope, where they are usable', () => { + for (const key of ['highlight', 'rtl', 'cs', 'oMath']) { + expect(classifyPatchKey(key, 'paragraph', 'style')).toEqual({ status: 'cross_channel', ownerChannel: 'run' }); + } + }); + + it('reports a key excluded on the other channel as excluded, not unknown', () => { + // Step 4 of the classification. Degrading it to 'unknown' would drop the + // reason a caller needs to understand the rejection. + expect(classifyPatchKey('sectPr', 'run', 'docDefaults')).toEqual({ status: 'excluded', reason: 'w:sectPr' }); + }); + + it('still excludes revision tracking and self-reference under the style scope', () => { + expect(classifyPatchKey('rPrChange', 'run', 'style').status).toBe('excluded'); + expect(classifyPatchKey('rStyle', 'run', 'style').status).toBe('excluded'); + expect(classifyPatchKey('pStyle', 'paragraph', 'style').status).toBe('excluded'); + }); +}); + +describe('styles.apply: cross-channel rejection keeps its reason code', () => { + for (const key of ['highlight', 'rtl', 'cs', 'oMath']) { + it(`rejects run-only "${key}" on the paragraph channel with excluded_docdefaults_key`, () => { + const adapter = makeAdapter(); + try { + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { [key]: true } as never, + }); + throw new Error('Expected a validation error.'); + } catch (err) { + const e = err as DocumentApiValidationError; + expect(e.code).toBe('INVALID_INPUT'); + expect(e.message).toContain('docDefaults'); + expect((e as unknown as { details: Record }).details?.reason).toBe('excluded_docdefaults_key'); + } + }); + } +}); + // --------------------------------------------------------------------------- // Excluded-key tests // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/styles/validation.ts b/packages/document-api/src/styles/validation.ts index 2893d0a248..d0b2d2d1aa 100644 --- a/packages/document-api/src/styles/validation.ts +++ b/packages/document-api/src/styles/validation.ts @@ -7,8 +7,18 @@ import { DocumentApiValidationError } from '../errors.js'; import { isRecord } from '../validation-primitives.js'; -import type { ValueSchema, StylesChannel } from './registry.js'; -import { ALLOWED_KEYS_BY_CHANNEL, EXCLUDED_KEYS, getPropertyDefinition } from './registry.js'; +import type { ValueSchema, StylesChannel, StylesScope } from './registry.js'; +import { ALLOWED_KEYS_BY_CHANNEL, EXCLUDED_KEYS_BY_SCOPE, SCOPE_LABEL, getPropertyDefinition } from './registry.js'; + +/** + * Error-detail code per scope. `docDefaults` keeps the code it has always + * emitted; callers switching on it must not have to learn a new one because a + * second scope appeared. + */ +const EXCLUSION_REASON_CODE: Record = { + docDefaults: 'excluded_docdefaults_key', + style: 'excluded_style_key', +}; // --------------------------------------------------------------------------- // Recursive ValueSchema validation @@ -197,41 +207,7 @@ export function validateStylesApplyInput(input: unknown): asserts input is Style throw new DocumentApiValidationError('INVALID_INPUT', 'patch must include at least one property.'); } - const allowedKeys = ALLOWED_KEYS_BY_CHANNEL[channel]; - - for (const key of patchKeys) { - const classification = classifyPatchKey(key, channel); - - switch (classification.status) { - case 'valid': - break; - - case 'excluded': - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `patch key '${key}' is not valid in Word docDefaults (${classification.reason}). This is an intentional restriction per MS-OI29500.`, - { field: 'patch', key, reason: 'excluded_docdefaults_key' }, - ); - - case 'cross_channel': - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `Unknown patch key "${key}" for channel "${channel}". "${key}" is a ${classification.ownerChannel}-channel property. Allowed keys: ${[...allowedKeys].join(', ')}.`, - { field: 'patch', key }, - ); - - case 'unknown': - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `Unknown patch key "${key}" for channel "${channel}". Allowed keys: ${[...allowedKeys].join(', ')}.`, - { field: 'patch', key }, - ); - } - - // Validate the value against the registry schema - const def = getPropertyDefinition(key, channel); - if (def) validateValue(`patch.${key}`, patch[key], def.schema); - } + validatePatchObject(patch, channel, 'docDefaults', 'patch'); } export function validateStylesApplyOptions(options: unknown): void { @@ -284,9 +260,15 @@ export type PatchKeyClassification = * nesting conditionals across excluded-key maps, allowed-key sets, and * cross-channel lookups. */ -export function classifyPatchKey(key: string, channel: StylesChannel): PatchKeyClassification { +export function classifyPatchKey( + key: string, + channel: StylesChannel, + scope: StylesScope = 'docDefaults', +): PatchKeyClassification { + const excluded = EXCLUDED_KEYS_BY_SCOPE[scope]; + // 1. Excluded on the requested channel - const excludedReason = EXCLUDED_KEYS[channel].get(key); + const excludedReason = excluded[channel].get(key); if (excludedReason !== undefined) { return { status: 'excluded', reason: excludedReason }; } @@ -296,14 +278,18 @@ export function classifyPatchKey(key: string, channel: StylesChannel): PatchKeyC return { status: 'valid' }; } - // 3. Belongs to the other channel + // 3. Belongs to the other channel — and is reachable there in this scope. + // The scope test is what keeps the ordering honest: `highlight` is a run + // property, but in docDefaults it is excluded from the run channel too, so + // "you sent a run property to the paragraph channel" would be the wrong + // answer. It is excluded outright, and step 4 says so with a reason. const otherChannel: StylesChannel = channel === 'run' ? 'paragraph' : 'run'; - if (ALLOWED_KEYS_BY_CHANNEL[otherChannel].has(key)) { + if (ALLOWED_KEYS_BY_CHANNEL[otherChannel].has(key) && !excluded[otherChannel].has(key)) { return { status: 'cross_channel', ownerChannel: otherChannel }; } // 4. Excluded on the other channel (still an exclusion, not "unknown") - const otherExcludedReason = EXCLUDED_KEYS[otherChannel].get(key); + const otherExcludedReason = excluded[otherChannel].get(key); if (otherExcludedReason !== undefined) { return { status: 'excluded', reason: otherExcludedReason }; } @@ -312,6 +298,58 @@ export function classifyPatchKey(key: string, channel: StylesChannel): PatchKeyC return { status: 'unknown' }; } +/** + * Validates the keys and values of a patch object against one channel. + * + * Shared by `styles.apply` (scope `docDefaults`) and `styles.create` (scope + * `style`); `field` names the caller's own input path so the error points at + * `patch` or at `run` / `paragraph` rather than at whichever one happened to + * write this code. + */ +export function validatePatchObject( + patch: Record, + channel: StylesChannel, + scope: StylesScope, + field: string, +): void { + const allowedKeys = [...ALLOWED_KEYS_BY_CHANNEL[channel]].filter( + (key) => !EXCLUDED_KEYS_BY_SCOPE[scope][channel].has(key), + ); + + for (const key of Object.keys(patch)) { + const classification = classifyPatchKey(key, channel, scope); + + switch (classification.status) { + case 'valid': + break; + + case 'excluded': + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${field} key '${key}' is not valid in ${SCOPE_LABEL[scope]} (${classification.reason}). This is an intentional restriction per MS-OI29500.`, + { field, key, reason: EXCLUSION_REASON_CODE[scope] }, + ); + + case 'cross_channel': + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown ${field} key "${key}" for channel "${channel}". "${key}" is a ${classification.ownerChannel}-channel property. Allowed keys: ${allowedKeys.join(', ')}.`, + { field, key }, + ); + + case 'unknown': + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown ${field} key "${key}" for channel "${channel}". Allowed keys: ${allowedKeys.join(', ')}.`, + { field, key }, + ); + } + + const def = getPropertyDefinition(key, channel); + if (def) validateValue(`${field}.${key}`, patch[key], def.schema); + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/types/sd-props.ts b/packages/document-api/src/types/sd-props.ts index a5108e53c5..5be25a8508 100644 --- a/packages/document-api/src/types/sd-props.ts +++ b/packages/document-api/src/types/sd-props.ts @@ -10,6 +10,8 @@ // Color and font primitives // --------------------------------------------------------------------------- +import type { ST_HIGHLIGHT_VALUES } from '../inline-semantics/token-sets.js'; + export type SDThemeFontRef = | 'majorAscii' | 'majorHAnsi' @@ -61,24 +63,7 @@ export type SDColorRef = // Inline formatting primitives // --------------------------------------------------------------------------- -export type SDHighlightColor = - | 'black' - | 'blue' - | 'cyan' - | 'green' - | 'magenta' - | 'red' - | 'yellow' - | 'white' - | 'darkBlue' - | 'darkCyan' - | 'darkGreen' - | 'darkMagenta' - | 'darkRed' - | 'darkYellow' - | 'darkGray' - | 'lightGray' - | 'none'; +export type SDHighlightColor = (typeof ST_HIGHLIGHT_VALUES)[number]; export type SDUnderlineStyle = | 'none' diff --git a/tests/consumer-typecheck/src/document-api-styles-create.ts b/tests/consumer-typecheck/src/document-api-styles-create.ts new file mode 100644 index 0000000000..1a4c50c70b --- /dev/null +++ b/tests/consumer-typecheck/src/document-api-styles-create.ts @@ -0,0 +1,50 @@ +import type { DocumentApi } from 'superdoc'; + +declare const api: DocumentApi; + +// Parameter shape: a paragraph style, including the run properties that are +// reachable only on a named style (`w:rtl`, `w:cs`) and not in docDefaults. +const receipt = api.styles.create( + { + id: 'Kushya', + name: 'Kushya', + type: 'paragraph', + basedOn: 'Normal', + next: 'Terutz', + aliases: ['Question'], + priority: 21, + qFormat: true, + custom: true, + conflictPolicy: 'replace', + paragraph: { keepNext: true, rightToLeft: true }, + run: { bold: true, rtl: true, cs: true }, + }, + { dryRun: true }, +); + +// Return shape. +if (receipt.success) { + const changed: boolean = receipt.changed; + const created: boolean = receipt.created; + const scope: 'style' = receipt.resolution.scope; + const styleId: string = receipt.resolution.id; + const styleType: 'paragraph' | 'character' = receipt.resolution.type; + const xmlPart: string = receipt.resolution.xmlPart; + // Per channel, so a style carrying both `w:pPr` and `w:rPr` stays readable. + const runState = receipt.after.run; + const paragraphState = receipt.before?.paragraph ?? null; + void [changed, created, scope, styleId, styleType, xmlPart, runState, paragraphState]; +} else { + const code: string = receipt.failure.code; + void code; +} + +// A character style takes run properties and nothing paragraph-shaped. +const characterReceipt = api.styles.create({ + id: 'Emphasis2', + name: 'Emphasis 2', + type: 'character', + run: { italic: true, highlight: 'yellow' }, +}); + +void characterReceipt;