feat(document-api): create named paragraph and character styles - #3976
feat(document-api): create named paragraph and character styles#3976Nathaniel-260 wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
4 issues found across 19 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/document-api/src/inline-semantics/token-sets.ts">
<violation number="1" location="packages/document-api/src/inline-semantics/token-sets.ts:70">
P3: The new ST_HighlightColor block was inserted between the ST_ThemeColor section header comment and its doc comment/array, so the `// ST_ThemeColor (underline rich attrs: w:themeColor)` header now sits directly above the highlight section and is separated from the values it describes. Move the highlight block after ST_THEME_COLOR_VALUE_SET (or before the ST_ThemeColor header) so each section header is adjacent to its own content.</violation>
</file>
<file name="packages/document-api/src/index.ts">
<violation number="1" location="packages/document-api/src/index.ts:2113">
P2: When the host omits `adapters.styles.create`, `capabilities.get()` can still report `styles.create` as available, but `api.styles.create()` throws `CAPABILITY_UNAVAILABLE`. Gate this operation in the capability snapshot, or derive its availability from the hook, so callers do not select an advertised unavailable operation.</violation>
</file>
<file name="packages/document-api/src/contract/operation-definitions.ts">
<violation number="1" location="packages/document-api/src/contract/operation-definitions.ts:1290">
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.</violation>
</file>
<file name="packages/document-api/src/contract/schemas.ts">
<violation number="1" location="packages/document-api/src/contract/schemas.ts:5169">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| write: WriteAdapter; | ||
| selectionMutation: SelectionMutationAdapter; | ||
| styles: StylesAdapter; | ||
| styles: StylesAdapter & Partial<StylesCreateAdapter>; |
There was a problem hiding this comment.
P2: When the host omits adapters.styles.create, capabilities.get() can still report styles.create as available, but api.styles.create() throws CAPABILITY_UNAVAILABLE. Gate this operation in the capability snapshot, or derive its availability from the hook, so callers do not select an advertised unavailable operation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/index.ts, line 2113:
<comment>When the host omits `adapters.styles.create`, `capabilities.get()` can still report `styles.create` as available, but `api.styles.create()` throws `CAPABILITY_UNAVAILABLE`. Gate this operation in the capability snapshot, or derive its availability from the hook, so callers do not select an advertised unavailable operation.</comment>
<file context>
@@ -2078,7 +2110,7 @@ export interface DocumentApiAdapters {
write: WriteAdapter;
selectionMutation: SelectionMutationAdapter;
- styles: StylesAdapter;
+ styles: StylesAdapter & Partial<StylesCreateAdapter>;
templates: TemplatesAdapter;
trackChanges: TrackChangesAdapter;
</file context>
| throws: ['INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'], | ||
| // Writes word/styles.xml outside the document history, exactly as | ||
| // styles.apply does. | ||
| historyUnsafe: true, |
There was a problem hiding this comment.
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>
| historyUnsafe: true, | |
| historyUnsafe: true, | |
| returnsReceipt: true, |
| /** | ||
| * Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97). | ||
| */ | ||
| // --------------------------------------------------------------------------- |
There was a problem hiding this comment.
P3: The new ST_HighlightColor block was inserted between the ST_ThemeColor section header comment and its doc comment/array, so the // ST_ThemeColor (underline rich attrs: w:themeColor) header now sits directly above the highlight section and is separated from the values it describes. Move the highlight block after ST_THEME_COLOR_VALUE_SET (or before the ST_ThemeColor header) so each section header is adjacent to its own content.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/inline-semantics/token-sets.ts, line 70:
<comment>The new ST_HighlightColor block was inserted between the ST_ThemeColor section header comment and its doc comment/array, so the `// ST_ThemeColor (underline rich attrs: w:themeColor)` header now sits directly above the highlight section and is separated from the values it describes. Move the highlight block after ST_THEME_COLOR_VALUE_SET (or before the ST_ThemeColor header) so each section header is adjacent to its own content.</comment>
<file context>
@@ -67,6 +67,37 @@ export const ST_UNDERLINE_VALUE_SET: ReadonlySet<string> = new Set(ST_UNDERLINE_
/**
* Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97).
*/
+// ---------------------------------------------------------------------------
+// ST_HighlightColor (w:highlight)
+// ---------------------------------------------------------------------------
</file context>
| id: { type: 'string', minLength: 1 }, | ||
| type: { enum: ['paragraph', 'character'] }, | ||
| xmlPart: { type: 'string' }, | ||
| xmlPath: { const: 'w:styles/w:style' }, |
There was a problem hiding this comment.
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>
Relates to #3975
What changed
styles.create— define or redefine a named paragraph or character style. It isthe missing half of
styles.getCatalog: what the catalogue can describe, thiscan author. #3975 has the full argument for why the gap exists and why
templates.applyis the wrong tool for it; this PR is the contract.Additive, 19 files. New operation in
packages/document-api/src/styles/create.ts, registered the way every otheroperation is:
operation-definitions.ts(viamutationOperation()),operation-registry.ts, hand-written schemas incontract/schemas.ts,invoke/invoke.ts, and the four sites insrc/index.ts. Nothing generated iscommitted.
The decisions worth arguing with are in the commit message; the two that touch
existing code are below.
The exclusion list is now a property of the destination
EXCLUDED_KEYSwas thedocDefaultsexclusion list and the only list, soevery caller inherited a restriction only one of them is subject to. Four of its
run entries —
w:cs,w:highlight,w:oMath,w:rtl— are disallowed inw:docDefaultsand legal on a namedw:style; the header comment inregistry.tsalready says so ("intentionally disallowed in WorddocDefaults"), and all four are read back off a
w:stylebyStyleDefinition.runPropertiesin@superdoc/style-engine.w:rtlis the property that makes a run right-to-left. Without the split, noright-to-left style is expressible through this API — the operation would ship
unable to author a style for Hebrew, Arabic, Persian or Urdu.
So
EXCLUDED_KEYS_BY_SCOPEkeys the list by'docDefaults' | 'style', thedocDefaultslist is byte-for-byte what it was, and the four new registryentries are reachable under the style scope alone.
styles.applyis unchanged, and two things had to be fixed to keep it that wayBoth were caught by tests written for this PR, and both fail without the fix:
buildStateSchemais scoped as well asbuildPatchSchema. Otherwise thefour keys land in the published
before/aftermaps of adocDefaultsreceipt — the exact keys that scope rejects, in objects carrying
additionalProperties: false, so receipt validation would have loosened too.classifyPatchKeyasks whether a key is reachable on the other channelin this scope. Putting the four in
ALLOWED_KEYS_BY_CHANNEL.runmeant arun property sent to the paragraph channel started answering
cross_channelinstead of
excluded, dropping theexcluded_docdefaults_keyreason acaller branches on.
Verified rather than asserted. Generating
document-api-contract.jsonon thisbranch and on the merge-base and deep-diffing per operation:
styles.createcapabilities.getonly, and only because it enumerates operation idsstyles.*inherited from main, changedstyles.applyis byte-identical. Same accepted keys, same rejection messages,same detail codes, same published input and output schemas.
Two more gates run against the generated contract:
styles.createschemas compile in a 2020-12 validator, with nodangling
$ref. This is why the failure schema typescodeas a stringrather than an enum derived from
possibleFailureCodes: that list is emptywhile the operation ships without an adapter, and
enum: []does not compile— it takes the whole output
oneOfwith it, so a consumer could not validateeven a success receipt.
styles.applypublishes the same open shape.schema side by side: 0 disagreements. That gate is why one rule 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
w:aliases, is enforced on both sides and expressed as apattern.What is not in this PR
The adapter. The mutation lives in
@superdoc/docx-engine, which is not inthis repository, so
executeStylesCreatethrowsCAPABILITY_UNAVAILABLEuntilthe engine side lands — the same way
styles.getCatalogfails closed when itsoptional hook is absent.
Two consequences are deliberate, and I would rather state them than have them
discovered after merge:
possibleFailureCodesisNONE_FAILURES, because with no adapter there is nocode a host can currently produce and the contract must not over-declare.
capabilities.get()before it cansucceed, and a
feat:release publishes it into the JSON schemas, the agentartifacts and the reference site.
OperationDefinitionEntryhas nostatus/plannedfield, so the contract cannot currently say "defined, notyet implemented".
That second point is a decision for you, not a detail — it is the question at
the bottom of #3975. Please do not merge this until it is answered. If you
would rather implement contract and adapter together and drop this, say so and
I will close it; the shape is yours to use.
Checks
pnpm run <script>does not run on Windows in this repo (the rootpreparehook is POSIX, and
scripts/test.mjscallsspawnSync('pnpm.cmd')withoutshell: true), so each stage was invoked directly. CI runs the real scripts.tsc -b packages/document-api— cleanbun test packages/document-api/src— 1800 pass, 1801 run. The one failure,executeParagraphsSetTabStop > rejects clear as a setTabStop alignment, failsidentically on the merge-base and is untouched by this branch
check-contract-parity— 428 operations, 428 API memberscheck-contract-outputs— 5 generated filescheck-agent-artifacts— 3 filescheck-stable-schemas— 2 filescheck-documented-operations— 428 operations, 468 pagesvp fmt --checkon all 19 files — cleanvp linton all 19 files — 0 errors; the 24 warnings are pre-existing inindex.tsandschemas.tsand name no symbol this PR addstests/consumer-typecheck/src/document-api-styles-create.ts— new fixture,asserting both the parameter and the return shapes; compiles clean against the
built
.d.tsunder the fixture's own strict settingsTests: validation and routing in
styles/create.test.ts, scope filtering ofboth schema builders in
styles/schema.test.ts, direct coverage of the publicclassifyPatchKeyexport, aninvokeparity case, and the SD-2018 registrygate pinned per scope. 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.