Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/config/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/tests/export.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
2 changes: 2 additions & 0 deletions packages/document-api/src/contract/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' ||
Expand Down
28 changes: 28 additions & 0 deletions packages/document-api/src/contract/operation-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@
// ---------------------------------------------------------------------------
const NONE_FAILURES: readonly ReceiptFailureCode[] = [];
const NONE_THROWS: readonly PreApplyThrowCode[] = [];
const INVALID_FRAGMENT_FAILURES: readonly ReceiptFailureCode[] = ['INVALID_FRAGMENT'];

Check warning on line 576 in packages/document-api/src/contract/operation-definitions.ts

View workflow job for this annotation

GitHub Actions / Core

eslint(no-unused-vars)

Variable 'INVALID_FRAGMENT_FAILURES' is declared but never used. Unused variables should start with a '_'.
const FOOTNOTE_MUTATION_FAILURES: readonly ReceiptFailureCode[] = ['INVALID_FRAGMENT', 'CAPABILITY_UNAVAILABLE'];
function readOperation(
options: {
Expand Down Expand Up @@ -1264,6 +1264,34 @@
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: styles.create returns a StylesCreateReceipt, but its command metadata omits returnsReceipt: true; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/contract/operation-definitions.ts, line 1290:

<comment>`styles.create` returns a `StylesCreateReceipt`, but its command metadata omits `returnsReceipt: true`; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.</comment>

<file context>
@@ -1264,6 +1264,34 @@ export const OPERATION_DEFINITIONS = {
+      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',
</file context>
Suggested change
historyUnsafe: true,
historyUnsafe: true,
returnsReceipt: true,

}),
referenceDocPath: 'styles/create.mdx',
referenceGroup: 'styles',
},
'styles.getCatalog': {
memberPath: 'styles.getCatalog',
description:
Expand Down
4 changes: 4 additions & 0 deletions packages/document-api/src/contract/operation-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ import type {
StylesApplyInput,
StylesApplyOptions,
StylesApplyReceipt,
StylesCreateInput,
StylesCreateOptions,
StylesCreateReceipt,
StylesGetCatalogInput,
StylesGetCatalogResult,
} from '../styles/index.js';
Expand Down Expand Up @@ -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;
Expand Down
95 changes: 95 additions & 0 deletions packages/document-api/src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5108,6 +5108,101 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
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' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The published schema hardcodes xmlPath: { const: 'w:styles/w:style' }, duplicating the STYLE_XML_PATH constant exported from create.ts and pinned as the StylesCreateResolution.xmlPath type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports STYLE_XML_PATH), reference the constant here instead so the schema and the resolution type cannot diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/contract/schemas.ts, line 5169:

<comment>The published schema hardcodes `xmlPath: { const: 'w:styles/w:style' }`, duplicating the `STYLE_XML_PATH` constant exported from create.ts and pinned as the `StylesCreateResolution.xmlPath` type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports `STYLE_XML_PATH`), reference the constant here instead so the schema and the resolution type cannot diverge.</comment>

<file context>
@@ -5122,6 +5122,101 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
+        id: { type: 'string', minLength: 1 },
+        type: { enum: ['paragraph', 'character'] },
+        xmlPart: { type: 'string' },
+        xmlPath: { const: 'w:styles/w:style' },
+      },
+      ['scope', 'id', 'type', 'xmlPart', 'xmlPath'],
</file context>

},
['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'];
Expand Down
77 changes: 77 additions & 0 deletions packages/document-api/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
71 changes: 68 additions & 3 deletions packages/document-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
*/
Expand Down Expand Up @@ -2078,7 +2110,7 @@ export interface DocumentApiAdapters {
comments: CommentsAdapter;
write: WriteAdapter;
selectionMutation: SelectionMutationAdapter;
styles: StylesAdapter;
styles: StylesAdapter & Partial<StylesCreateAdapter>;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
templates: TemplatesAdapter;
trackChanges: TrackChangesAdapter;
create: CreateAdapter;
Expand Down Expand Up @@ -2249,6 +2281,24 @@ const ADAPTER_GATED_PREFIXES = [
'authorities',
'export',
] as const;

/**
* Operations gated on one optional *method* of a namespace adapter that is
* itself present, so {@link ADAPTER_GATED_PREFIXES} cannot reach them.
*
* Without this the snapshot advertises an operation whose only possible answer
* is `CAPABILITY_UNAVAILABLE`, and a caller selecting capabilities from it
* picks an operation that cannot run. Only operations this package adds are
* listed: changing what an already-shipped operation advertises is a separate
* change, not a side effect of this one.
*/
const HOOK_GATED_OPERATIONS: ReadonlyArray<{
readonly operationId: OperationId;
readonly hasHook: (adapters: DocumentApiAdapters) => boolean;
// Optional chaining despite the required type: the namespace loop above
// tolerates a missing adapter, and a JavaScript host can pass one.
}> = [{ operationId: 'styles.create', hasHook: (a) => typeof a.styles?.create === 'function' }];

export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi {
const rawCapFn = () => executeCapabilities(adapters.capabilities);
const capFn = (): DocumentApiCapabilities => {
Expand All @@ -2266,6 +2316,18 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi {
cap.reasons = [...(cap.reasons ?? []), 'NAMESPACE_UNAVAILABLE'];
}
}
// Then the same gate one level down, for a hook rather than a namespace.
for (const { operationId, hasHook } of HOOK_GATED_OPERATIONS) {
if (hasHook(adapters)) continue;
// An engine older than the operation reports no entry for it at all,
// which already says unavailable; there is nothing to correct.
const cap = caps.operations[operationId];
if (!cap) continue;
cap.available = false;
cap.tracked = false;
cap.dryRun = false;
cap.reasons = [...(cap.reasons ?? []), 'OPERATION_UNAVAILABLE'];
}
return caps;
};
const capabilities: CapabilitiesApi = Object.assign(capFn, {
Expand Down Expand Up @@ -2439,6 +2501,9 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi {
apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt {
return executeStylesApply(adapters.styles, input, options);
},
create(input: StylesCreateInput, options?: StylesCreateOptions): StylesCreateReceipt {
return executeStylesCreate(adapters.styles, input, options);
},
getCatalog(input?: StylesGetCatalogInput): StylesGetCatalogResult {
return executeStylesGetCatalog(adapters.styles, input);
},
Expand Down
Loading
Loading