From 5c63134d9570581c861b45753d90205d92af79c8 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Fri, 4 Sep 2026 12:28:40 +0300 Subject: [PATCH 1/3] feat(document-api): create named paragraph and character styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, whose input is { source, bodyPolicy } with no scope selector — which parts of the document it also adopts is then 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. Contract only. The adapter that writes word/styles.xml is not in this repository, so the operation reports CAPABILITY_UNAVAILABLE until the engine side lands — the same way styles.getCatalog fails closed when its optional catalogue hook is absent. Two consequences are deliberate and worth stating rather than discovering: possibleFailureCodes is NONE_FAILURES, because with no adapter there is no code a host can currently produce and the contract must not over-declare; and a merged contract is advertised by capabilities.get() before it can succeed, which is a decision for the maintainers, not a side effect to be discovered after merge. Design notes, each of which had a plausible alternative: - Named .create, not .define. The create.* namespace is body content, but the .create leaf is already how this codebase names a durable object that lives in an auxiliary part and is referenced from the body — lists.create writes a numbering definition into word/numbering.xml, which is structurally the same operation on a different part. - The input is a discriminated union rather than one flat interface with cross-field checks, matching StylesApplyInput and ListsCreateInput. A character style cannot carry next or paragraph properties, and that is now a compile error rather than a runtime one. - Fields are named id and priority, not styleId and uiPriority, so that what StyleCatalogItem reads back is what this writes. priority stays number | null and is not capped at Word's 0..99 UI band, because the catalogue can return values outside it and a cap here would break the round trip. - The exclusion list is now a property of the destination. EXCLUDED_KEYS was the docDefaults list and the only list, so every caller inherited a restriction only one of them was subject to. Its four run entries that Word forbids in docDefaults and allows on a named style — w:cs, w:highlight, w:oMath, w:rtl — join the registry and are reachable under the new style scope alone. w:rtl is the property that makes a run right-to-left, so without this split no right-to-left style could be authored through this API at all. Each of the four is already read back off a w:style by StyleDefinition.runProperties in the style engine. styles.apply is unchanged in every observable way, and two of those ways took a fix to keep. buildStateSchema is scoped as well as buildPatchSchema, or the four keys would have landed in the published before/after maps of a docDefaults receipt — the exact keys that scope rejects, in objects carrying additionalProperties: false, so receipt validation would have loosened too. And classifyPatchKey now asks whether a key is reachable on the other channel *in this scope*: putting the four in ALLOWED_KEYS_BY_CHANNEL.run meant a run property sent to the paragraph channel started answering cross_channel instead of excluded, dropping the excluded_docdefaults_key reason a caller branches on. Both are covered by tests that fail without the fix. What remains: same accepted keys, same rejection messages, same detail codes, same published input and output schemas. - before/after are per channel, in the published schema as well as in the types. styles.apply can use a flat map because resolution.channel says which channel it 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). buildStateSchema therefore takes an optional channel: without it the receipt schema would have folded the two back together and accepted rtl under `after.paragraph`, which is the exact confusion the split exists to prevent. - The one alias rule that is not enforced: an alias equal to the style's own name. 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 would get a green light and then a throw. The comma rule, which does corrupt, is enforced in both and expressed as a pattern. - No new failure codes. DUPLICATE_ID, PRECONDITION_FAILED, LOCK_VIOLATION and STYLE_CONFLICT already cover every case; STYLE_CONFLICT in particular was unclaimed and is exactly the name collision this operation has to report. The published failure schema types `code` as a string rather than an enum derived from possibleFailureCodes: that list is empty while the operation ships without an adapter, and an empty enum does not compile in a 2020-12 validator — it would have taken the whole output oneOf with it, so a consumer could not have validated even a success receipt. styles.apply publishes the same open shape. - idempotency is conditional, not idempotent. Under the default conflictPolicy 'fail' a second identical call fails; only 'replace' makes it repeatable. The value is published to the reference page and the agent artifacts, so declaring it idempotent would invite an orchestrator to replay the call after a transport timeout and take a hard conflict — or, under replace, silently clobber a style someone edited in between. - highlight is the closed ST_HighlightColor enumeration, not a free string. A free string writes an invalid w:highlight into styles.xml, which Word reports as a damaged document rather than as a rejected call. The token list moves to inline-semantics/token-sets.ts beside the other ST_* sets, and SDHighlightColor is now derived from it so the two cannot drift. - An empty run: {} or paragraph: {} is rejected, matching styles.apply and the published minProperties: 1. Omitting the channel is how you say "no properties"; an empty object asked the adapter to write an empty w:rPr and disagreed with the contract a caller may pre-validate against. - conflictPolicy is decided on both id and name. Word keys its Styles gallery on w:name: two styles with distinct ids and one name are two identically labelled entries, and a name that collides with a latent style is resolved by Word against w:latentStyles, which can inherit w:semiHidden and leave a successful call with an invisible style. Out of scope for this first version, and deliberately: merge semantics, since the patch types cannot express removal and the registry attaches a per-property merge strategy on a second axis; linked pairs, since each half names the other and one call cannot satisfy the first; table and numbering styles, which have no patch surface here; and w:default, which is a singleton per type. Tests: validation and routing in styles/create.test.ts, scope filtering of both schema builders in styles/schema.test.ts, direct coverage of the public classifyPatchKey export, an invoke parity case, and a consumer-typecheck fixture asserting both the parameter and the return shape. The SD-2018 registry gate pins each scope separately. Every branch was checked by mutation: each test was confirmed to fail against the corresponding broken code, not merely to pass against the working code. --- .../src/contract/contract.test.ts | 2 + .../src/contract/operation-definitions.ts | 28 ++ .../src/contract/operation-registry.ts | 4 + packages/document-api/src/contract/schemas.ts | 95 ++++ packages/document-api/src/index.ts | 41 +- .../src/inline-semantics/token-sets.ts | 31 ++ .../document-api/src/invoke/invoke.test.ts | 28 +- packages/document-api/src/invoke/invoke.ts | 1 + .../document-api/src/styles/create.test.ts | 446 +++++++++++++++++ packages/document-api/src/styles/create.ts | 452 ++++++++++++++++++ packages/document-api/src/styles/index.ts | 31 +- .../document-api/src/styles/registry.test.ts | 24 +- packages/document-api/src/styles/registry.ts | 68 ++- .../document-api/src/styles/schema.test.ts | 104 ++++ packages/document-api/src/styles/schema.ts | 36 +- .../src/styles/validation.test.ts | 109 ++++- .../document-api/src/styles/validation.ts | 122 +++-- packages/document-api/src/types/sd-props.ts | 21 +- .../src/document-api-styles-create.ts | 50 ++ 19 files changed, 1613 insertions(+), 80 deletions(-) create mode 100644 packages/document-api/src/styles/create.test.ts create mode 100644 packages/document-api/src/styles/create.ts create mode 100644 packages/document-api/src/styles/schema.test.ts create mode 100644 tests/consumer-typecheck/src/document-api-styles-create.ts 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 9c776774a6..9f0321a7c6 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 cc72a7a465..5a10b08626 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -5122,6 +5122,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.ts b/packages/document-api/src/index.ts index 44f56c24eb..5d4416fa8e 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; @@ -2439,6 +2471,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..20d3f65fe5 100644 --- a/packages/document-api/src/inline-semantics/token-sets.ts +++ b/packages/document-api/src/inline-semantics/token-sets.ts @@ -67,6 +67,37 @@ export const ST_UNDERLINE_VALUE_SET: ReadonlySet = new Set(ST_UNDERLINE_ /** * Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97). */ +// --------------------------------------------------------------------------- +// 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. + * {@link SDHighlightColor} is derived from this list so the two cannot drift. + */ +export const ST_HIGHLIGHT_VALUES = [ + 'black', + 'blue', + 'cyan', + 'green', + 'magenta', + 'red', + 'yellow', + 'white', + 'darkBlue', + 'darkCyan', + 'darkGreen', + 'darkMagenta', + 'darkRed', + 'darkYellow', + 'darkGray', + 'lightGray', + 'none', +] as const; + +export const ST_HIGHLIGHT_VALUE_SET: ReadonlySet = new Set(ST_HIGHLIGHT_VALUES); + export const ST_THEME_COLOR_VALUES = [ 'dark1', 'light1', 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 224d2bf765..2ac6884e67 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -108,6 +108,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..296a5a070d --- /dev/null +++ b/packages/document-api/src/styles/create.test.ts @@ -0,0 +1,446 @@ +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'; + +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(); + }); +}); 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; From 7f9107b5aad8a3edbb4f471e0d22583d0b819866 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Sat, 5 Sep 2026 19:57:39 +0300 Subject: [PATCH 2/3] fix(document-api): gate styles.create on its adapter hook Review found that capabilities.get() reported the operation available on a host that cannot run it. createDocumentApi already gates operations on adapter presence -- ADAPTER_GATED_PREFIXES marks a whole namespace unavailable with NAMESPACE_UNAVAILABLE -- but it could not reach this one: adapters.styles is present, and only its optional create hook is missing. A second gate now runs at hook granularity with the existing OPERATION_UNAVAILABLE reason code. It is scoped to the operation this branch adds. styles.getCatalog and capabilities.check have the same shape, but changing what an already-shipped operation advertises is a separate change, not a side effect of this one. Two details are load-bearing and pinned by tests. The check reads adapters.styles?.create, because the namespace loop above already tolerates a missing adapter and a JavaScript host can pass one. And an operation the engine's snapshot does not mention is left alone rather than invented: an absent entry already says unavailable, and adding one would claim the engine reported something it did not. This also corrects the branch's own claim that a merged contract would be advertised before it can succeed. It no longer is. What remains is that a feat: release publishes the operation into the schemas, the agent artifacts and the reference site regardless, and OperationDefinitionEntry still has no status field -- the runtime can now say "not implemented here", the contract cannot. Also from the review: - The ST_HighlightColor block had landed between the ST_ThemeColor section header and its doc comment, orphaning both. Moved below ST_THEME_COLOR_VALUE_SET, and the set carries the runtime-set doc comment its neighbours have. - schemas.ts keeps its xmlPath literal rather than referencing STYLE_XML_PATH, and gains a test instead. A published wire format that follows a constant changes whenever the constant moves, silently, which is the failure it most needs to avoid; styles.apply spells out its own two paths for the same reason. What a schema must not do is drift from the type, so the test asserts const === STYLE_XML_PATH, and the same assertion is added for styles.apply against XML_PATH_BY_CHANNEL. - returnsReceipt is deliberately still unset. The field marks a result that follows the shared ReceiptSuccess/ReceiptFailure envelope, which every one of the sixteen operations carrying it does -- TextMutationReceipt is (ReceiptSuccess & { resolution }) | (ReceiptFailureResult & { resolution }). StylesCreateReceiptSuccess deliberately does not extend ReceiptSuccess, because inserted/updated take EntityAddress and a style definition is not addressable as a document entity. 93 receipt-returning operations omit the marker, styles.apply among them. And from CI rather than review: apps/docs/config/routes.json was missing /document-api/reference/styles/create/, which failed Docs check:redirects. The generator discovers routes by scanning the built export, so producing it the usual way needs a full docs build; the manifest is append-only and written as a sorted union through JSON.stringify with two-space indent, so the entry was produced through that same serialisation and verified byte-identical to it rather than placed by hand. The docs redirect suite passes, 32 of 32. Seven mutations, each confirmed to fail the corresponding test. --- apps/docs/config/routes.json | 1 + packages/document-api/src/index.test.ts | 77 +++++++++++++++++++ packages/document-api/src/index.ts | 30 ++++++++ .../src/inline-semantics/token-sets.ts | 56 +++++++------- .../document-api/src/styles/create.test.ts | 39 ++++++++++ 5 files changed, 177 insertions(+), 26 deletions(-) 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/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 5d4416fa8e..00a60b7c45 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -2281,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 => { @@ -2298,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, { diff --git a/packages/document-api/src/inline-semantics/token-sets.ts b/packages/document-api/src/inline-semantics/token-sets.ts index 20d3f65fe5..cde79ff063 100644 --- a/packages/document-api/src/inline-semantics/token-sets.ts +++ b/packages/document-api/src/inline-semantics/token-sets.ts @@ -67,6 +67,31 @@ export const ST_UNDERLINE_VALUE_SET: ReadonlySet = new Set(ST_UNDERLINE_ /** * Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97). */ +export const ST_THEME_COLOR_VALUES = [ + 'dark1', + 'light1', + 'dark2', + 'light2', + 'accent1', + 'accent2', + 'accent3', + 'accent4', + 'accent5', + 'accent6', + 'hyperlink', + 'followedHyperlink', + 'background1', + 'text1', + 'background2', + 'text2', + 'none', +] as const; + +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) // --------------------------------------------------------------------------- @@ -74,7 +99,10 @@ export const ST_UNDERLINE_VALUE_SET: ReadonlySet = new Set(ST_UNDERLINE_ /** * Accepted `w:val` values for `w:highlight`. A closed enumeration, unlike a * colour value: anything outside it produces a document Word offers to repair. - * {@link SDHighlightColor} is derived from this list so the two cannot drift. + * + * 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', @@ -96,29 +124,5 @@ export const ST_HIGHLIGHT_VALUES = [ 'none', ] as const; +/** Runtime set for O(1) ST_HighlightColor validation. */ export const ST_HIGHLIGHT_VALUE_SET: ReadonlySet = new Set(ST_HIGHLIGHT_VALUES); - -export const ST_THEME_COLOR_VALUES = [ - 'dark1', - 'light1', - 'dark2', - 'light2', - 'accent1', - 'accent2', - 'accent3', - 'accent4', - 'accent5', - 'accent6', - 'hyperlink', - 'followedHyperlink', - 'background1', - 'text1', - 'background2', - 'text2', - 'none', -] as const; - -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); diff --git a/packages/document-api/src/styles/create.test.ts b/packages/document-api/src/styles/create.test.ts index 296a5a070d..4d1f00ec2b 100644 --- a/packages/document-api/src/styles/create.test.ts +++ b/packages/document-api/src/styles/create.test.ts @@ -7,6 +7,16 @@ import { 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 { @@ -444,3 +454,32 @@ describe('executeStylesCreate validation: options', () => { 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()); + }); +}); From 0fe340180de919b7c1d459a0415ab5e6b08d9eb8 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Sat, 5 Sep 2026 21:00:04 +0300 Subject: [PATCH 3/3] test(docs): count the operation this branch adds apps/docs/tests/export.test.mjs pins the reference landing copy, and the page renders the count from the contract -- Object.keys(model.operations).length in components/document-api-reference/index.tsx. Adding an operation moves it from 427 to 428, so the assertion has to move with it. This is the third CI failure on this branch and all three were the same mistake, not three mistakes: every one of them lived in the Docs job, and every one of them was invisible without running that job. The document-api gates I did run locally passed on all three pushes, which is exactly why they were reassuring and wrong -- what they measured was "the gates I know how to run pass", not "the branch is green". Five of the fourteen Docs steps read apps/docs/out and therefore need a full docs build: check:links, check:redirects, test:export, test:migration-agent-prompt, and the build itself, which writes _redirects. All three failures were in that group. test:migration-agent-prompt is the one worth naming: nothing about its name says it reads build output, and run without a build it fails with ENOENT on out/md/**, which reads as a regression rather than a missing precondition. The manifest entry in the previous commit and this count are both facts about the operation set that live in committed files, so adding an operation to this package is not a packages/document-api change alone. --- apps/docs/tests/export.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/);