diff --git a/.changeset/platejs-static-presentation.md b/.changeset/platejs-static-presentation.md new file mode 100644 index 0000000000..91486d3197 --- /dev/null +++ b/.changeset/platejs-static-presentation.md @@ -0,0 +1,15 @@ +--- +'platejs': patch +--- + +- Add `presentation` to `renderStaticHtml` and `exportDocx`. Pass the app's static kit, such as `BaseEditorKit`, when exporting a live editor, so each node draws with the static plugin of the same name instead of its editing component. A plugin with no static drawing renders without its component or wrapper and reports a `missing-static-presentation` diagnostic, which `exportDocx` counts as lost content. The kit compiles without activating its plugins. +- Throw a `TypeError` when `renderStaticHtml` `props` set `editor` or `document` to a value. +- Take DOCX list levels from the indent of the block a list wraps when the list has no margin of its own. +- Fix DOCX export failing when an element that the stylesheet styles has a quoted value in its inline style, such as a callout's emoji font list. +- Mark the drawings of `CopilotPlugin` edit-only, so an export with `presentation` leaves out its ghost text instead of reporting a `missing-static-presentation` diagnostic. +- Export equations as Word math built from KaTeX's MathML. An equation that uses notation the converter does not cover, such as a matrix, exports its TeX as text inside a Word equation. +- Import Word math in the body as `equation` and `inlineEquation` nodes, with TeX rebuilt from the equation's structure, and in comment bodies as TeX text. Import flattens a tracked revision inside an equation and reports an `unsupported-content` diagnostic. +- Export a task list item, an `li` with `data-checked`, with a ☑ or ☐ list marker at its list level. +- Leave colors, fonts and sizes of the DOCX drawings to `stylesheet`. A callout draws as a borderless layout table shaded with its own color, and the code block and placeholder drawings carry no inline styles. +- Rebuild an unchanged retained `source` when the caller passes `stylesheet` or `fontFamily`, instead of returning its original file. +- Give concurrent `exportDocx` calls their own list numbering. diff --git a/apps/www/public/r/authored-changes-docs.json b/apps/www/public/r/authored-changes-docs.json index 28078b3729..298c9dd50a 100644 --- a/apps/www/public/r/authored-changes-docs.json +++ b/apps/www/public/r/authored-changes-docs.json @@ -6,7 +6,7 @@ "files": [ { "path": "../../content/docs/(guides)/authored-changes.mdx", - "content": "---\ntitle: Authored Changes\ndescription: Capture attributed edits, review proposals, retain author history, and export explicit projections.\ndocs:\n - route: /docs/suggestion\n title: Suggestions\n - route: /docs/comment\n title: Comments\n - route: /docs/yjs\n title: Collaboration\n---\n\nAuthored changes keep accepted content, proposals, decisions, and stable change identities in one saved document. Use [Suggestions](/docs/plate/suggestion) for the common editing and suggesting modes. This guide covers the underlying records, advanced views, decisions, retained history, and formats.\n\n## Choose the right surface\n\n| Need | Surface |\n| ------------------------------------------------- | ----------------------------------------------------------------------- |\n| Switch between editing and suggesting | `SuggestionPlugin` from `platejs/suggestion/react` |\n| Add copied highlighting and review cards | [SuggestionKit](/docs/plate/suggestion) and [DiscussionKit](/docs/plate/discussion) |\n| Record changes as the editor's user | `AuthoredPlugin` from `platejs/authored` |\n| Retain accepted history for revert | `AuthoredPlugin.configure({ initialState: { retainHistory: true } })` |\n| Read, decide, resolve, or revert changes | The installed authored plugin portal |\n| Attach a conversation | `CommentsPlugin` with a `{ type: 'change', id }` target |\n\nSuggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.\n\n## Install authored changes\n\n`AuthoredPlugin` resolves the author from the Plate editor's `userId` once per transaction:\n\n```tsx title=\"editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst editor = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: [\n { type: \"paragraph\", children: [{ text: \"Review this sentence.\" }] },\n ],\n userId: \"alice\",\n});\n\nconst authored = editor.plugin(AuthoredPlugin);\n```\n\nAn editor without a `userId` writes as the local user, `'local'`, so a solo\neditor needs no setup, AI suggestions included. Pass an authenticated, stable\nuser ID whenever more than one person writes the document.\n\nTo retain accepted history, configure the same descriptor. The editor reads\n`retainHistory` once, when it is created:\n\n```tsx title=\"history-editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst historyEditor = createEditor({\n plugins: [\n BaseParagraphPlugin,\n AuthoredPlugin.configure({ initialState: { retainHistory: true } }),\n ],\n initialValue,\n userId: session.user.id,\n});\n\nconst retainedAuthored = historyEditor.plugin(AuthoredPlugin);\n```\n\n`retainHistory` keeps closed edit bodies needed for selective revert. Omit it when the application only needs pending review and attribution.\n\n## Capture changes\n\nAn editing view records ordinary updates as accepted contributions. Begin an explicit proposal inside the transaction before applying its document mutation:\n\n```tsx\nlet changeId = \"\";\n\neditor.update((tx) => {\n changeId = tx.authored.propose();\n tx.text.insert(\" reviewed\", {\n at: { offset: 8, path: [0, 0] },\n });\n});\n```\n\nPass `{ changeId }` to assign an identity to a new proposal or amend a pending contribution owned by the current author. The current author must own an existing change's latest head.\n\n## Import a compared revision\n\nCompare a fixed draft with the accepted document, then publish its canonical change as one native proposal:\n\n```tsx\nimport { proposeAuthoredComparison } from \"platejs/authored\";\nimport { compare } from \"platejs/diff\";\n\nconst incomingDraft = [\n { type: \"paragraph\", children: [{ text: \"Revised sentence.\" }] },\n];\nconst comparison = await compare({\n before: editor.read.value(),\n after: incomingDraft,\n schema: editor.read.schema,\n});\nconst imported = proposeAuthoredComparison(editor, { comparison });\n```\n\n`groupIds` selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle `stale`, `blocked`, `invalid`, and `unavailable` without treating them as applied proposals. A resolved three-way comparison can use the same call when its selected baseline is the live accepted document.\n\n## Configure a view\n\nSet the starting intent and projection on `EditorRoot`:\n\n```tsx\nimport { AuthoredPlugin } from \"platejs/authored\";\nimport { EditorContent, EditorRoot, useCreateEditor } from \"platejs/react\";\n\nexport function SuggestionEditor() {\n const editor = useCreateEditor({\n plugins: [AuthoredPlugin],\n userId: \"alice\",\n });\n\n return (\n \n \n \n );\n}\n```\n\nThe editor must install an authored plugin, either directly or through `SuggestionPlugin`. Changing the `intent` or `projection` prop updates this view. Rerendering with equivalent values preserves subsequent mode changes made through the mounted view's portal.\n\nFor an interactive mode control, let `SuggestionPlugin` change the intent while preserving the current projection:\n\n```tsx\nimport { SuggestionPlugin } from \"platejs/suggestion/react\";\n\neditor.plugin(SuggestionPlugin).api.setMode(\"suggesting\");\n```\n\nUse the authored portal for advanced projections:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\n\nauthored.api.setView({\n intent: \"propose\",\n projection: \"proposed\",\n});\n```\n\n| Projection | Visible content |\n| ---------- | ----------------------------------------------------------- |\n| `accepted` | Accepted content without pending proposals |\n| `proposed` | The proposed result without retained review fragments |\n| `markup` | Proposed content with retained deletions and move fragments |\n\nUse `intent: 'edit'` with any projection. An editing markup or proposed view keeps pending content visible while edits to independent accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes directly in the same undoable transaction. Partial retained or dependency-spanning targets stay protected; other dependent edits remain proposals with the current author. Use `intent: 'propose'` with `proposed` or `markup` when ordinary input must create proposals.\n\nIntent and projection belong to the exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.\n\n## Review changes\n\nRead a page of pending changes, then capture a revision-bound selection for the decision:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\nconst pending = authored.read.changes({\n authorId: \"alice\",\n limit: 50,\n status: \"pending\",\n});\nconst selection = authored.read.select({\n ids: pending.items.map(({ id }) => id),\n});\nconst result = authored.update.decide({\n action: \"accept\",\n selection,\n});\n```\n\nUse `action: 'reject'` to reject changes. The example selects only the returned page. Pass a non-null page `cursor` to `changes(...)` to read more, or call `select({ authorId: 'alice', status: 'pending' })` to select every current match.\n\n`AuthoredSelection` captures the document ID, change revisions, and current heads. A later amendment makes an old selection `stale`. Dependencies and concurrent conflicts return `blocked` without partially applying the batch.\n\nFor spatial controls, use `change(id)` to read one record and `changesAt(range)` to find records intersecting a text range. Each `AuthoredChange` includes its author, status, kind, dependencies, revision, and current ranges.\n\nRead semantic detail when the reviewer opens one change:\n\n```tsx\nconst details = authored.read.details(changeId);\n\nif (details?.parts.status === \"available\") {\n for (const part of details.parts.items) {\n // Render content, boundary, property, or root facts in review UI.\n }\n}\n```\n\n`details(id)` returns the compact change summary, decision history, and immutable semantic parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Property parts preserve exact before and after values. Root parts describe root creation or deletion.\n\nClosed changes without retained edit bodies return `parts.status: 'unavailable'` with `reason: 'retention'`; their summary and reviews remain readable. Keep list and spatial reads compact, and request details only for the change being reviewed.\n\n### Decision results\n\nAlways inspect the result of `decide`, `resolve`, or `revert`:\n\n| Status | Meaning |\n| ------------- | ------------------------------------------------------------------------------------------------ |\n| `applied` | The operation applied to the returned IDs. |\n| `unchanged` | No change was needed. |\n| `stale` | The selected revisions or heads changed. Read the records again before retrying. |\n| `blocked` | The selection has unresolved dependencies, dependants, or conflicts. Inspect the returned lists. |\n| `invalid` | The selection or document identity is invalid. |\n| `unavailable` | A revert needs content that is no longer retained. |\n\n`authored.read.preview({ action, selection })` checks an accept or reject decision without applying it. An `applied` preview means the decision can apply at that moment; the later `decide` call still needs result handling.\n\nAccepting a change can require its dependencies. Rejecting one can require its dependants. The editor never expands the selection silently. Call `authored.update.resolve({ action, selection })` only after the reviewer chooses a decision for the complete conflict component.\n\n## Retain author history\n\nEnable `retainHistory` before recording work whose content must remain available for selective revert. Loading a compacted document with retention enabled cannot recover discarded edit bodies.\n\nSelect an author's accepted contributions and create a compensating change:\n\n```tsx\nconst selection = retainedAuthored.read.select({\n authorId: \"alice\",\n status: \"accepted\",\n});\nconst result = retainedAuthored.update.revert({ selection });\n```\n\nThe original records keep their authors. The compensation belongs to the current author. A revert can return `blocked` when later dependent work prevents compensation, or `unavailable` when the required content is absent.\n\nPlate History reverses local interaction batches with `editor.api.history.undo()` and `redo()`. Local undo also restores mapped selection. Selective revert records another authored contribution. The [Version History example](/docs/plate/examples/version-history) lets you edit as two authors and revert one retained contribution.\n\n## Save the document\n\nSave the complete result of `editor.read.value()`. It contains accepted content and the `meta.authored` records needed to reconstruct proposals and decisions. Saving only the node array loses those records.\n\n```tsx\nconst savedDocument = editor.read.value();\n\nconst restored = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: savedDocument,\n userId: \"alice\",\n});\n```\n\nUse the same content plugins and schema when loading a revision into a fresh editor. Loading validates the authored records and document schema before publishing the value. Treat `meta.authored` as codec-owned data. Local undo starts empty.\n\nIf you install [Comments](/docs/plate/comment), save `editor.plugin(CommentsPlugin).api.toJSON()` atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved `CommentsJSON` to `CommentsPlugin.configure({ initialState: { initialComments } })` in the fresh editor. Live thread targets contain semantic identities; `attachment(id)` reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.\n\nHistorical previews and restores retain current conversations and use only the selected revision's proven targets. See [Comments and versions](/docs/plate/comment#document-history-and-versions).\n\n## Export explicit projections\n\nChoose `accepted` or `proposed` for a clean document, or `review` when the\nformat can preserve review records. Clean projections report diagnostics when\nthey omit pending work or resolve a conflict to one side. Every helper accepts\nor captures a complete document, including named roots and metadata.\n\n### JSON\n\nUse the pure authored projection helpers for detached documents:\n\n```tsx\nimport {\n parseAuthoredDocument,\n projectAuthoredDocument,\n projectAuthoredReview,\n} from \"platejs/authored\";\n\nconst source = editor.read.value();\nconst accepted = projectAuthoredDocument(source, {\n projection: \"accepted\",\n});\nconst review = projectAuthoredReview(source);\nconst json = JSON.stringify(review.review);\nconst document = parseAuthoredDocument(json);\n```\n\n`parseAuthoredDocument` validates the document envelope. Loading it into an\neditor applies the installed application schema and authored-record checks.\n\n### Plain text and Markdown\n\n```tsx\nimport { serializePlainText } from \"platejs\";\n\nconst text = serializePlainText(editor, { projection: \"accepted\" });\nconst markdown = editor.api.markdown.serialize({ projection: \"proposed\" });\n\nif (markdown.ok) downloadMarkdown(markdown.data);\n```\n\nPlain text and Markdown carry visible content only. Feature-owned format\nmappings preserve the structures they can represent and report omitted roots,\nmetadata, pending work, and unsupported nodes. Markdown does not contain a\nhidden Plate document and cannot restore exact review state.\n\n### HTML and DOCX\n\n```tsx\nimport { exportDocx } from \"platejs/docx/export\";\nimport { renderStaticHtml } from \"platejs/static\";\n\nconst semanticHtml = editor.api.html.serialize({ projection: \"proposed\" });\nconst styledHtml = await renderStaticHtml(editor, { projection: \"proposed\" });\nconst docx = await exportDocx(editor, { projection: \"review\" });\n```\n\n`renderStaticHtml` uses the current editor's static components. Semantic HTML\nuses feature mappings and no application styling. Both HTML forms carry visible\naccepted or proposed content only. A successful DOCX result returns a `Blob` in\n`docx.blob`. Every serializer returns structured `diagnostics`.\n\n| Format | Accepted or proposed | Review |\n| ------ | -------------------- | ------ |\n| JSON | Plain projected document | Canonical authored document |\n| Plain text | Structural text | Not supported |\n| Markdown | Plain Markdown | Not supported; save authored JSON |\n| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |\n| Static HTML | Component-rendered HTML | Not supported; save authored JSON |\n| DOCX | Projected Word document | Word revisions; save authored JSON for exact state |\n\nDOCX review export reports `lossy-content` or `unsupported-content` when Word\ncannot express a native construct. Save authored JSON beside the file to keep\nthat construct exactly.\n\n## Collaboration\n\nInstall the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.\n\nThe Yjs adapter applies authored operations and decisions in one remote transaction. Its checkpoints preserve pending work and each peer's retained content. Retention policy is local: an archival peer can retain closed bodies while another peer compacts them.\n\nSee [Collaboration](/docs/plate/yjs) for setup and compaction ownership.\n\n## API Reference\n\n### `AuthoredPlugin`\n\nImport the standard descriptor from `platejs/authored`. It records each change as the Plate editor's `userId`, or as the local user, `'local'`, when the editor has none. `SuggestionPlugin` and `AIChatPlugin` depend on this descriptor.\n\n| Option | Type | Purpose |\n| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |\n| `initialState.retainHistory` | `boolean` | Keep closed edit bodies for selective revert. The editor reads it once, when it is created. Defaults to `false`. |\n\n### Transaction, reads, and updates\n\nAfter `const authored = editor.plugin(AuthoredPlugin)`, use these methods:\n\n| Surface | Purpose |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `tx.authored.propose(options?)` | Start a proposal, assign a new `{ changeId }`, or amend an owned pending change. |\n| `authored.read.canPropose()` | Report whether the current identity can author proposals; always true with `AuthoredPlugin`. |\n| `authored.read.view()` | Read this view's intent and projection. |\n| `authored.read.change(id)` | Read one logical change, or `null`. |\n| `authored.read.details(id)` | Read one change with semantic parts and decision history, or `null`. |\n| `authored.read.changes(query?)` | Read a page filtered by author, status, or time. |\n| `authored.read.changesAt(range)` | Read changes intersecting a text range. |\n| `authored.read.select(query)` | Capture a revision-bound selection for a decision. |\n| `authored.read.preview(decision)` | Check an accept or reject decision without applying it. |\n| `authored.update.decide(decision)` | Accept or reject the selected changes atomically. |\n| `authored.update.resolve(decision)` | Decide a complete conflict component explicitly. |\n| `authored.update.revert({ selection })` | Compensate retained accepted contributions. |\n| `authored.api.setView(view)` | Change the policy on this exact view. |\n| `authored.api.subscribeChanges(listener)` | Subscribe to immutable affected change IDs and decoration node keys for this view. |\n\n`subscribeChanges` is a low-level integration hook for semantic decorators. Suggestion applications use `useSuggestionChanges(path)` instead.\n\n### Types and format helpers\n\n`platejs/authored` exports the authored plugin descriptors, change and decision\ntypes, immutable projection results and diagnostics, and\n`createAuthoredReviewDocument` for trusted cumulative imported revisions.\n\n`projectAuthoredDocument(document, { projection })` returns one accepted or\nproposed `document`, the exact captured `review` document, diagnostics, and\nseparate `unresolved.pending` and `unresolved.conflicted` counts.\n`projectAuthoredReview(document)` also exposes stable visible markup segments\nand property counterparts. Both helpers are pure document operations: they do\nnot construct, activate, or subscribe an editor runtime.\n\n`projectAuthoredRange(projection, range)` maps proposed coordinates into the\naccepted document captured by the same projection. `parseAuthoredDocument`\nparses a JSON document envelope; the target editor remains responsible for its\napplication schema.\n", + "content": "---\ntitle: Authored Changes\ndescription: Capture attributed edits, review proposals, retain author history, and export explicit projections.\ndocs:\n - route: /docs/suggestion\n title: Suggestions\n - route: /docs/comment\n title: Comments\n - route: /docs/yjs\n title: Collaboration\n---\n\nAuthored changes keep accepted content, proposals, decisions, and stable change identities in one saved document. Use [Suggestions](/docs/plate/suggestion) for the common editing and suggesting modes. This guide covers the underlying records, advanced views, decisions, retained history, and formats.\n\n## Choose the right surface\n\n| Need | Surface |\n| ------------------------------------------------- | ----------------------------------------------------------------------- |\n| Switch between editing and suggesting | `SuggestionPlugin` from `platejs/suggestion/react` |\n| Add copied highlighting and review cards | [SuggestionKit](/docs/plate/suggestion) and [DiscussionKit](/docs/plate/discussion) |\n| Record changes as the editor's user | `AuthoredPlugin` from `platejs/authored` |\n| Retain accepted history for revert | `AuthoredPlugin.configure({ initialState: { retainHistory: true } })` |\n| Read, decide, resolve, or revert changes | The installed authored plugin portal |\n| Attach a conversation | `CommentsPlugin` with a `{ type: 'change', id }` target |\n\nSuggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.\n\n## Install authored changes\n\n`AuthoredPlugin` resolves the author from the Plate editor's `userId` once per transaction:\n\n```tsx title=\"editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst editor = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: [\n { type: \"paragraph\", children: [{ text: \"Review this sentence.\" }] },\n ],\n userId: \"alice\",\n});\n\nconst authored = editor.plugin(AuthoredPlugin);\n```\n\nAn editor without a `userId` writes as the local user, `'local'`, so a solo\neditor needs no setup, AI suggestions included. Pass an authenticated, stable\nuser ID whenever more than one person writes the document.\n\nTo retain accepted history, configure the same descriptor. The editor reads\n`retainHistory` once, when it is created:\n\n```tsx title=\"history-editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst historyEditor = createEditor({\n plugins: [\n BaseParagraphPlugin,\n AuthoredPlugin.configure({ initialState: { retainHistory: true } }),\n ],\n initialValue,\n userId: session.user.id,\n});\n\nconst retainedAuthored = historyEditor.plugin(AuthoredPlugin);\n```\n\n`retainHistory` keeps closed edit bodies needed for selective revert. Omit it when the application only needs pending review and attribution.\n\n## Capture changes\n\nAn editing view records ordinary updates as accepted contributions. Begin an explicit proposal inside the transaction before applying its document mutation:\n\n```tsx\nlet changeId = \"\";\n\neditor.update((tx) => {\n changeId = tx.authored.propose();\n tx.text.insert(\" reviewed\", {\n at: { offset: 8, path: [0, 0] },\n });\n});\n```\n\nPass `{ changeId }` to assign an identity to a new proposal or amend a pending contribution owned by the current author. The current author must own an existing change's latest head.\n\n## Import a compared revision\n\nCompare a fixed draft with the accepted document, then publish its canonical change as one native proposal:\n\n```tsx\nimport { proposeAuthoredComparison } from \"platejs/authored\";\nimport { compare } from \"platejs/diff\";\n\nconst incomingDraft = [\n { type: \"paragraph\", children: [{ text: \"Revised sentence.\" }] },\n];\nconst comparison = await compare({\n before: editor.read.value(),\n after: incomingDraft,\n schema: editor.read.schema,\n});\nconst imported = proposeAuthoredComparison(editor, { comparison });\n```\n\n`groupIds` selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle `stale`, `blocked`, `invalid`, and `unavailable` without treating them as applied proposals. A resolved three-way comparison can use the same call when its selected baseline is the live accepted document.\n\n## Configure a view\n\nSet the starting intent and projection on `EditorRoot`:\n\n```tsx\nimport { AuthoredPlugin } from \"platejs/authored\";\nimport { EditorContent, EditorRoot, useCreateEditor } from \"platejs/react\";\n\nexport function SuggestionEditor() {\n const editor = useCreateEditor({\n plugins: [AuthoredPlugin],\n userId: \"alice\",\n });\n\n return (\n \n \n \n );\n}\n```\n\nThe editor must install an authored plugin, either directly or through `SuggestionPlugin`. Changing the `intent` or `projection` prop updates this view. Rerendering with equivalent values preserves subsequent mode changes made through the mounted view's portal.\n\nFor an interactive mode control, let `SuggestionPlugin` change the intent while preserving the current projection:\n\n```tsx\nimport { SuggestionPlugin } from \"platejs/suggestion/react\";\n\neditor.plugin(SuggestionPlugin).api.setMode(\"suggesting\");\n```\n\nUse the authored portal for advanced projections:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\n\nauthored.api.setView({\n intent: \"propose\",\n projection: \"proposed\",\n});\n```\n\n| Projection | Visible content |\n| ---------- | ----------------------------------------------------------- |\n| `accepted` | Accepted content without pending proposals |\n| `proposed` | The proposed result without retained review fragments |\n| `markup` | Proposed content with retained deletions and move fragments |\n\nUse `intent: 'edit'` with any projection. An editing markup or proposed view keeps pending content visible while edits to independent accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes directly in the same undoable transaction. Partial retained or dependency-spanning targets stay protected; other dependent edits remain proposals with the current author. Use `intent: 'propose'` with `proposed` or `markup` when ordinary input must create proposals.\n\nIntent and projection belong to the exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.\n\n## Review changes\n\nRead a page of pending changes, then capture a revision-bound selection for the decision:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\nconst pending = authored.read.changes({\n authorId: \"alice\",\n limit: 50,\n status: \"pending\",\n});\nconst selection = authored.read.select({\n ids: pending.items.map(({ id }) => id),\n});\nconst result = authored.update.decide({\n action: \"accept\",\n selection,\n});\n```\n\nUse `action: 'reject'` to reject changes. The example selects only the returned page. Pass a non-null page `cursor` to `changes(...)` to read more, or call `select({ authorId: 'alice', status: 'pending' })` to select every current match.\n\n`AuthoredSelection` captures the document ID, change revisions, and current heads. A later amendment makes an old selection `stale`. Dependencies and concurrent conflicts return `blocked` without partially applying the batch.\n\nFor spatial controls, use `change(id)` to read one record and `changesAt(range)` to find records intersecting a text range. Each `AuthoredChange` includes its author, status, kind, dependencies, revision, and current ranges.\n\nRead semantic detail when the reviewer opens one change:\n\n```tsx\nconst details = authored.read.details(changeId);\n\nif (details?.parts.status === \"available\") {\n for (const part of details.parts.items) {\n // Render content, boundary, property, or root facts in review UI.\n }\n}\n```\n\n`details(id)` returns the compact change summary, decision history, and immutable semantic parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Property parts preserve exact before and after values. Root parts describe root creation or deletion.\n\nClosed changes without retained edit bodies return `parts.status: 'unavailable'` with `reason: 'retention'`; their summary and reviews remain readable. Keep list and spatial reads compact, and request details only for the change being reviewed.\n\n### Decision results\n\nAlways inspect the result of `decide`, `resolve`, or `revert`:\n\n| Status | Meaning |\n| ------------- | ------------------------------------------------------------------------------------------------ |\n| `applied` | The operation applied to the returned IDs. |\n| `unchanged` | No change was needed. |\n| `stale` | The selected revisions or heads changed. Read the records again before retrying. |\n| `blocked` | The selection has unresolved dependencies, dependants, or conflicts. Inspect the returned lists. |\n| `invalid` | The selection or document identity is invalid. |\n| `unavailable` | A revert needs content that is no longer retained. |\n\n`authored.read.preview({ action, selection })` checks an accept or reject decision without applying it. An `applied` preview means the decision can apply at that moment; the later `decide` call still needs result handling.\n\nAccepting a change can require its dependencies. Rejecting one can require its dependants. The editor never expands the selection silently. Call `authored.update.resolve({ action, selection })` only after the reviewer chooses a decision for the complete conflict component.\n\n## Retain author history\n\nEnable `retainHistory` before recording work whose content must remain available for selective revert. Loading a compacted document with retention enabled cannot recover discarded edit bodies.\n\nSelect an author's accepted contributions and create a compensating change:\n\n```tsx\nconst selection = retainedAuthored.read.select({\n authorId: \"alice\",\n status: \"accepted\",\n});\nconst result = retainedAuthored.update.revert({ selection });\n```\n\nThe original records keep their authors. The compensation belongs to the current author. A revert can return `blocked` when later dependent work prevents compensation, or `unavailable` when the required content is absent.\n\nPlate History reverses local interaction batches with `editor.api.history.undo()` and `redo()`. Local undo also restores mapped selection. Selective revert records another authored contribution. The [Version History example](/docs/plate/examples/version-history) lets you edit as two authors and revert one retained contribution.\n\n## Save the document\n\nSave the complete result of `editor.read.value()`. It contains accepted content and the `meta.authored` records needed to reconstruct proposals and decisions. Saving only the node array loses those records.\n\n```tsx\nconst savedDocument = editor.read.value();\n\nconst restored = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: savedDocument,\n userId: \"alice\",\n});\n```\n\nUse the same content plugins and schema when loading a revision into a fresh editor. Loading validates the authored records and document schema before publishing the value. Treat `meta.authored` as codec-owned data. Local undo starts empty.\n\nIf you install [Comments](/docs/plate/comment), save `editor.plugin(CommentsPlugin).api.toJSON()` atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved `CommentsJSON` to `CommentsPlugin.configure({ initialState: { initialComments } })` in the fresh editor. Live thread targets contain semantic identities; `attachment(id)` reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.\n\nHistorical previews and restores retain current conversations and use only the selected revision's proven targets. See [Comments and versions](/docs/plate/comment#document-history-and-versions).\n\n## Export explicit projections\n\nChoose `accepted` or `proposed` for a clean document, or `review` when the\nformat can preserve review records. Clean projections report diagnostics when\nthey omit pending work or resolve a conflict to one side. Every helper accepts\nor captures a complete document, including named roots and metadata.\n\n### JSON\n\nUse the pure authored projection helpers for detached documents:\n\n```tsx\nimport {\n parseAuthoredDocument,\n projectAuthoredDocument,\n projectAuthoredReview,\n} from \"platejs/authored\";\n\nconst source = editor.read.value();\nconst accepted = projectAuthoredDocument(source, {\n projection: \"accepted\",\n});\nconst review = projectAuthoredReview(source);\nconst json = JSON.stringify(review.review);\nconst document = parseAuthoredDocument(json);\n```\n\n`parseAuthoredDocument` validates the document envelope. Loading it into an\neditor applies the installed application schema and authored-record checks.\n\n### Plain text and Markdown\n\n```tsx\nimport { serializePlainText } from \"platejs\";\n\nconst text = serializePlainText(editor, { projection: \"accepted\" });\nconst markdown = editor.api.markdown.serialize({ projection: \"proposed\" });\n\nif (markdown.ok) downloadMarkdown(markdown.data);\n```\n\nPlain text and Markdown carry visible content only. Feature-owned format\nmappings preserve the structures they can represent and report omitted roots,\nmetadata, pending work, and unsupported nodes. Markdown does not contain a\nhidden Plate document and cannot restore exact review state.\n\n### HTML and DOCX\n\n```tsx\nimport { exportDocx } from \"platejs/docx/export\";\nimport { renderStaticHtml } from \"platejs/static\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst semanticHtml = editor.api.html.serialize({ projection: \"proposed\" });\nconst styledHtml = await renderStaticHtml(editor, {\n presentation: BaseEditorKit,\n projection: \"proposed\",\n});\nconst docx = await exportDocx(editor, {\n presentation: BaseEditorKit,\n projection: \"review\",\n});\n```\n\n`renderStaticHtml` and `exportDocx` draw with the static kit passed as\n`presentation`, because a live editor's components are editing UI. Semantic HTML\nuses feature mappings and no application styling. Both HTML forms carry visible\naccepted or proposed content only. A successful DOCX result returns a `Blob` in\n`docx.blob`. Every serializer returns structured `diagnostics`.\n\n| Format | Accepted or proposed | Review |\n| ------ | -------------------- | ------ |\n| JSON | Plain projected document | Canonical authored document |\n| Plain text | Structural text | Not supported |\n| Markdown | Plain Markdown | Not supported; save authored JSON |\n| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |\n| Static HTML | Component-rendered HTML | Not supported; save authored JSON |\n| DOCX | Projected Word document | Word revisions; save authored JSON for exact state |\n\nDOCX review export reports `lossy-content` or `unsupported-content` when Word\ncannot express a native construct. Save authored JSON beside the file to keep\nthat construct exactly.\n\n## Collaboration\n\nInstall the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.\n\nThe Yjs adapter applies authored operations and decisions in one remote transaction. Its checkpoints preserve pending work and each peer's retained content. Retention policy is local: an archival peer can retain closed bodies while another peer compacts them.\n\nSee [Collaboration](/docs/plate/yjs) for setup and compaction ownership.\n\n## API Reference\n\n### `AuthoredPlugin`\n\nImport the standard descriptor from `platejs/authored`. It records each change as the Plate editor's `userId`, or as the local user, `'local'`, when the editor has none. `SuggestionPlugin` and `AIChatPlugin` depend on this descriptor.\n\n| Option | Type | Purpose |\n| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |\n| `initialState.retainHistory` | `boolean` | Keep closed edit bodies for selective revert. The editor reads it once, when it is created. Defaults to `false`. |\n\n### Transaction, reads, and updates\n\nAfter `const authored = editor.plugin(AuthoredPlugin)`, use these methods:\n\n| Surface | Purpose |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `tx.authored.propose(options?)` | Start a proposal, assign a new `{ changeId }`, or amend an owned pending change. |\n| `authored.read.canPropose()` | Report whether the current identity can author proposals; always true with `AuthoredPlugin`. |\n| `authored.read.view()` | Read this view's intent and projection. |\n| `authored.read.change(id)` | Read one logical change, or `null`. |\n| `authored.read.details(id)` | Read one change with semantic parts and decision history, or `null`. |\n| `authored.read.changes(query?)` | Read a page filtered by author, status, or time. |\n| `authored.read.changesAt(range)` | Read changes intersecting a text range. |\n| `authored.read.select(query)` | Capture a revision-bound selection for a decision. |\n| `authored.read.preview(decision)` | Check an accept or reject decision without applying it. |\n| `authored.update.decide(decision)` | Accept or reject the selected changes atomically. |\n| `authored.update.resolve(decision)` | Decide a complete conflict component explicitly. |\n| `authored.update.revert({ selection })` | Compensate retained accepted contributions. |\n| `authored.api.setView(view)` | Change the policy on this exact view. |\n| `authored.api.subscribeChanges(listener)` | Subscribe to immutable affected change IDs and decoration node keys for this view. |\n\n`subscribeChanges` is a low-level integration hook for semantic decorators. Suggestion applications use `useSuggestionChanges(path)` instead.\n\n### Types and format helpers\n\n`platejs/authored` exports the authored plugin descriptors, change and decision\ntypes, immutable projection results and diagnostics, and\n`createAuthoredReviewDocument` for trusted cumulative imported revisions.\n\n`projectAuthoredDocument(document, { projection })` returns one accepted or\nproposed `document`, the exact captured `review` document, diagnostics, and\nseparate `unresolved.pending` and `unresolved.conflicted` counts.\n`projectAuthoredReview(document)` also exposes stable visible markup segments\nand property counterparts. Both helpers are pure document operations: they do\nnot construct, activate, or subscribe an editor runtime.\n\n`projectAuthoredRange(projection, range)` maps proposed coordinates into the\naccepted document captured by the same projection. `parseAuthoredDocument`\nparses a JSON document envelope; the target editor remains responsible for its\napplication schema.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/authored-changes.mdx" } diff --git a/apps/www/public/r/block-list-static.json b/apps/www/public/r/block-list-static.json index 03ff776ff5..f04140d0c8 100644 --- a/apps/www/public/r/block-list-static.json +++ b/apps/www/public/r/block-list-static.json @@ -9,7 +9,7 @@ "files": [ { "path": "src/registry/components/editor/block-list-static.tsx", - "content": "import { CheckIcon } from 'lucide-react';\nimport {\n BaseListPlugin,\n isOrderedList,\n ListType,\n type RenderStaticNodeWrapper,\n} from 'platejs';\nimport * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\ntype ListWrapper = RenderStaticNodeWrapper;\n\nexport const BlockListStatic: ListWrapper = (props) => {\n const { listStyle, listType } = props.element;\n\n if (!listType || listType === ListType.Bulleted) return undefined;\n\n return function BlockListStaticWrapper(innerProps) {\n return (\n \n );\n };\n};\n\nfunction List(\n props: Parameters>>[0] & {\n listStart?: number;\n listStyle?: string;\n listType: ListType;\n }\n) {\n const { listStart, listStyle, listType } = props;\n const isTask = listType === ListType.Task;\n const InnerList = isOrderedList(props.element) ? 'ol' : 'ul';\n const markerStyle =\n listStyle ?? (listType === ListType.Numbered ? 'decimal' : 'none');\n\n return (\n \n {isTask && }\n \n {props.children}\n \n \n );\n}\n\nfunction TodoMarkerStatic(\n props: Parameters>>[0] & {\n listStart?: number;\n listStyle?: string;\n listType: ListType;\n }\n) {\n const checked = props.element.checked === true;\n\n return (\n
\n \n
\n {checked && }\n
\n \n
\n );\n}\n", + "content": "import { CheckIcon } from 'lucide-react';\nimport {\n BaseListPlugin,\n isOrderedList,\n ListType,\n type RenderStaticNodeWrapper,\n} from 'platejs';\nimport * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\ntype ListWrapper = RenderStaticNodeWrapper;\n\nexport const BlockListStatic: ListWrapper = (props) => {\n const { listStyle, listType } = props.element;\n\n if (!listType || listType === ListType.Bulleted) return undefined;\n\n return function BlockListStaticWrapper(innerProps) {\n return (\n \n );\n };\n};\n\nfunction List(\n props: Parameters>>[0] & {\n listStart?: number;\n listStyle?: string;\n listType: ListType;\n }\n) {\n const { listStart, listStyle, listType } = props;\n const isTask = listType === ListType.Task;\n const InnerList = isOrderedList(props.element) ? 'ol' : 'ul';\n const markerStyle =\n listStyle ?? (listType === ListType.Numbered ? 'decimal' : 'none');\n\n return (\n \n \n {isTask && }\n {props.children}\n \n \n );\n}\n\nfunction TodoMarkerStatic(\n props: Parameters>>[0] & {\n listStart?: number;\n listStyle?: string;\n listType: ListType;\n }\n) {\n const checked = props.element.checked === true;\n\n return (\n \n
\n {checked && }\n
\n \n );\n}\n", "type": "registry:component", "target": "@components/editor/block-list-static.tsx" } diff --git a/apps/www/public/r/docx-docs.json b/apps/www/public/r/docx-docs.json index 27917f5004..b10c75a965 100644 --- a/apps/www/public/r/docx-docs.json +++ b/apps/www/public/r/docx-docs.json @@ -6,7 +6,7 @@ "files": [ { "path": "../../content/docs/(plugins)/(serializing)/docx.mdx", - "content": "---\ntitle: DOCX\ndescription: Paste Word content, import bounded DOCX files, and export an explicit document projection.\n---\n\nUse `WordPastePlugin` for clipboard content. Use `importDocx` and `exportDocx` for files. The three operations have separate entrypoints and dependencies.\n\n\n\n\n\n## Features\n\n- Normalize HTML and RTF pasted from Microsoft Word\n- Import a bounded DOCX package into one complete editor document\n- Preserve supported Word revisions and comment ranges as structured data\n- Export one captured editor snapshot as an accepted, proposed, or review DOCX\n\n\n\n\n Paste, file import, and file download use browser APIs. Keep these operations\n in client code. Export uses React static rendering, so pass the descriptors\n and static components that define your document output.\n\n\n## Kit usage\n\nThe app-local `DocxKit` installs `WordPastePlugin`. File import and export are standalone functions.\n\n\n\n### Installation\n\n```bash\nnpm install platejs @tanstack/react-virtual juice validator\n```\n\n### Add kit\n\n\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { createEditor } from \"platejs/react\";\nimport { DocxKit } from \"@/components/editor/docx\";\n\nconst editor = createEditor({\n plugins: [...DocxKit],\n});\n```\n\n\n\n## Paste from Word\n\n`WordPastePlugin` inlines pasted CSS and normalizes Word HTML and RTF before the installed Plate format mappings decode the content.\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { WordPastePlugin } from \"platejs/docx/paste\";\nimport { createEditor } from \"platejs/react\";\n\nconst editor = createEditor({\n plugins: [WordPastePlugin],\n});\n```\n\nClipboard paste does not upload embedded images or preserve Word table geometry. Configure those destination policies in their owning media and table features.\n\n## Import a DOCX file\n\n`importDocx` reads a `Blob` or `ArrayBuffer` without modifying the editor. On success, replace the complete editor value so document metadata, named roots, and authored review state stay coherent.\n\nInside a mounted React control, get that complete editor with\n`useModelEditor()`. `useEditor()` returns the provider-selected mounted command\nview, whose root and authored projection belong to that view.\n\n```bash\nnpm install platejs @tanstack/react-virtual mammoth validator\n```\n\n```tsx title=\"components/editor/import-docx.tsx\"\nimport { importDocx } from \"platejs/docx/import\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n});\n\nexport async function importFile(file: File) {\n const result = await importDocx(file, {\n plugins: BaseEditorKit,\n });\n\n if (!result.ok) {\n return { diagnostics: result.diagnostics };\n }\n\n editor.update.value.replace(result.document);\n\n return {\n comments: result.comments,\n diagnostics: result.diagnostics,\n };\n}\n```\n\nAn expected file, package, limit, or decode failure returns `{ ok: false, diagnostics }` and no document. An aborted import rejects with the signal reason. Invalid options and internal invariant failures throw.\n\n### Retain the source for later export\n\nSet `retainSource: true` when the same editor imports, edits, and exports a Word file. A successful import then includes `source`. It is a `DocxSource` when exact export can return the package unchanged. Otherwise it is `null`, with a `source-unavailable` warning that names the first part exact export cannot admit. Keep the source in application state, pass it to `exportDocx`, and dispose it when the document closes or another file replaces it.\n\n```tsx title=\"components/editor/docx-roundtrip.tsx\" showLineNumbers\nimport { exportDocx } from \"platejs/docx/export\";\nimport { importDocx } from \"platejs/docx/import\";\n\nconst imported = await importDocx(file, {\n plugins: BaseEditorKit,\n retainSource: true,\n});\n\nif (!imported.ok) return imported.diagnostics;\n\neditor.update.value.replace(imported.document);\n\nconst exported = await exportDocx(editor, {\n projection: \"review\",\n source: imported.source,\n stylesheet,\n});\n\nimported.source?.dispose();\n```\n\nExact export admits a closed set of passive package content:\n\n- the main document at `word/document.xml`, styles, settings, numbering, fonts, and theme;\n- headers, footers, footnotes, endnotes, and comments;\n- core, extended, and custom document properties and a thumbnail;\n- AVIF, BMP, GIF, JPEG, PNG, and WebP images reached through internal relationships;\n- hyperlinks whose destination is absolute and meets the navigation floor.\n\n`PAGE` is the only recognized field instruction. Any other part, relationship, markup namespace, field instruction, or XML processing instruction leaves `source` null. That includes unsafe or relative hyperlinks, other external relationships, macros, ActiveX controls, OLE objects, imported HTML chunks, external templates, custom XML data, and SVG or EMF images. A null source does not mean the import lost content; exporting without a source generates the document from editor content.\n\n`DocxSource` retains the admitted compressed package plus immutable document, comment, schema, and limit correspondence. It does not retain expanded ZIP entries or an editor reference. `dispose()` is idempotent. An export that already acquired the source can finish after disposal; later exports report `source-unavailable` and generate a fresh DOCX.\n\n### Import comments\n\nThe importer returns comment format facts without writing app comment storage. Each `DocxComment` can include a rich Plate `body`, an exact `target.range` in proposed-projection coordinates, Word author and date metadata, a durable ID, a parent ID, and resolved state. Map those records to your app identities explicitly.\n\n### Import tracked revisions\n\nSupported Word insertions, deletions, moves, run property changes, and paragraph property changes become authored changes. Revision IDs follow Word source order and structural dependencies; dates remain metadata. A missing or invalid date uses the authored unknown-time value and produces a diagnostic.\n\nIf a revision cannot map to the installed schema, the importer keeps the Word-visible proposed content and reports the loss in `diagnostics`.\n\n### Import list numbering\n\nNumbered and bulleted Word paragraphs become list items, and each item joins the list of its Word numbering definition. Word counts every instance of a definition in one sequence per level. A list therefore continues past other paragraphs and across instances, and lists of different definitions that interleave keep their own numbers. A level restarts after a paragraph at the level its `lvlRestart` names or a shallower one, which by default is the level just above it. An instance's start override resets its level once, at the instance's first paragraph at that level. That item imports with the override as `listRestart`, and the items after it count on from it, so an inserted item renumbers the rest the way Word does. Each item's `listLevels` hold its definition's level starts, `lvlRestart` rules and level text, with its instance's format and level text overrides applied. Word ignores a start or `lvlRestart` inside a level override, and so does the import. A paragraph whose instance shows its level as bullets imports as a bulleted item and does not count.\n\n## Export a DOCX file\n\n`exportDocx` captures the editor once before asynchronous rendering. Pass the visible projection explicitly.\n\n```bash\nnpm install platejs @tanstack/react-virtual color-name html-to-vdom jszip juice mime-types virtual-dom xmlbuilder2\n```\n\n```tsx title=\"components/editor/export-docx.tsx\"\nimport { exportDocx } from \"platejs/docx/export\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\nimport { DOCX_EXPORT_STYLES } from \"@/components/editor/docx-export\";\n\nconst editor = createEditor({ plugins: BaseEditorKit });\n\nexport async function downloadEditorDocx() {\n const result = await exportDocx(editor, {\n orientation: \"portrait\",\n projection: \"proposed\",\n stylesheet: DOCX_EXPORT_STYLES,\n title: \"Document\",\n });\n\n if (!result.ok) return result.diagnostics;\n\n const url = URL.createObjectURL(result.blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"document.docx\";\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n\n return result.diagnostics;\n}\n```\n\nChoose `accepted` to omit pending changes, `proposed` to apply them, or `review` to write Word tracked-change markup. Accepted and proposed exports warn about omitted pending changes and conflicts resolved to one side. A review export with any conflict returns `ok: false` and an `authored-conflict` error because Word revisions cannot represent that state faithfully.\n\nA review file contains what Word displays: text, comments, and tracked revisions with their authors. Importing it restores those revisions as authored changes. Export does not write named roots, document metadata, or exact authored change records, such as their original IDs, to the file. To keep them, save the authored JSON document beside the file, as [Serializing](/docs/plate/serializing) describes:\n\n```tsx\nimport { parseAuthoredDocument, projectAuthoredReview } from \"platejs/authored\";\n\nconst json = JSON.stringify(projectAuthoredReview(editor.read.value()).review);\nconst document = parseAuthoredDocument(json);\n```\n\n### Content loss\n\n`exportDocx` rejects content loss by default. When an image cannot be embedded, or a supplied comment has no representable range, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: \"allow\"` to download the file and receive the same loss as warnings:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n lossPolicy: \"allow\",\n projection: \"review\",\n});\n```\n\nNamed roots, document metadata, removed link destinations, and other properties DOCX cannot represent are warnings under both policies. An `accepted` export omits a comment whose annotated content that projection excludes, and reports a warning, as Word does when the annotated suggestion is rejected.\n\n### Lists\n\nEach Plate list becomes one Word list. Its `listLevels` give the list's level starts, `lvlRestart` rules and level text. An item with `listRestart`, or one whose number Word would not reach, gets a numbering instance of its own that overrides that level's start, as Word's Restart Numbering writes it. The items after it count on from it in the list's own instance. Word and a re-import then show the numbers the editor shows, and the restart stays on that item. Word draws one marker style per list level, so an item whose `listStyle` differs from the first item at its level keeps that first style and reports a `list-marker` warning under both policies. A Word list level is numbered or bulleted, as its first item is, and the exporter writes one set of level rules per list. An item of the other kind at its level is therefore not part of its list in Word, and an item whose `listLevels` differ from its list's first item takes that item's rules. Each reports a `list-structure` warning, and `lossPolicy: 'reject'` fails the export. Word has no start that applies only when an item opens its level, so an item with `listStart` exports its shown number and reports a `list-start` warning under both policies.\n\n### Links and images\n\nExport writes a link destination only when it is absolute and meets the navigation floor, such as `https:` or `mailto:`. It writes bookmark links such as `#introduction` as Word anchors. An unsafe destination, or a relative link that has no document base in a Word file, keeps its text without the destination.\n\nImages are embedded as PNG, JPEG, GIF, or BMP. Set `allowRemoteImages: true` to fetch HTTP(S) images during export. These images are omitted and leave their alt text:\n\n- an image with an unsafe, relative, or blob source;\n- a remote image when `allowRemoteImages` is off;\n- an image that cannot be fetched;\n- an image in any other format.\n\nBefore returning a generated file, export checks the written package against the same passive vocabulary that retained sources use. Output outside it is withheld with an `invalid-package` error. An unchanged source-aware export returns the retained file, which passed the same check when it was imported.\n\n### Source-aware export\n\nWith a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, or `pageSize` override. Static renderer options are bypassed on this path because no content is regenerated.\n\nIf an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting:\n\n- headers and footers from a document with one section, including their images and hyperlinks;\n- custom document properties.\n\nThe regenerated package has its own core document properties. It omits source body XML, body-only parts such as footnotes and body images, and headers and footers from a document with multiple sections. It also omits the source core and extended properties and the package thumbnail: the core properties, such as the author, title and creation date, the extended properties, which count pages and words and hold details such as the company name, and the thumbnail, which pictures the body. Diagnostics report why the source was rewritten and identify each omitted source part. A custom property linked to body text keeps the value cached in the source file, even when the edit changed that text.\n\nWhen regeneration is required, omitted `comments` means that no comments are generated. Pass the current app-owned comment set explicitly to retain comments after an edit. Imported comment ranges are not relocated automatically.\n\n### Export comments\n\nPass comments whose ranges address the editor's proposed projection. Export maps those ranges into accepted or review output before rendering:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n projection: \"review\",\n});\n```\n\nThe same configured static renderer handles the main document and rich comment bodies. Under the default `lossPolicy`, a comment without a complete range fails the export; with `lossPolicy: \"allow\"` the export omits it with a warning.\n\n### Static rendering and styles\n\nDOCX owns its semantic static mappings for code blocks, columns, equations,\ncallouts, headings, and tables of contents. The app supplies presentation CSS\nand may pass a custom root `component` when it needs a different wrapper.\n`DOCX_EXPORT_STYLES` is the registry's copied presentation preset.\n\n\n\n## Format support\n\n| Content | Behavior |\n| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n| Paragraphs, headings, supported marks and properties | Decode through the installed HTML mappings and export through the configured static renderer |\n| Lists, links, bookmarks, rows, cells, and spans | Preserve supported semantic structure; omit Word layout details with diagnostics |\n| Tracked revisions | Preserve supported insert, delete, move, run-property, and paragraph-property changes |\n| Main-body comments | Preserve rich bodies, ranges, author/date metadata, durable IDs, replies, and resolved state when present |\n| Embedded images during import | Omit the resource and return a `resource-omitted` diagnostic |\n| Links and images during export | Write absolute navigation-safe links and PNG, JPEG, GIF, or BMP images; report removed destinations and omitted images |\n| Single-section headers and footers | Keep safe closed source subgraphs during source-aware export; do not map them into the editor document |\n| Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics |\n| Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content |\n| Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON |\n| Custom Plate, math, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics |\n\n## API reference\n\n### Entrypoints\n\n| Surface | Import path |\n| ------------------------------- | --------------------- |\n| `WordPastePlugin` | `platejs/docx/paste` |\n| `importDocx` and import types | `platejs/docx/import` |\n| `exportDocx` and export types | `platejs/docx/export` |\n\n### `importDocx`\n\n```ts\nimportDocx(\n source: ArrayBuffer | Blob,\n options: DocxImportOptions\n): Promise\n```\n\n`DocxImportOptions` requires `plugins` and accepts `schema`, a cooperative\n`signal`, `lossPolicy`, partial `limits` overrides, and\n`retainSource`. Import captures that detached target before its first await and\nnever reads a live editor or mutable plugin store. Every limit must be a\npositive safe integer. Literal `retainSource: true` conditionally adds\n`source: DocxSource | null` to a successful result; omitted or literal `false`\ndoes not.\n\n| Default limit | Value |\n| ------------------ | ------: |\n| `maxInputBytes` | 32 MiB |\n| `maxEntries` | 1,024 |\n| `maxEntryBytes` | 32 MiB |\n| `maxExpandedBytes` | 128 MiB |\n| `maxRelationships` | 4,096 |\n| `maxRevisions` | 2,000 |\n| `maxComments` | 5,000 |\n| `maxXmlDepth` | 128 |\n| `maxXmlNodes` | 500,000 |\n\nA successful result contains `document`, `comments`, and structured `diagnostics`. A retained success also contains `source`, which is `null` when exact export cannot admit the package. A failed result contains only `diagnostics`.\n\n### `DocxSource`\n\n```ts\nclass DocxSource {\n private constructor();\n dispose(): void;\n}\n```\n\nOnly `importDocx` creates a `DocxSource`. The object is process-local and cannot be serialized or reattached. To restore source-aware export after application reload, keep the original file and import it again.\n\n### `exportDocx`\n\n```ts\nexportDocx(\n editor: Editor,\n options: DocxExportOptions\n): Promise\n```\n\n| Option | Type | Description |\n| ----------------------- | ---------------------------------------- | -------------------------------------------------- |\n| `projection` | `'accepted' \\| 'proposed' \\| 'review'` | Required visible document projection |\n| `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates |\n| `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` |\n| `component` | `React.ComponentType` | Optional static editor wrapper |\n| `fontFamily` | `string` | Document body font |\n| `lossPolicy` | `'allow' \\| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` |\n| `margins` | `Margins` | Page margins in twentieths of a point |\n| `orientation` | `'landscape' \\| 'portrait'` | Page orientation |\n| `pageSize` | `PageSize` | Page dimensions in twentieths of a point |\n| `signal` | `AbortSignal` | Cooperative cancellation signal |\n| `source` | `DocxSource \\| null` | Retained source correspondence for exact or safe export |\n| `stylesheet` | `string` | CSS applied before DOCX conversion |\n| `title` | `string` | Document metadata title |\n\nA successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`.\n\nSee [Authored Changes](/docs/plate/authored-changes) for the editor's accepted, proposed, and review projections.\n", + "content": "---\ntitle: DOCX\ndescription: Paste Word content, import bounded DOCX files, and export an explicit document projection.\n---\n\nUse `WordPastePlugin` for clipboard content. Use `importDocx` and `exportDocx` for files. The three operations have separate entrypoints and dependencies.\n\n\n\n\n\n## Features\n\n- Normalize HTML and RTF pasted from Microsoft Word\n- Import a bounded DOCX package into one complete editor document\n- Preserve supported Word revisions and comment ranges as structured data\n- Export one captured editor snapshot as an accepted, proposed, or review DOCX\n\n\n\n\n Paste, file import, and file download use browser APIs. Keep these operations\n in client code. Export uses React static rendering, so pass the descriptors\n and static components that define your document output.\n\n\n## Kit usage\n\nThe app-local `DocxKit` installs `WordPastePlugin`. File import and export are standalone functions.\n\n\n\n### Installation\n\n```bash\nnpm install platejs @tanstack/react-virtual juice validator\n```\n\n### Add kit\n\n\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { createEditor } from \"platejs/react\";\nimport { DocxKit } from \"@/components/editor/docx\";\n\nconst editor = createEditor({\n plugins: [...DocxKit],\n});\n```\n\n\n\n## Paste from Word\n\n`WordPastePlugin` inlines pasted CSS and normalizes Word HTML and RTF before the installed Plate format mappings decode the content.\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { WordPastePlugin } from \"platejs/docx/paste\";\nimport { createEditor } from \"platejs/react\";\n\nconst editor = createEditor({\n plugins: [WordPastePlugin],\n});\n```\n\nClipboard paste does not upload embedded images or preserve Word table geometry. Configure those destination policies in their owning media and table features.\n\n## Import a DOCX file\n\n`importDocx` reads a `Blob` or `ArrayBuffer` without modifying the editor. On success, replace the complete editor value so document metadata, named roots, and authored review state stay coherent.\n\nInside a mounted React control, get that complete editor with\n`useModelEditor()`. `useEditor()` returns the provider-selected mounted command\nview, whose root and authored projection belong to that view.\n\n```bash\nnpm install platejs @tanstack/react-virtual mammoth validator\n```\n\n```tsx title=\"components/editor/import-docx.tsx\"\nimport { importDocx } from \"platejs/docx/import\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n});\n\nexport async function importFile(file: File) {\n const result = await importDocx(file, {\n plugins: BaseEditorKit,\n });\n\n if (!result.ok) {\n return { diagnostics: result.diagnostics };\n }\n\n editor.update.value.replace(result.document);\n\n return {\n comments: result.comments,\n diagnostics: result.diagnostics,\n };\n}\n```\n\nAn expected file, package, limit, or decode failure returns `{ ok: false, diagnostics }` and no document. An aborted import rejects with the signal reason. Invalid options and internal invariant failures throw.\n\n### Retain the source for later export\n\nSet `retainSource: true` when the same editor imports, edits, and exports a Word file. A successful import then includes `source`. It is a `DocxSource` when exact export can return the package unchanged. Otherwise it is `null`, with a `source-unavailable` warning that names the first part exact export cannot admit. Keep the source in application state, pass it to `exportDocx`, and dispose it when the document closes or another file replaces it.\n\n```tsx title=\"components/editor/docx-roundtrip.tsx\" showLineNumbers\nimport { exportDocx } from \"platejs/docx/export\";\nimport { importDocx } from \"platejs/docx/import\";\n\nconst imported = await importDocx(file, {\n plugins: BaseEditorKit,\n retainSource: true,\n});\n\nif (!imported.ok) return imported.diagnostics;\n\neditor.update.value.replace(imported.document);\n\nconst exported = await exportDocx(editor, {\n projection: \"review\",\n source: imported.source,\n});\n\nimported.source?.dispose();\n```\n\nExact export admits a closed set of passive package content:\n\n- the main document at `word/document.xml`, styles, settings, numbering, fonts, and theme;\n- headers, footers, footnotes, endnotes, and comments;\n- core, extended, and custom document properties and a thumbnail;\n- AVIF, BMP, GIF, JPEG, PNG, and WebP images reached through internal relationships;\n- hyperlinks whose destination is absolute and meets the navigation floor.\n\n`PAGE` is the only recognized field instruction. Any other part, relationship, markup namespace, field instruction, or XML processing instruction leaves `source` null. That includes unsafe or relative hyperlinks, other external relationships, macros, ActiveX controls, OLE objects, imported HTML chunks, external templates, custom XML data, and SVG or EMF images. A null source does not mean the import lost content; exporting without a source generates the document from editor content.\n\n`DocxSource` retains the admitted compressed package plus immutable document, comment, schema, and limit correspondence. It does not retain expanded ZIP entries or an editor reference. `dispose()` is idempotent. An export that already acquired the source can finish after disposal; later exports report `source-unavailable` and generate a fresh DOCX.\n\n### Import comments\n\nThe importer returns comment format facts without writing app comment storage. Each `DocxComment` can include a rich Plate `body`, an exact `target.range` in proposed-projection coordinates, Word author and date metadata, a durable ID, a parent ID, and resolved state. Map those records to your app identities explicitly.\n\n### Import tracked revisions\n\nSupported Word insertions, deletions, moves, run property changes, and paragraph property changes become authored changes. Revision IDs follow Word source order and structural dependencies; dates remain metadata. A missing or invalid date uses the authored unknown-time value and produces a diagnostic.\n\nIf a revision cannot map to the installed schema, the importer keeps the Word-visible proposed content and reports the loss in `diagnostics`.\n\n### Import list numbering\n\nNumbered and bulleted Word paragraphs become list items, and each item joins the list of its Word numbering definition. Word counts every instance of a definition in one sequence per level. A list therefore continues past other paragraphs and across instances, and lists of different definitions that interleave keep their own numbers. A level restarts after a paragraph at the level its `lvlRestart` names or a shallower one, which by default is the level just above it. An instance's start override resets its level once, at the instance's first paragraph at that level. That item imports with the override as `listRestart`, and the items after it count on from it, so an inserted item renumbers the rest the way Word does. Each item's `listLevels` hold its definition's level starts, `lvlRestart` rules and level text, with its instance's format and level text overrides applied. Word ignores a start or `lvlRestart` inside a level override, and so does the import. A paragraph whose instance shows its level as bullets imports as a bulleted item and does not count.\n\n## Export a DOCX file\n\n`exportDocx` captures the editor once before asynchronous rendering. Pass the visible projection explicitly.\n\n```bash\nnpm install platejs @tanstack/react-virtual color-name html-to-vdom jszip juice mime-types virtual-dom xmlbuilder2\n```\n\n```tsx title=\"components/editor/export-docx.tsx\"\nimport { exportDocx } from \"platejs/docx/export\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\nimport { DOCX_EXPORT_STYLES } from \"@/components/editor/docx-export\";\n\nconst editor = createEditor({ plugins: BaseEditorKit });\n\nexport async function downloadEditorDocx() {\n const result = await exportDocx(editor, {\n orientation: \"portrait\",\n projection: \"proposed\",\n stylesheet: DOCX_EXPORT_STYLES,\n title: \"Document\",\n });\n\n if (!result.ok) return result.diagnostics;\n\n const url = URL.createObjectURL(result.blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"document.docx\";\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n\n return result.diagnostics;\n}\n```\n\nChoose `accepted` to omit pending changes, `proposed` to apply them, or `review` to write Word tracked-change markup. Accepted and proposed exports warn about omitted pending changes and conflicts resolved to one side. A review export with any conflict returns `ok: false` and an `authored-conflict` error because Word revisions cannot represent that state faithfully.\n\nA review file contains what Word displays: text, comments, and tracked revisions with their authors. Importing it restores those revisions as authored changes. Export does not write named roots, document metadata, or exact authored change records, such as their original IDs, to the file. To keep them, save the authored JSON document beside the file, as [Serializing](/docs/plate/serializing) describes:\n\n```tsx\nimport { parseAuthoredDocument, projectAuthoredReview } from \"platejs/authored\";\n\nconst json = JSON.stringify(projectAuthoredReview(editor.read.value()).review);\nconst document = parseAuthoredDocument(json);\n```\n\n### Content loss\n\n`exportDocx` rejects content loss by default. When an image cannot be embedded, a supplied comment has no representable range, or a node has no drawing in `presentation`, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: \"allow\"` to download the file and receive the same loss as warnings:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n lossPolicy: \"allow\",\n projection: \"review\",\n});\n```\n\nNamed roots, document metadata, removed link destinations, and other properties DOCX cannot represent are warnings under both policies. An `accepted` export omits a comment whose annotated content that projection excludes, and reports a warning, as Word does when the annotated suggestion is rejected.\n\n### Lists\n\nEach Plate list becomes one Word list. Its `listLevels` give the list's level starts, `lvlRestart` rules and level text. An item with `listRestart`, or one whose number Word would not reach, gets a numbering instance of its own that overrides that level's start, as Word's Restart Numbering writes it. The items after it count on from it in the list's own instance. Word and a re-import then show the numbers the editor shows, and the restart stays on that item. Word draws one marker style per list level, so an item whose `listStyle` differs from the first item at its level keeps that first style and reports a `list-marker` warning under both policies. A Word list level is numbered or bulleted, as its first item is, and the exporter writes one set of level rules per list. An item of the other kind at its level is therefore not part of its list in Word, and an item whose `listLevels` differ from its list's first item takes that item's rules. Each reports a `list-structure` warning, and `lossPolicy: 'reject'` fails the export. Word has no start that applies only when an item opens its level, so an item with `listStart` exports its shown number and reports a `list-start` warning under both policies.\n\n### Links and images\n\nExport writes a link destination only when it is absolute and meets the navigation floor, such as `https:` or `mailto:`. It writes bookmark links such as `#introduction` as Word anchors. An unsafe destination, or a relative link that has no document base in a Word file, keeps its text without the destination.\n\nImages are embedded as PNG, JPEG, GIF, or BMP. Set `allowRemoteImages: true` to fetch HTTP(S) images during export. These images are omitted and leave their alt text:\n\n- an image with an unsafe, relative, or blob source;\n- a remote image when `allowRemoteImages` is off;\n- an image that cannot be fetched;\n- an image in any other format.\n\nBefore returning a generated file, export checks the written package against the same passive vocabulary that retained sources use. Output outside it is withheld with an `invalid-package` error. An unchanged source-aware export returns the retained file, which passed the same check when it was imported.\n\n### Source-aware export\n\nWith a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, `pageSize`, `stylesheet`, or `fontFamily` option. `component` and `presentation` do not apply on this path, because export regenerates nothing.\n\nIf an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting:\n\n- headers and footers from a document with one section, including their images and hyperlinks;\n- custom document properties.\n\nThe regenerated package has its own core document properties. It omits source body XML, body-only parts such as footnotes and body images, and headers and footers from a document with multiple sections. It also omits the source core and extended properties and the package thumbnail: the core properties, such as the author, title and creation date, the extended properties, which count pages and words and hold details such as the company name, and the thumbnail, which pictures the body. Diagnostics report why the source was rewritten and identify each omitted source part. A custom property linked to body text keeps the value cached in the source file, even when the edit changed that text.\n\nWhen regeneration is required, omitted `comments` means that no comments are generated. Pass the current app-owned comment set explicitly to retain comments after an edit. Imported comment ranges are not relocated automatically.\n\n### Export comments\n\nPass comments whose ranges address the editor's proposed projection. Export maps those ranges into accepted or review output before rendering:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n projection: \"review\",\n});\n```\n\nThe same configured static renderer handles the main document and rich comment bodies. Under the default `lossPolicy`, a comment without a complete range fails the export; with `lossPolicy: \"allow\"` the export omits it with a warning.\n\n### Static rendering and styles\n\nDOCX owns its semantic static mappings for code blocks, columns, equations,\ncallouts, headings, and tables of contents. They set no color, font, or font\nsize beyond an element's own values, such as a callout's color. The app's\n`stylesheet` sets them and can select:\n\n- `editor-`, the class on each drawn element, such as `.editor-codeBlock`;\n- `table > tbody > tr > td` inside a callout, whose first cell holds the icon;\n- `[data-docx-preserve-whitespace]`, the text of a code block;\n- `[data-empty]`, the placeholder of an empty equation or table of contents.\n\nThe app may pass a custom root `component` when it needs a different wrapper.\n`DOCX_EXPORT_STYLES` is the registry's copied presentation preset.\n\nEquations export as Word math built from KaTeX's MathML. An equation that uses\nnotation the converter does not cover, such as a matrix, exports its TeX as\ntext inside a Word equation. A task list item, an `li` with `data-checked`, exports with a ☑ or ☐\nlist marker.\n\nA live editor's components are editing UI. Pass the app's static kit as\n`presentation` so the body and every comment body draw with static components\nof the same plugin name. A plugin with no static version exports its content\nwithout its component or wrapper and reports a `missing-static-presentation`\ndiagnostic, which counts as lost content under `lossPolicy`.\n\n\n\n## Format support\n\n| Content | Behavior |\n| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n| Paragraphs, headings, supported marks and properties | Decode through the installed HTML mappings and export through the configured static renderer |\n| Lists, links, bookmarks, rows, cells, and spans | Preserve supported semantic structure; omit Word layout details with diagnostics |\n| Tracked revisions | Preserve supported insert, delete, move, run-property, and paragraph-property changes |\n| Main-body comments | Preserve rich bodies, ranges, author/date metadata, durable IDs, replies, and resolved state when present |\n| Embedded images during import | Omit the resource and return a `resource-omitted` diagnostic |\n| Links and images during export | Write absolute navigation-safe links and PNG, JPEG, GIF, or BMP images; report removed destinations and omitted images |\n| Single-section headers and footers | Keep safe closed source subgraphs during source-aware export; do not map them into the editor document |\n| Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics |\n| Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content |\n| Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON |\n| Equations | Export as Word math, or as TeX inside a Word equation when the converter does not cover the notation. Import reads Word math into `equation` and `inlineEquation` nodes, with TeX rebuilt from the equation's structure, or into TeX text when those plugins are not installed. A display equation that shares its paragraph with a comment, tracked change or text, or sits in a list item or an indented paragraph, imports inline |\n| Task lists | Give an item drawn as an `li` with `data-checked` a ☑ or ☐ list marker |\n| Custom Plate, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics |\n\n## API reference\n\n### Entrypoints\n\n| Surface | Import path |\n| ------------------------------- | --------------------- |\n| `WordPastePlugin` | `platejs/docx/paste` |\n| `importDocx` and import types | `platejs/docx/import` |\n| `exportDocx` and export types | `platejs/docx/export` |\n\n### `importDocx`\n\n```ts\nimportDocx(\n source: ArrayBuffer | Blob,\n options: DocxImportOptions\n): Promise\n```\n\n`DocxImportOptions` requires `plugins` and accepts `schema`, a cooperative\n`signal`, `lossPolicy`, partial `limits` overrides, and\n`retainSource`. Import captures that detached target before its first await and\nnever reads a live editor or mutable plugin store. Every limit must be a\npositive safe integer. Literal `retainSource: true` conditionally adds\n`source: DocxSource | null` to a successful result; omitted or literal `false`\ndoes not.\n\n| Default limit | Value |\n| ------------------ | ------: |\n| `maxInputBytes` | 32 MiB |\n| `maxEntries` | 1,024 |\n| `maxEntryBytes` | 32 MiB |\n| `maxExpandedBytes` | 128 MiB |\n| `maxRelationships` | 4,096 |\n| `maxRevisions` | 2,000 |\n| `maxComments` | 5,000 |\n| `maxXmlDepth` | 128 |\n| `maxXmlNodes` | 500,000 |\n\nA successful result contains `document`, `comments`, and structured `diagnostics`. A retained success also contains `source`, which is `null` when exact export cannot admit the package. A failed result contains only `diagnostics`.\n\n### `DocxSource`\n\n```ts\nclass DocxSource {\n private constructor();\n dispose(): void;\n}\n```\n\nOnly `importDocx` creates a `DocxSource`. The object is process-local and cannot be serialized or reattached. To restore source-aware export after application reload, keep the original file and import it again.\n\n### `exportDocx`\n\n```ts\nexportDocx(\n editor: Editor,\n options: DocxExportOptions\n): Promise\n```\n\n| Option | Type | Description |\n| ----------------------- | ---------------------------------------- | -------------------------------------------------- |\n| `projection` | `'accepted' \\| 'proposed' \\| 'review'` | Required visible document projection |\n| `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates |\n| `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` |\n| `component` | `React.ComponentType` | Optional static editor wrapper |\n| `fontFamily` | `string` | Document body font; rebuilds an unchanged `source` |\n| `lossPolicy` | `'allow' \\| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` |\n| `margins` | `Margins` | Page margins in twentieths of a point |\n| `orientation` | `'landscape' \\| 'portrait'` | Page orientation |\n| `pageSize` | `PageSize` | Page dimensions in twentieths of a point |\n| `presentation` | `readonly BasePluginInput[]` | Static plugins that draw the body and comment bodies, matched by plugin name |\n| `signal` | `AbortSignal` | Cooperative cancellation signal |\n| `source` | `DocxSource \\| null` | Retained source correspondence for exact or safe export |\n| `stylesheet` | `string` | CSS that sets the Word look; rebuilds an unchanged `source` |\n| `title` | `string` | Document metadata title |\n\nA successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`.\n\nSee [Authored Changes](/docs/plate/authored-changes) for the editor's accepted, proposed, and review projections.\n", "type": "registry:file", "target": "content/docs/plate/(plugins)/(serializing)/docx.mdx" } diff --git a/apps/www/public/r/docx-export.json b/apps/www/public/r/docx-export.json index 531f70271d..5ebaf50217 100644 --- a/apps/www/public/r/docx-export.json +++ b/apps/www/public/r/docx-export.json @@ -4,7 +4,7 @@ "files": [ { "path": "src/registry/components/editor/docx-export.tsx", - "content": "export const DOCX_EXPORT_STYLES = `\nbody {\n font-family: 'Calibri', 'Arial', sans-serif;\n font-size: 11pt;\n line-height: 1.5;\n color: #000;\n margin: 0;\n padding: 20px;\n}\nh1 { font-size: 24pt; font-weight: bold; margin: 0 0 12pt 0; }\nh2 { font-size: 18pt; font-weight: bold; margin: 0 0 10pt 0; }\nh3 { font-size: 14pt; font-weight: bold; margin: 0 0 8pt 0; }\nh4 { font-size: 12pt; font-weight: bold; margin: 0 0 6pt 0; }\nh5 { font-size: 11pt; font-weight: bold; margin: 0 0 6pt 0; }\nh6 { font-size: 10pt; font-weight: bold; margin: 0 0 6pt 0; }\np { margin: 0 0 8pt 0; }\nul, ol { margin: 0 0 8pt 0; padding-left: 20pt; }\nli { margin: 0 0 4pt 0; }\nstrong, b { font-weight: bold; }\nem, i { font-style: italic; }\nu { text-decoration: underline; }\ns, strike, del { text-decoration: line-through; }\ncode {\n font-family: 'Courier New', Consolas, monospace;\n background-color: #f5f5f5;\n padding: 2px 4px;\n border-radius: 3px;\n}\npre {\n font-family: 'Courier New', Consolas, monospace;\n background-color: #f5f5f5;\n padding: 10px;\n margin: 0 0 8pt 0;\n white-space: pre-wrap;\n border-radius: 4px;\n}\n.hljs-addition, .hljs-name, .hljs-quote, .hljs-selector-pseudo, .hljs-selector-tag { color: #22863a; }\n.hljs-attr, .hljs-attribute, .hljs-literal, .hljs-meta, .hljs-number, .hljs-operator,\n.hljs-section, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id, .hljs-variable { color: #005cc5; }\n.hljs-built_in, .hljs-symbol { color: #e36209; }\n.hljs-bullet { color: #735c0f; }\n.hljs-comment, .hljs-formula { color: #6a737d; }\n.hljs-deletion { color: #b31d28; }\n.hljs-doctag, .hljs-keyword, .hljs-template-tag, .hljs-template-variable, .hljs-type { color: #d73a49; }\n.hljs-regexp, .hljs-string { color: #032f62; }\n.hljs-title { color: #6f42c1; }\n.hljs-emphasis { font-style: italic; }\n.hljs-section, .hljs-strong { font-weight: bold; }\nblockquote {\n border-left: 3px solid #ccc;\n margin: 0 0 8pt 0;\n padding-left: 10pt;\n color: #666;\n font-style: italic;\n}\ntable {\n border-collapse: collapse;\n width: 100%;\n margin: 0 0 8pt 0;\n}\nth, td {\n border: 1px solid #ccc;\n padding: 6pt;\n text-align: left;\n}\nth {\n background-color: #f5f5f5;\n font-weight: bold;\n}\na {\n color: #0066cc;\n text-decoration: underline;\n}\nimg {\n max-width: 100%;\n height: auto;\n}\nhr {\n border: none;\n border-top: 1px solid #ccc;\n margin: 12pt 0;\n}\nsup { vertical-align: super; font-size: 8pt; }\nsub { vertical-align: sub; font-size: 8pt; }\nmark { background-color: #ffff00; }\n`.trim();\n", + "content": "export const DOCX_EXPORT_STYLES = `\nbody {\n font-family: 'Calibri', 'Arial', sans-serif;\n font-size: 11pt;\n line-height: 1.5;\n color: #000;\n margin: 0;\n padding: 20px;\n}\nh1 { font-size: 24pt; font-weight: bold; margin: 0 0 12pt 0; }\nh2 { font-size: 18pt; font-weight: bold; margin: 0 0 10pt 0; }\nh3 { font-size: 14pt; font-weight: bold; margin: 0 0 8pt 0; }\nh4 { font-size: 12pt; font-weight: bold; margin: 0 0 6pt 0; }\nh5 { font-size: 11pt; font-weight: bold; margin: 0 0 6pt 0; }\nh6 { font-size: 10pt; font-weight: bold; margin: 0 0 6pt 0; }\np { margin: 0 0 8pt 0; }\nul, ol { margin: 0 0 8pt 0; padding-left: 20pt; }\nli { margin: 0 0 4pt 0; }\nstrong, b { font-weight: bold; }\nem, i { font-style: italic; }\nu { text-decoration: underline; }\ns, strike, del { text-decoration: line-through; }\ncode {\n font-family: 'Courier New', Consolas, monospace;\n background-color: #f5f5f5;\n padding: 2px 4px;\n border-radius: 3px;\n}\npre {\n font-family: 'Courier New', Consolas, monospace;\n background-color: #f5f5f5;\n padding: 10px;\n margin: 0 0 8pt 0;\n white-space: pre-wrap;\n border-radius: 4px;\n}\n.hljs-addition, .hljs-name, .hljs-quote, .hljs-selector-pseudo, .hljs-selector-tag { color: #22863a; }\n.hljs-attr, .hljs-attribute, .hljs-literal, .hljs-meta, .hljs-number, .hljs-operator,\n.hljs-section, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id, .hljs-variable { color: #005cc5; }\n.hljs-built_in, .hljs-symbol { color: #e36209; }\n.hljs-bullet { color: #735c0f; }\n.hljs-comment, .hljs-formula { color: #6a737d; }\n.hljs-deletion { color: #b31d28; }\n.hljs-doctag, .hljs-keyword, .hljs-template-tag, .hljs-template-variable, .hljs-type { color: #d73a49; }\n.hljs-regexp, .hljs-string { color: #032f62; }\n.hljs-title { color: #6f42c1; }\n.hljs-emphasis { font-style: italic; }\n.hljs-section, .hljs-strong { font-weight: bold; }\nblockquote {\n border-left: 3px solid #ccc;\n margin: 0 0 8pt 0;\n padding-left: 10pt;\n color: #666;\n font-style: italic;\n}\ntable {\n border-collapse: collapse;\n width: 100%;\n margin: 0 0 8pt 0;\n}\nth, td {\n border: 1px solid #ccc;\n padding: 6pt;\n text-align: left;\n}\nth {\n background-color: #f5f5f5;\n font-weight: bold;\n}\na {\n color: #0066cc;\n text-decoration: underline;\n}\nimg {\n max-width: 100%;\n height: auto;\n}\nhr {\n border: none;\n border-top: 1px solid #ccc;\n margin: 12pt 0;\n}\nsup { vertical-align: super; font-size: 8pt; }\nsub { vertical-align: sub; font-size: 8pt; }\nmark { background-color: #ffff00; }\n.editor-callout > table > tbody > tr > td { background-color: #f4f4f5; }\n.editor-callout > table > tbody > tr > td:first-child {\n font-family: \"Apple Color Emoji\", \"Segoe UI Emoji\", NotoColorEmoji, \"Noto Color Emoji\", \"Segoe UI Symbol\", \"Android Emoji\", EmojiSymbols;\n font-size: 18px;\n}\n.editor-codeBlock > [data-docx-preserve-whitespace] {\n background-color: #f5f5f5;\n font-family: 'Courier New', Consolas, monospace;\n font-size: 10pt;\n}\n.editor-equation [data-empty],\n.editor-inlineEquation [data-empty] { color: #888; }\n.editor-toc [data-empty] { color: #666; font-size: 10pt; }\n`.trim();\n", "type": "registry:component", "target": "@components/editor/docx-export.tsx" } diff --git a/apps/www/public/r/examples-export-docs.json b/apps/www/public/r/examples-export-docs.json index 126da7f430..fbec9b0e6e 100644 --- a/apps/www/public/r/examples-export-docs.json +++ b/apps/www/public/r/examples-export-docs.json @@ -6,7 +6,7 @@ "files": [ { "path": "../../content/docs/examples/export.mdx", - "content": "---\ntitle: Export\ndescription: Export a Plate document to HTML, Markdown, or Word.\ndocs:\n - route: https://pro.platejs.org/docs/examples/export\n title: Plus\n---\n\nThe registry `export-toolbar-button` downloads the current document as HTML,\nMarkdown, or Word from the browser. The demo mounts it in the fixed toolbar.\n\n## Demo\n\n\n\n## Toolbar source\n\nInstall the [Export Toolbar Button](/docs/plate/components/export-toolbar-button)\ncomponent to add the menu to an editor toolbar.\n\n\n\n## Export formats\n\n| Format | Implementation | Output |\n| -------- | -------------- | ------ |\n| HTML | `renderStaticHtml(model, { component: EditorStatic, projection })` from `platejs/static` | `plate.html` |\n| Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` |\n| Word | `exportDocx(model, { component: EditorStatic, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` |\n\nEvery format captures the same complete document and explicit authored\nprojection from `useModelEditor()`. Export reads model content rather than the\nDOM, so selection, placeholders, and review decorations do not enter the file.\nThe current editor supplies its compiled schema, format mappings, and static component\nbindings; export does not create a second conversion editor.\n\nThe HTML file inlines the app's own stylesheets and copies the page's `body`\nclasses. Cross-origin stylesheets stay as links.\n\n## Suggestions\n\nWhen the document has unresolved [suggestions](/docs/plate/suggestion), the menu shows\nan **Unresolved suggestions** choice:\n\n| Choice | Projection | Result |\n| ------------------------- | ---------- | ------------------------------------------- |\n| Include suggested changes | `proposed` | The document with pending suggestions applied |\n| Exclude suggested changes | `accepted` | The document without pending suggestions |\n\nThe initial choice follows the mounted view. HTML, Markdown, and Word use the\nsame choice. **Export as Word with tracked changes** uses `review`, which writes\npending suggestions as Word revisions. A conflict has no faithful Word\nrevision, so review export returns an error until the conflict is resolved.\n\nWithout unresolved suggestions, Word export uses `review`. A retained,\nunchanged imported DOCX can then be returned byte for byte. Each exporter\nreturns diagnostics, which the menu presents as warning or error toasts.\n\n## DOCX presentation\n\nDOCX owns semantic mappings for Plate code blocks, columns, equations,\ncallouts, headings, and tables of contents. The registry keeps only the copied\nvisual stylesheet:\n\n\n\n## Plus export\n\nPlate Plus includes a server-side export flow for PDF output with page settings.\n\n\n\n## Related\n\n- [DOCX](/docs/plate/docx) covers DOCX paste, file import, and export APIs.\n- [Markdown](/docs/plate/markdown) covers Markdown serialization.\n- [Static Rendering](/docs/plate/static) covers static editor rendering and HTML serialization.\n- [Authored Changes](/docs/plate/authored-changes) covers visible HTML/Markdown projections, authored JSON, and DOCX review.\n", + "content": "---\ntitle: Export\ndescription: Export a Plate document to HTML, Markdown, or Word.\ndocs:\n - route: https://pro.platejs.org/docs/examples/export\n title: Plus\n---\n\nThe registry `export-toolbar-button` downloads the current document as HTML,\nMarkdown, or Word from the browser. The demo mounts it in the fixed toolbar.\n\n## Demo\n\n\n\n## Toolbar source\n\nInstall the [Export Toolbar Button](/docs/plate/components/export-toolbar-button)\ncomponent to add the menu to an editor toolbar.\n\n\n\n## Export formats\n\n| Format | Implementation | Output |\n| -------- | -------------- | ------ |\n| HTML | `renderStaticHtml(model, { component: EditorStatic, presentation: BaseEditorKit, projection })` from `platejs/static` | `plate.html` |\n| Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` |\n| Word | `exportDocx(model, { component: EditorStatic, presentation: BaseEditorKit, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` |\n\nEvery format captures the same complete document and explicit authored\nprojection from `useModelEditor()`. Export reads model content rather than the\nDOM, so selection, placeholders, and review decorations do not enter the file.\nThe current editor supplies its compiled schema, format mappings, and plugin\nsettings; the static kit supplies only the drawings.\n\nThe editor's own components are editing UI, so HTML and Word pass the app's\nstatic kit as `presentation`. Export draws each node with the static component\nof the same plugin name. When a plugin has no static component, export draws its\ncontent without one and adds a `missing-static-presentation` warning.\n\nThe HTML file inlines the app's own stylesheets and copies the page's `body`\nclasses. Cross-origin stylesheets stay as links.\n\n## Suggestions\n\nWhen the document has unresolved [suggestions](/docs/plate/suggestion), the menu shows\nan **Unresolved suggestions** choice:\n\n| Choice | Projection | Result |\n| ------------------------- | ---------- | ------------------------------------------- |\n| Include suggested changes | `proposed` | The document with pending suggestions applied |\n| Exclude suggested changes | `accepted` | The document without pending suggestions |\n\nThe initial choice follows the mounted view. HTML, Markdown, and Word use the\nsame choice. **Export as Word with tracked changes** uses `review`, which writes\npending suggestions as Word revisions. A conflict has no faithful Word\nrevision, so review export returns an error until the conflict is resolved.\n\nWithout unresolved suggestions, Word export uses `review`. A retained,\nunchanged imported DOCX can then be returned byte for byte. Each exporter\nreturns diagnostics, which the menu presents as warning or error toasts.\n\n## DOCX presentation\n\nDOCX owns semantic mappings for Plate code blocks, columns, equations,\ncallouts, headings, and tables of contents. The registry keeps only the copied\nvisual stylesheet:\n\n\n\n## Plus export\n\nPlate Plus includes a server-side export flow for PDF output with page settings.\n\n\n\n## Related\n\n- [DOCX](/docs/plate/docx) covers DOCX paste, file import, and export APIs.\n- [Markdown](/docs/plate/markdown) covers Markdown serialization.\n- [Static Rendering](/docs/plate/static) covers static editor rendering and HTML serialization.\n- [Authored Changes](/docs/plate/authored-changes) covers visible HTML/Markdown projections, authored JSON, and DOCX review.\n", "type": "registry:file", "target": "content/docs/plate/examples/export.mdx" } diff --git a/apps/www/public/r/export-toolbar-button.json b/apps/www/public/r/export-toolbar-button.json index 91ad7cb50f..9260061486 100644 --- a/apps/www/public/r/export-toolbar-button.json +++ b/apps/www/public/r/export-toolbar-button.json @@ -22,17 +22,17 @@ ], "registryDependencies": [ "https://platejs.org/r/editor.json", - "https://platejs.org/r/editor-plugins-static.json", "https://platejs.org/r/docx-export.json", "https://platejs.org/r/docx-source.json", "https://platejs.org/r/editor-dropdown-menu.json", + "https://platejs.org/r/editor-plugins-static.json", "https://platejs.org/r/editor-static.json", "https://platejs.org/r/toolbar.json" ], "files": [ { "path": "src/registry/components/editor/export-toolbar-button.tsx", - "content": "'use client';\n\nimport { ArrowDownToLineIcon } from 'lucide-react';\nimport { createEditorView } from 'platejs';\nimport { AuthoredPlugin, isAuthoredEditor } from 'platejs/authored';\nimport { CommentsPlugin } from 'platejs/comments/react';\nimport { exportDocx, type DocxComment } from 'platejs/docx/export';\nimport { MarkdownPlugin } from 'platejs/markdown';\nimport { useEditor, useEditorSelector, useModelEditor } from 'platejs/react';\nimport { renderStaticHtml } from 'platejs/static';\nimport * as React from 'react';\nimport { toast } from 'sonner';\n\nimport { DOCX_EXPORT_STYLES } from '@/registry/components/editor/docx-export';\nimport { useDocxSource } from '@/registry/components/editor/docx-source';\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/registry/components/editor/dropdown-menu';\nimport { ToolbarButton } from '@/registry/components/editor/toolbar';\n\nimport { EditorStatic } from './editor-static';\n\ntype CleanProjection = 'accepted' | 'proposed';\n\nconst downloadBlob = (blob: Blob, filename: string) => {\n const url = URL.createObjectURL(blob);\n const link = document.createElement('a');\n\n link.href = url;\n link.download = filename;\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n};\n\nconst absoluteCssUrls = (css: string, base: string) =>\n css.replaceAll(\n /url\\((['\"]?)([^'\")]+)\\1\\)/g,\n (match, quote: string, url: string) =>\n url.startsWith('data:') || url.startsWith('#')\n ? match\n : `url(${quote}${new URL(url, base).href}${quote})`\n );\n\n// Inline the app's own CSS so the file renders with the same styles without\n// depending on a stylesheet URL that changes between deployments.\nconst readAppStyles = () =>\n Array.from(document.styleSheets, (sheet) => {\n try {\n const css = Array.from(sheet.cssRules, (rule) => rule.cssText).join('\\n');\n\n return ``;\n } catch {\n // Cross-origin rules are unreadable; keep the app's link instead.\n return sheet.href ? `` : '';\n }\n }).join('\\n');\n\nconst toastWarnings = (\n diagnostics: ReadonlyArray>\n) => {\n const warningCount = diagnostics.filter(\n ({ severity }) => severity === 'warning'\n ).length;\n\n if (warningCount > 0) {\n toast.warning(\n `Exported with ${warningCount} warning${warningCount === 1 ? '' : 's'}.`\n );\n }\n};\n\nexport function ExportToolbarButton() {\n const editor = useEditor();\n const model = useModelEditor();\n const commentsInstalled = editor.plugin(CommentsPlugin).installed;\n const docxSource = useDocxSource();\n const [open, setOpen] = React.useState(false);\n const [projectionChoice, setProjectionChoice] =\n React.useState();\n\n const authoredState = useEditorSelector((current) => {\n const authored = current.plugin(AuthoredPlugin);\n\n if (!authored.installed) return 'clean:proposed';\n\n const unresolved = (['pending', 'conflicted'] as const).some(\n (status) =>\n authored.read.changes({\n limit: 1,\n status,\n }).items.length > 0\n );\n\n return `${unresolved ? 'unresolved' : 'clean'}:${authored.read.view().projection}`;\n });\n const [authoredStatus, mountedProjection] = authoredState.split(':');\n const unresolved = authoredStatus === 'unresolved';\n const projection =\n projectionChoice ??\n (mountedProjection === 'accepted' ? 'accepted' : 'proposed');\n\n const exportToHtml = async () => {\n const result = await renderStaticHtml(model, {\n component: EditorStatic,\n projection,\n props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } },\n });\n\n const html = `\n\n \n \n \n ${readAppStyles()}\n \n \n ${result.data}\n \n`;\n\n downloadBlob(new Blob([html], { type: 'text/html' }), 'plate.html');\n toastWarnings(result.diagnostics);\n };\n\n const exportToMarkdown = () => {\n const result = model.plugin(MarkdownPlugin).api.serialize({ projection });\n\n if (!result.ok) {\n toast.error(\n result.diagnostics.find(({ severity }) => severity === 'error')\n ?.message ?? 'Markdown export failed.'\n );\n\n return;\n }\n downloadBlob(\n new Blob([result.data], { type: 'text/markdown' }),\n 'plate.md'\n );\n toastWarnings(result.diagnostics);\n };\n\n const exportToWord = async (\n requestedProjection?: CleanProjection | 'review'\n ) => {\n let omittedCommentCount = 0;\n const docxComments: DocxComment[] = [];\n\n if (commentsInstalled) {\n const commentEditor = isAuthoredEditor(model)\n ? createEditorView(model, {\n authored: { intent: 'edit', projection: 'proposed' },\n })\n : model;\n const comments = commentEditor.plugin(CommentsPlugin);\n const users = comments.store.get('users');\n\n comments.api.getThreads().forEach((thread) => {\n if (thread.status !== 'published') return;\n const attachment = comments.api.attachment(thread.id);\n\n if (attachment?.type !== 'range' || attachment.status !== 'attached') {\n omittedCommentCount += 1;\n\n return;\n }\n const parentId = thread.messages[0]\n ? `${thread.id}:${thread.messages[0].id}`\n : null;\n\n thread.messages.forEach((message, index) => {\n const user = users[message.userId];\n const name = user?.name ?? 'Unknown';\n const initials = name\n .split(/\\s+/)\n .filter(Boolean)\n .map((part) => part[0])\n .join('')\n .slice(0, 4);\n\n docxComments.push({\n author: { ...(initials ? { initials } : {}), name },\n body: message.body,\n createdAt: message.createdAt,\n durableId: null,\n id: `${thread.id}:${message.id}`,\n parentId: index === 0 ? null : parentId,\n resolved: index === 0 ? thread.resolution !== null : null,\n target: { range: attachment.range },\n });\n });\n });\n }\n const wordProjection =\n requestedProjection ?? (unresolved ? projection : ('review' as const));\n const result = await exportDocx(model, {\n comments: docxComments,\n component: EditorStatic,\n // The download still completes; the toasts below report what was lost.\n lossPolicy: 'allow',\n projection: wordProjection,\n source: docxSource?.source,\n stylesheet: DOCX_EXPORT_STYLES,\n });\n\n if (!result.ok) {\n toast.error(\n result.diagnostics.find(({ severity }) => severity === 'error')\n ?.message ?? 'The Word document could not be exported.'\n );\n\n return;\n }\n\n downloadBlob(result.blob, 'plate.docx');\n toastWarnings(result.diagnostics);\n if (omittedCommentCount > 0) {\n toast.warning(\n `${omittedCommentCount} comment thread${omittedCommentCount === 1 ? '' : 's'} could not be attached to the Word document.`\n );\n }\n };\n\n return (\n {\n if (nextOpen) setProjectionChoice(undefined);\n setOpen(nextOpen);\n }}\n modal={false}\n >\n \n \n \n \n \n\n \n {unresolved && (\n <>\n Unresolved suggestions\n \n setProjectionChoice(\n value === 'accepted' ? 'accepted' : 'proposed'\n )\n }\n >\n event.preventDefault()}\n >\n Include suggested changes\n \n event.preventDefault()}\n >\n Exclude suggested changes\n \n \n \n \n )}\n \n \n Export as HTML\n \n \n Export as Markdown\n \n exportToWord()}>\n Export as Word\n \n {unresolved && (\n exportToWord('review')}>\n Export as Word with tracked changes\n \n )}\n \n \n \n );\n}\n", + "content": "'use client';\n\nimport { ArrowDownToLineIcon } from 'lucide-react';\nimport { createEditorView } from 'platejs';\nimport { AuthoredPlugin, isAuthoredEditor } from 'platejs/authored';\nimport { CommentsPlugin } from 'platejs/comments/react';\nimport { exportDocx, type DocxComment } from 'platejs/docx/export';\nimport { MarkdownPlugin } from 'platejs/markdown';\nimport { useEditor, useEditorSelector, useModelEditor } from 'platejs/react';\nimport { renderStaticHtml } from 'platejs/static';\nimport * as React from 'react';\nimport { toast } from 'sonner';\n\nimport { DOCX_EXPORT_STYLES } from '@/registry/components/editor/docx-export';\nimport { useDocxSource } from '@/registry/components/editor/docx-source';\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/registry/components/editor/dropdown-menu';\nimport { BaseEditorKit } from '@/registry/components/editor/plugins-static';\nimport { ToolbarButton } from '@/registry/components/editor/toolbar';\n\nimport { EditorStatic } from './editor-static';\n\ntype CleanProjection = 'accepted' | 'proposed';\n\nconst downloadBlob = (blob: Blob, filename: string) => {\n const url = URL.createObjectURL(blob);\n const link = document.createElement('a');\n\n link.href = url;\n link.download = filename;\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n};\n\nconst absoluteCssUrls = (css: string, base: string) =>\n css.replaceAll(\n /url\\((['\"]?)([^'\")]+)\\1\\)/g,\n (match, quote: string, url: string) =>\n url.startsWith('data:') || url.startsWith('#')\n ? match\n : `url(${quote}${new URL(url, base).href}${quote})`\n );\n\n// Inline the app's own CSS so the file renders with the same styles without\n// depending on a stylesheet URL that changes between deployments.\nconst readAppStyles = () =>\n Array.from(document.styleSheets, (sheet) => {\n try {\n const css = Array.from(sheet.cssRules, (rule) => rule.cssText).join('\\n');\n\n return ``;\n } catch {\n // Cross-origin rules are unreadable; keep the app's link instead.\n return sheet.href ? `` : '';\n }\n }).join('\\n');\n\nconst toastWarnings = (\n diagnostics: ReadonlyArray>\n) => {\n const warningCount = diagnostics.filter(\n ({ severity }) => severity === 'warning'\n ).length;\n\n if (warningCount > 0) {\n toast.warning(\n `Exported with ${warningCount} warning${warningCount === 1 ? '' : 's'}.`\n );\n }\n};\n\nexport function ExportToolbarButton() {\n const editor = useEditor();\n const model = useModelEditor();\n const commentsInstalled = editor.plugin(CommentsPlugin).installed;\n const docxSource = useDocxSource();\n const [open, setOpen] = React.useState(false);\n const [projectionChoice, setProjectionChoice] =\n React.useState();\n\n const authoredState = useEditorSelector((current) => {\n const authored = current.plugin(AuthoredPlugin);\n\n if (!authored.installed) return 'clean:proposed';\n\n const unresolved = (['pending', 'conflicted'] as const).some(\n (status) =>\n authored.read.changes({\n limit: 1,\n status,\n }).items.length > 0\n );\n\n return `${unresolved ? 'unresolved' : 'clean'}:${authored.read.view().projection}`;\n });\n const [authoredStatus, mountedProjection] = authoredState.split(':');\n const unresolved = authoredStatus === 'unresolved';\n const projection =\n projectionChoice ??\n (mountedProjection === 'accepted' ? 'accepted' : 'proposed');\n\n const exportToHtml = async () => {\n try {\n const result = await renderStaticHtml(model, {\n component: EditorStatic,\n presentation: BaseEditorKit,\n projection,\n props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } },\n });\n\n const html = `\n\n \n \n \n ${readAppStyles()}\n \n \n ${result.data}\n \n`;\n\n downloadBlob(new Blob([html], { type: 'text/html' }), 'plate.html');\n toastWarnings(result.diagnostics);\n } catch (error) {\n console.error('Could not export HTML.', error);\n toast.error('The HTML file could not be exported.');\n }\n };\n\n const exportToMarkdown = () => {\n const result = model.plugin(MarkdownPlugin).api.serialize({ projection });\n\n if (!result.ok) {\n toast.error(\n result.diagnostics.find(({ severity }) => severity === 'error')\n ?.message ?? 'Markdown export failed.'\n );\n\n return;\n }\n downloadBlob(\n new Blob([result.data], { type: 'text/markdown' }),\n 'plate.md'\n );\n toastWarnings(result.diagnostics);\n };\n\n const exportToWord = async (\n requestedProjection?: CleanProjection | 'review'\n ) => {\n try {\n let omittedCommentCount = 0;\n const docxComments: DocxComment[] = [];\n\n if (commentsInstalled) {\n const commentEditor = isAuthoredEditor(model)\n ? createEditorView(model, {\n authored: { intent: 'edit', projection: 'proposed' },\n })\n : model;\n const comments = commentEditor.plugin(CommentsPlugin);\n const users = comments.store.get('users');\n\n comments.api.getThreads().forEach((thread) => {\n if (thread.status !== 'published') return;\n const attachment = comments.api.attachment(thread.id);\n\n if (\n attachment?.type !== 'range' ||\n attachment.status !== 'attached'\n ) {\n omittedCommentCount += 1;\n\n return;\n }\n const parentId = thread.messages[0]\n ? `${thread.id}:${thread.messages[0].id}`\n : null;\n\n thread.messages.forEach((message, index) => {\n const user = users[message.userId];\n const name = user?.name ?? 'Unknown';\n const initials = name\n .split(/\\s+/)\n .filter(Boolean)\n .map((part) => part[0])\n .join('')\n .slice(0, 4);\n\n docxComments.push({\n author: { ...(initials ? { initials } : {}), name },\n body: message.body,\n createdAt: message.createdAt,\n durableId: null,\n id: `${thread.id}:${message.id}`,\n parentId: index === 0 ? null : parentId,\n resolved: index === 0 ? thread.resolution !== null : null,\n target: { range: attachment.range },\n });\n });\n });\n }\n const wordProjection =\n requestedProjection ?? (unresolved ? projection : ('review' as const));\n const result = await exportDocx(model, {\n comments: docxComments,\n component: EditorStatic,\n // The download still completes; the toasts below report what was lost.\n lossPolicy: 'allow',\n presentation: BaseEditorKit,\n projection: wordProjection,\n source: docxSource?.source,\n stylesheet: DOCX_EXPORT_STYLES,\n });\n\n if (!result.ok) {\n toast.error(\n result.diagnostics.find(({ severity }) => severity === 'error')\n ?.message ?? 'The Word document could not be exported.'\n );\n\n return;\n }\n\n downloadBlob(result.blob, 'plate.docx');\n toastWarnings(result.diagnostics);\n if (omittedCommentCount > 0) {\n toast.warning(\n `${omittedCommentCount} comment thread${omittedCommentCount === 1 ? '' : 's'} could not be attached to the Word document.`\n );\n }\n } catch (error) {\n console.error('Could not export Word.', error);\n toast.error('The Word document could not be exported.');\n }\n };\n\n return (\n {\n if (nextOpen) setProjectionChoice(undefined);\n setOpen(nextOpen);\n }}\n modal={false}\n >\n \n \n \n \n \n\n \n {unresolved && (\n <>\n Unresolved suggestions\n \n setProjectionChoice(\n value === 'accepted' ? 'accepted' : 'proposed'\n )\n }\n >\n event.preventDefault()}\n >\n Include suggested changes\n \n event.preventDefault()}\n >\n Exclude suggested changes\n \n \n \n \n )}\n \n \n Export as HTML\n \n \n Export as Markdown\n \n exportToWord()}>\n Export as Word\n \n {unresolved && (\n exportToWord('review')}>\n Export as Word with tracked changes\n \n )}\n \n \n \n );\n}\n", "type": "registry:component", "target": "@components/editor/export-toolbar-button.tsx" } diff --git a/apps/www/public/r/indent-static.json b/apps/www/public/r/indent-static.json index 5f4dbf22c5..727516fe0e 100644 --- a/apps/www/public/r/indent-static.json +++ b/apps/www/public/r/indent-static.json @@ -8,7 +8,7 @@ "files": [ { "path": "src/registry/components/editor/indent-static.tsx", - "content": "import { BaseIndentPlugin, PLUGINS } from 'platejs';\n\nexport const BaseIndentKit = [\n BaseIndentPlugin.configure({\n initialState: {\n offset: 24,\n },\n targetPlugins: [\n PLUGINS.heading,\n PLUGINS.paragraph,\n PLUGINS.blockquote,\n PLUGINS.codeBlock,\n PLUGINS.details,\n ],\n }),\n];\n", + "content": "import { BaseIndentPlugin, PLUGINS } from 'platejs';\n\nexport const BaseIndentKit = [\n BaseIndentPlugin.configure({\n initialState: {\n offset: 24,\n },\n targetPlugins: [\n PLUGINS.heading,\n PLUGINS.paragraph,\n PLUGINS.blockquote,\n PLUGINS.codeBlock,\n PLUGINS.details,\n PLUGINS.image,\n ],\n }),\n];\n", "type": "registry:component", "target": "@components/editor/indent-static.tsx" } diff --git a/apps/www/public/r/list-static.json b/apps/www/public/r/list-static.json index 67171bb400..69f8b87dcc 100644 --- a/apps/www/public/r/list-static.json +++ b/apps/www/public/r/list-static.json @@ -12,7 +12,7 @@ "files": [ { "path": "src/registry/components/editor/list-static.tsx", - "content": "import { BaseListPlugin, isOrderedList, ListType, PLUGINS } from 'platejs';\n\nimport { BlockListStatic } from '@/registry/components/editor/block-list-static';\nimport { BaseIndentKit } from '@/registry/components/editor/indent-static';\n\nexport const BaseListKit = [\n ...BaseIndentKit,\n BaseListPlugin.configure({\n inject: {\n isElement: true,\n nodeProps: {\n nodeKey: 'listType',\n query: ({ nodeProps }) => {\n const { element } = nodeProps;\n\n return (\n element?.listType === ListType.Bulleted && !isOrderedList(element)\n );\n },\n transformProps: ({ props }) => ({\n ...props,\n role: 'listitem',\n style: {\n ...props.style,\n display: 'list-item',\n },\n }),\n },\n },\n slots: {\n wrapNodeChildren: BlockListStatic,\n },\n targetPlugins: [\n PLUGINS.paragraph,\n PLUGINS.heading,\n PLUGINS.blockquote,\n PLUGINS.codeBlock,\n PLUGINS.details,\n ],\n }),\n];\n", + "content": "import { BaseListPlugin, isOrderedList, ListType, PLUGINS } from 'platejs';\n\nimport { BlockListStatic } from '@/registry/components/editor/block-list-static';\nimport { BaseIndentKit } from '@/registry/components/editor/indent-static';\n\nexport const BaseListKit = [\n ...BaseIndentKit,\n BaseListPlugin.configure({\n inject: {\n isElement: true,\n nodeProps: {\n nodeKey: 'listType',\n query: ({ nodeProps }) => {\n const { element } = nodeProps;\n\n return (\n element?.listType === ListType.Bulleted && !isOrderedList(element)\n );\n },\n transformProps: ({ props }) => ({\n ...props,\n role: 'listitem',\n style: {\n ...props.style,\n display: 'list-item',\n },\n }),\n },\n },\n slots: {\n wrapNodeChildren: BlockListStatic,\n },\n targetPlugins: [\n PLUGINS.paragraph,\n PLUGINS.heading,\n PLUGINS.blockquote,\n PLUGINS.codeBlock,\n PLUGINS.details,\n PLUGINS.image,\n ],\n }),\n];\n", "type": "registry:component", "target": "@components/editor/list-static.tsx" } diff --git a/apps/www/public/r/registry-docs.json b/apps/www/public/r/registry-docs.json index 6d4d3cbab6..79883f0a08 100644 --- a/apps/www/public/r/registry-docs.json +++ b/apps/www/public/r/registry-docs.json @@ -218,7 +218,7 @@ "files": [ { "path": "../../content/docs/(guides)/authored-changes.mdx", - "content": "---\ntitle: Authored Changes\ndescription: Capture attributed edits, review proposals, retain author history, and export explicit projections.\ndocs:\n - route: /docs/suggestion\n title: Suggestions\n - route: /docs/comment\n title: Comments\n - route: /docs/yjs\n title: Collaboration\n---\n\nAuthored changes keep accepted content, proposals, decisions, and stable change identities in one saved document. Use [Suggestions](/docs/plate/suggestion) for the common editing and suggesting modes. This guide covers the underlying records, advanced views, decisions, retained history, and formats.\n\n## Choose the right surface\n\n| Need | Surface |\n| ------------------------------------------------- | ----------------------------------------------------------------------- |\n| Switch between editing and suggesting | `SuggestionPlugin` from `platejs/suggestion/react` |\n| Add copied highlighting and review cards | [SuggestionKit](/docs/plate/suggestion) and [DiscussionKit](/docs/plate/discussion) |\n| Record changes as the editor's user | `AuthoredPlugin` from `platejs/authored` |\n| Retain accepted history for revert | `AuthoredPlugin.configure({ initialState: { retainHistory: true } })` |\n| Read, decide, resolve, or revert changes | The installed authored plugin portal |\n| Attach a conversation | `CommentsPlugin` with a `{ type: 'change', id }` target |\n\nSuggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.\n\n## Install authored changes\n\n`AuthoredPlugin` resolves the author from the Plate editor's `userId` once per transaction:\n\n```tsx title=\"editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst editor = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: [\n { type: \"paragraph\", children: [{ text: \"Review this sentence.\" }] },\n ],\n userId: \"alice\",\n});\n\nconst authored = editor.plugin(AuthoredPlugin);\n```\n\nAn editor without a `userId` writes as the local user, `'local'`, so a solo\neditor needs no setup, AI suggestions included. Pass an authenticated, stable\nuser ID whenever more than one person writes the document.\n\nTo retain accepted history, configure the same descriptor. The editor reads\n`retainHistory` once, when it is created:\n\n```tsx title=\"history-editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst historyEditor = createEditor({\n plugins: [\n BaseParagraphPlugin,\n AuthoredPlugin.configure({ initialState: { retainHistory: true } }),\n ],\n initialValue,\n userId: session.user.id,\n});\n\nconst retainedAuthored = historyEditor.plugin(AuthoredPlugin);\n```\n\n`retainHistory` keeps closed edit bodies needed for selective revert. Omit it when the application only needs pending review and attribution.\n\n## Capture changes\n\nAn editing view records ordinary updates as accepted contributions. Begin an explicit proposal inside the transaction before applying its document mutation:\n\n```tsx\nlet changeId = \"\";\n\neditor.update((tx) => {\n changeId = tx.authored.propose();\n tx.text.insert(\" reviewed\", {\n at: { offset: 8, path: [0, 0] },\n });\n});\n```\n\nPass `{ changeId }` to assign an identity to a new proposal or amend a pending contribution owned by the current author. The current author must own an existing change's latest head.\n\n## Import a compared revision\n\nCompare a fixed draft with the accepted document, then publish its canonical change as one native proposal:\n\n```tsx\nimport { proposeAuthoredComparison } from \"platejs/authored\";\nimport { compare } from \"platejs/diff\";\n\nconst incomingDraft = [\n { type: \"paragraph\", children: [{ text: \"Revised sentence.\" }] },\n];\nconst comparison = await compare({\n before: editor.read.value(),\n after: incomingDraft,\n schema: editor.read.schema,\n});\nconst imported = proposeAuthoredComparison(editor, { comparison });\n```\n\n`groupIds` selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle `stale`, `blocked`, `invalid`, and `unavailable` without treating them as applied proposals. A resolved three-way comparison can use the same call when its selected baseline is the live accepted document.\n\n## Configure a view\n\nSet the starting intent and projection on `EditorRoot`:\n\n```tsx\nimport { AuthoredPlugin } from \"platejs/authored\";\nimport { EditorContent, EditorRoot, useCreateEditor } from \"platejs/react\";\n\nexport function SuggestionEditor() {\n const editor = useCreateEditor({\n plugins: [AuthoredPlugin],\n userId: \"alice\",\n });\n\n return (\n \n \n \n );\n}\n```\n\nThe editor must install an authored plugin, either directly or through `SuggestionPlugin`. Changing the `intent` or `projection` prop updates this view. Rerendering with equivalent values preserves subsequent mode changes made through the mounted view's portal.\n\nFor an interactive mode control, let `SuggestionPlugin` change the intent while preserving the current projection:\n\n```tsx\nimport { SuggestionPlugin } from \"platejs/suggestion/react\";\n\neditor.plugin(SuggestionPlugin).api.setMode(\"suggesting\");\n```\n\nUse the authored portal for advanced projections:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\n\nauthored.api.setView({\n intent: \"propose\",\n projection: \"proposed\",\n});\n```\n\n| Projection | Visible content |\n| ---------- | ----------------------------------------------------------- |\n| `accepted` | Accepted content without pending proposals |\n| `proposed` | The proposed result without retained review fragments |\n| `markup` | Proposed content with retained deletions and move fragments |\n\nUse `intent: 'edit'` with any projection. An editing markup or proposed view keeps pending content visible while edits to independent accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes directly in the same undoable transaction. Partial retained or dependency-spanning targets stay protected; other dependent edits remain proposals with the current author. Use `intent: 'propose'` with `proposed` or `markup` when ordinary input must create proposals.\n\nIntent and projection belong to the exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.\n\n## Review changes\n\nRead a page of pending changes, then capture a revision-bound selection for the decision:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\nconst pending = authored.read.changes({\n authorId: \"alice\",\n limit: 50,\n status: \"pending\",\n});\nconst selection = authored.read.select({\n ids: pending.items.map(({ id }) => id),\n});\nconst result = authored.update.decide({\n action: \"accept\",\n selection,\n});\n```\n\nUse `action: 'reject'` to reject changes. The example selects only the returned page. Pass a non-null page `cursor` to `changes(...)` to read more, or call `select({ authorId: 'alice', status: 'pending' })` to select every current match.\n\n`AuthoredSelection` captures the document ID, change revisions, and current heads. A later amendment makes an old selection `stale`. Dependencies and concurrent conflicts return `blocked` without partially applying the batch.\n\nFor spatial controls, use `change(id)` to read one record and `changesAt(range)` to find records intersecting a text range. Each `AuthoredChange` includes its author, status, kind, dependencies, revision, and current ranges.\n\nRead semantic detail when the reviewer opens one change:\n\n```tsx\nconst details = authored.read.details(changeId);\n\nif (details?.parts.status === \"available\") {\n for (const part of details.parts.items) {\n // Render content, boundary, property, or root facts in review UI.\n }\n}\n```\n\n`details(id)` returns the compact change summary, decision history, and immutable semantic parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Property parts preserve exact before and after values. Root parts describe root creation or deletion.\n\nClosed changes without retained edit bodies return `parts.status: 'unavailable'` with `reason: 'retention'`; their summary and reviews remain readable. Keep list and spatial reads compact, and request details only for the change being reviewed.\n\n### Decision results\n\nAlways inspect the result of `decide`, `resolve`, or `revert`:\n\n| Status | Meaning |\n| ------------- | ------------------------------------------------------------------------------------------------ |\n| `applied` | The operation applied to the returned IDs. |\n| `unchanged` | No change was needed. |\n| `stale` | The selected revisions or heads changed. Read the records again before retrying. |\n| `blocked` | The selection has unresolved dependencies, dependants, or conflicts. Inspect the returned lists. |\n| `invalid` | The selection or document identity is invalid. |\n| `unavailable` | A revert needs content that is no longer retained. |\n\n`authored.read.preview({ action, selection })` checks an accept or reject decision without applying it. An `applied` preview means the decision can apply at that moment; the later `decide` call still needs result handling.\n\nAccepting a change can require its dependencies. Rejecting one can require its dependants. The editor never expands the selection silently. Call `authored.update.resolve({ action, selection })` only after the reviewer chooses a decision for the complete conflict component.\n\n## Retain author history\n\nEnable `retainHistory` before recording work whose content must remain available for selective revert. Loading a compacted document with retention enabled cannot recover discarded edit bodies.\n\nSelect an author's accepted contributions and create a compensating change:\n\n```tsx\nconst selection = retainedAuthored.read.select({\n authorId: \"alice\",\n status: \"accepted\",\n});\nconst result = retainedAuthored.update.revert({ selection });\n```\n\nThe original records keep their authors. The compensation belongs to the current author. A revert can return `blocked` when later dependent work prevents compensation, or `unavailable` when the required content is absent.\n\nPlate History reverses local interaction batches with `editor.api.history.undo()` and `redo()`. Local undo also restores mapped selection. Selective revert records another authored contribution. The [Version History example](/docs/plate/examples/version-history) lets you edit as two authors and revert one retained contribution.\n\n## Save the document\n\nSave the complete result of `editor.read.value()`. It contains accepted content and the `meta.authored` records needed to reconstruct proposals and decisions. Saving only the node array loses those records.\n\n```tsx\nconst savedDocument = editor.read.value();\n\nconst restored = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: savedDocument,\n userId: \"alice\",\n});\n```\n\nUse the same content plugins and schema when loading a revision into a fresh editor. Loading validates the authored records and document schema before publishing the value. Treat `meta.authored` as codec-owned data. Local undo starts empty.\n\nIf you install [Comments](/docs/plate/comment), save `editor.plugin(CommentsPlugin).api.toJSON()` atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved `CommentsJSON` to `CommentsPlugin.configure({ initialState: { initialComments } })` in the fresh editor. Live thread targets contain semantic identities; `attachment(id)` reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.\n\nHistorical previews and restores retain current conversations and use only the selected revision's proven targets. See [Comments and versions](/docs/plate/comment#document-history-and-versions).\n\n## Export explicit projections\n\nChoose `accepted` or `proposed` for a clean document, or `review` when the\nformat can preserve review records. Clean projections report diagnostics when\nthey omit pending work or resolve a conflict to one side. Every helper accepts\nor captures a complete document, including named roots and metadata.\n\n### JSON\n\nUse the pure authored projection helpers for detached documents:\n\n```tsx\nimport {\n parseAuthoredDocument,\n projectAuthoredDocument,\n projectAuthoredReview,\n} from \"platejs/authored\";\n\nconst source = editor.read.value();\nconst accepted = projectAuthoredDocument(source, {\n projection: \"accepted\",\n});\nconst review = projectAuthoredReview(source);\nconst json = JSON.stringify(review.review);\nconst document = parseAuthoredDocument(json);\n```\n\n`parseAuthoredDocument` validates the document envelope. Loading it into an\neditor applies the installed application schema and authored-record checks.\n\n### Plain text and Markdown\n\n```tsx\nimport { serializePlainText } from \"platejs\";\n\nconst text = serializePlainText(editor, { projection: \"accepted\" });\nconst markdown = editor.api.markdown.serialize({ projection: \"proposed\" });\n\nif (markdown.ok) downloadMarkdown(markdown.data);\n```\n\nPlain text and Markdown carry visible content only. Feature-owned format\nmappings preserve the structures they can represent and report omitted roots,\nmetadata, pending work, and unsupported nodes. Markdown does not contain a\nhidden Plate document and cannot restore exact review state.\n\n### HTML and DOCX\n\n```tsx\nimport { exportDocx } from \"platejs/docx/export\";\nimport { renderStaticHtml } from \"platejs/static\";\n\nconst semanticHtml = editor.api.html.serialize({ projection: \"proposed\" });\nconst styledHtml = await renderStaticHtml(editor, { projection: \"proposed\" });\nconst docx = await exportDocx(editor, { projection: \"review\" });\n```\n\n`renderStaticHtml` uses the current editor's static components. Semantic HTML\nuses feature mappings and no application styling. Both HTML forms carry visible\naccepted or proposed content only. A successful DOCX result returns a `Blob` in\n`docx.blob`. Every serializer returns structured `diagnostics`.\n\n| Format | Accepted or proposed | Review |\n| ------ | -------------------- | ------ |\n| JSON | Plain projected document | Canonical authored document |\n| Plain text | Structural text | Not supported |\n| Markdown | Plain Markdown | Not supported; save authored JSON |\n| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |\n| Static HTML | Component-rendered HTML | Not supported; save authored JSON |\n| DOCX | Projected Word document | Word revisions; save authored JSON for exact state |\n\nDOCX review export reports `lossy-content` or `unsupported-content` when Word\ncannot express a native construct. Save authored JSON beside the file to keep\nthat construct exactly.\n\n## Collaboration\n\nInstall the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.\n\nThe Yjs adapter applies authored operations and decisions in one remote transaction. Its checkpoints preserve pending work and each peer's retained content. Retention policy is local: an archival peer can retain closed bodies while another peer compacts them.\n\nSee [Collaboration](/docs/plate/yjs) for setup and compaction ownership.\n\n## API Reference\n\n### `AuthoredPlugin`\n\nImport the standard descriptor from `platejs/authored`. It records each change as the Plate editor's `userId`, or as the local user, `'local'`, when the editor has none. `SuggestionPlugin` and `AIChatPlugin` depend on this descriptor.\n\n| Option | Type | Purpose |\n| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |\n| `initialState.retainHistory` | `boolean` | Keep closed edit bodies for selective revert. The editor reads it once, when it is created. Defaults to `false`. |\n\n### Transaction, reads, and updates\n\nAfter `const authored = editor.plugin(AuthoredPlugin)`, use these methods:\n\n| Surface | Purpose |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `tx.authored.propose(options?)` | Start a proposal, assign a new `{ changeId }`, or amend an owned pending change. |\n| `authored.read.canPropose()` | Report whether the current identity can author proposals; always true with `AuthoredPlugin`. |\n| `authored.read.view()` | Read this view's intent and projection. |\n| `authored.read.change(id)` | Read one logical change, or `null`. |\n| `authored.read.details(id)` | Read one change with semantic parts and decision history, or `null`. |\n| `authored.read.changes(query?)` | Read a page filtered by author, status, or time. |\n| `authored.read.changesAt(range)` | Read changes intersecting a text range. |\n| `authored.read.select(query)` | Capture a revision-bound selection for a decision. |\n| `authored.read.preview(decision)` | Check an accept or reject decision without applying it. |\n| `authored.update.decide(decision)` | Accept or reject the selected changes atomically. |\n| `authored.update.resolve(decision)` | Decide a complete conflict component explicitly. |\n| `authored.update.revert({ selection })` | Compensate retained accepted contributions. |\n| `authored.api.setView(view)` | Change the policy on this exact view. |\n| `authored.api.subscribeChanges(listener)` | Subscribe to immutable affected change IDs and decoration node keys for this view. |\n\n`subscribeChanges` is a low-level integration hook for semantic decorators. Suggestion applications use `useSuggestionChanges(path)` instead.\n\n### Types and format helpers\n\n`platejs/authored` exports the authored plugin descriptors, change and decision\ntypes, immutable projection results and diagnostics, and\n`createAuthoredReviewDocument` for trusted cumulative imported revisions.\n\n`projectAuthoredDocument(document, { projection })` returns one accepted or\nproposed `document`, the exact captured `review` document, diagnostics, and\nseparate `unresolved.pending` and `unresolved.conflicted` counts.\n`projectAuthoredReview(document)` also exposes stable visible markup segments\nand property counterparts. Both helpers are pure document operations: they do\nnot construct, activate, or subscribe an editor runtime.\n\n`projectAuthoredRange(projection, range)` maps proposed coordinates into the\naccepted document captured by the same projection. `parseAuthoredDocument`\nparses a JSON document envelope; the target editor remains responsible for its\napplication schema.\n", + "content": "---\ntitle: Authored Changes\ndescription: Capture attributed edits, review proposals, retain author history, and export explicit projections.\ndocs:\n - route: /docs/suggestion\n title: Suggestions\n - route: /docs/comment\n title: Comments\n - route: /docs/yjs\n title: Collaboration\n---\n\nAuthored changes keep accepted content, proposals, decisions, and stable change identities in one saved document. Use [Suggestions](/docs/plate/suggestion) for the common editing and suggesting modes. This guide covers the underlying records, advanced views, decisions, retained history, and formats.\n\n## Choose the right surface\n\n| Need | Surface |\n| ------------------------------------------------- | ----------------------------------------------------------------------- |\n| Switch between editing and suggesting | `SuggestionPlugin` from `platejs/suggestion/react` |\n| Add copied highlighting and review cards | [SuggestionKit](/docs/plate/suggestion) and [DiscussionKit](/docs/plate/discussion) |\n| Record changes as the editor's user | `AuthoredPlugin` from `platejs/authored` |\n| Retain accepted history for revert | `AuthoredPlugin.configure({ initialState: { retainHistory: true } })` |\n| Read, decide, resolve, or revert changes | The installed authored plugin portal |\n| Attach a conversation | `CommentsPlugin` with a `{ type: 'change', id }` target |\n\nSuggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.\n\n## Install authored changes\n\n`AuthoredPlugin` resolves the author from the Plate editor's `userId` once per transaction:\n\n```tsx title=\"editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst editor = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: [\n { type: \"paragraph\", children: [{ text: \"Review this sentence.\" }] },\n ],\n userId: \"alice\",\n});\n\nconst authored = editor.plugin(AuthoredPlugin);\n```\n\nAn editor without a `userId` writes as the local user, `'local'`, so a solo\neditor needs no setup, AI suggestions included. Pass an authenticated, stable\nuser ID whenever more than one person writes the document.\n\nTo retain accepted history, configure the same descriptor. The editor reads\n`retainHistory` once, when it is created:\n\n```tsx title=\"history-editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst historyEditor = createEditor({\n plugins: [\n BaseParagraphPlugin,\n AuthoredPlugin.configure({ initialState: { retainHistory: true } }),\n ],\n initialValue,\n userId: session.user.id,\n});\n\nconst retainedAuthored = historyEditor.plugin(AuthoredPlugin);\n```\n\n`retainHistory` keeps closed edit bodies needed for selective revert. Omit it when the application only needs pending review and attribution.\n\n## Capture changes\n\nAn editing view records ordinary updates as accepted contributions. Begin an explicit proposal inside the transaction before applying its document mutation:\n\n```tsx\nlet changeId = \"\";\n\neditor.update((tx) => {\n changeId = tx.authored.propose();\n tx.text.insert(\" reviewed\", {\n at: { offset: 8, path: [0, 0] },\n });\n});\n```\n\nPass `{ changeId }` to assign an identity to a new proposal or amend a pending contribution owned by the current author. The current author must own an existing change's latest head.\n\n## Import a compared revision\n\nCompare a fixed draft with the accepted document, then publish its canonical change as one native proposal:\n\n```tsx\nimport { proposeAuthoredComparison } from \"platejs/authored\";\nimport { compare } from \"platejs/diff\";\n\nconst incomingDraft = [\n { type: \"paragraph\", children: [{ text: \"Revised sentence.\" }] },\n];\nconst comparison = await compare({\n before: editor.read.value(),\n after: incomingDraft,\n schema: editor.read.schema,\n});\nconst imported = proposeAuthoredComparison(editor, { comparison });\n```\n\n`groupIds` selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle `stale`, `blocked`, `invalid`, and `unavailable` without treating them as applied proposals. A resolved three-way comparison can use the same call when its selected baseline is the live accepted document.\n\n## Configure a view\n\nSet the starting intent and projection on `EditorRoot`:\n\n```tsx\nimport { AuthoredPlugin } from \"platejs/authored\";\nimport { EditorContent, EditorRoot, useCreateEditor } from \"platejs/react\";\n\nexport function SuggestionEditor() {\n const editor = useCreateEditor({\n plugins: [AuthoredPlugin],\n userId: \"alice\",\n });\n\n return (\n \n \n \n );\n}\n```\n\nThe editor must install an authored plugin, either directly or through `SuggestionPlugin`. Changing the `intent` or `projection` prop updates this view. Rerendering with equivalent values preserves subsequent mode changes made through the mounted view's portal.\n\nFor an interactive mode control, let `SuggestionPlugin` change the intent while preserving the current projection:\n\n```tsx\nimport { SuggestionPlugin } from \"platejs/suggestion/react\";\n\neditor.plugin(SuggestionPlugin).api.setMode(\"suggesting\");\n```\n\nUse the authored portal for advanced projections:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\n\nauthored.api.setView({\n intent: \"propose\",\n projection: \"proposed\",\n});\n```\n\n| Projection | Visible content |\n| ---------- | ----------------------------------------------------------- |\n| `accepted` | Accepted content without pending proposals |\n| `proposed` | The proposed result without retained review fragments |\n| `markup` | Proposed content with retained deletions and move fragments |\n\nUse `intent: 'edit'` with any projection. An editing markup or proposed view keeps pending content visible while edits to independent accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes directly in the same undoable transaction. Partial retained or dependency-spanning targets stay protected; other dependent edits remain proposals with the current author. Use `intent: 'propose'` with `proposed` or `markup` when ordinary input must create proposals.\n\nIntent and projection belong to the exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.\n\n## Review changes\n\nRead a page of pending changes, then capture a revision-bound selection for the decision:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\nconst pending = authored.read.changes({\n authorId: \"alice\",\n limit: 50,\n status: \"pending\",\n});\nconst selection = authored.read.select({\n ids: pending.items.map(({ id }) => id),\n});\nconst result = authored.update.decide({\n action: \"accept\",\n selection,\n});\n```\n\nUse `action: 'reject'` to reject changes. The example selects only the returned page. Pass a non-null page `cursor` to `changes(...)` to read more, or call `select({ authorId: 'alice', status: 'pending' })` to select every current match.\n\n`AuthoredSelection` captures the document ID, change revisions, and current heads. A later amendment makes an old selection `stale`. Dependencies and concurrent conflicts return `blocked` without partially applying the batch.\n\nFor spatial controls, use `change(id)` to read one record and `changesAt(range)` to find records intersecting a text range. Each `AuthoredChange` includes its author, status, kind, dependencies, revision, and current ranges.\n\nRead semantic detail when the reviewer opens one change:\n\n```tsx\nconst details = authored.read.details(changeId);\n\nif (details?.parts.status === \"available\") {\n for (const part of details.parts.items) {\n // Render content, boundary, property, or root facts in review UI.\n }\n}\n```\n\n`details(id)` returns the compact change summary, decision history, and immutable semantic parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Property parts preserve exact before and after values. Root parts describe root creation or deletion.\n\nClosed changes without retained edit bodies return `parts.status: 'unavailable'` with `reason: 'retention'`; their summary and reviews remain readable. Keep list and spatial reads compact, and request details only for the change being reviewed.\n\n### Decision results\n\nAlways inspect the result of `decide`, `resolve`, or `revert`:\n\n| Status | Meaning |\n| ------------- | ------------------------------------------------------------------------------------------------ |\n| `applied` | The operation applied to the returned IDs. |\n| `unchanged` | No change was needed. |\n| `stale` | The selected revisions or heads changed. Read the records again before retrying. |\n| `blocked` | The selection has unresolved dependencies, dependants, or conflicts. Inspect the returned lists. |\n| `invalid` | The selection or document identity is invalid. |\n| `unavailable` | A revert needs content that is no longer retained. |\n\n`authored.read.preview({ action, selection })` checks an accept or reject decision without applying it. An `applied` preview means the decision can apply at that moment; the later `decide` call still needs result handling.\n\nAccepting a change can require its dependencies. Rejecting one can require its dependants. The editor never expands the selection silently. Call `authored.update.resolve({ action, selection })` only after the reviewer chooses a decision for the complete conflict component.\n\n## Retain author history\n\nEnable `retainHistory` before recording work whose content must remain available for selective revert. Loading a compacted document with retention enabled cannot recover discarded edit bodies.\n\nSelect an author's accepted contributions and create a compensating change:\n\n```tsx\nconst selection = retainedAuthored.read.select({\n authorId: \"alice\",\n status: \"accepted\",\n});\nconst result = retainedAuthored.update.revert({ selection });\n```\n\nThe original records keep their authors. The compensation belongs to the current author. A revert can return `blocked` when later dependent work prevents compensation, or `unavailable` when the required content is absent.\n\nPlate History reverses local interaction batches with `editor.api.history.undo()` and `redo()`. Local undo also restores mapped selection. Selective revert records another authored contribution. The [Version History example](/docs/plate/examples/version-history) lets you edit as two authors and revert one retained contribution.\n\n## Save the document\n\nSave the complete result of `editor.read.value()`. It contains accepted content and the `meta.authored` records needed to reconstruct proposals and decisions. Saving only the node array loses those records.\n\n```tsx\nconst savedDocument = editor.read.value();\n\nconst restored = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: savedDocument,\n userId: \"alice\",\n});\n```\n\nUse the same content plugins and schema when loading a revision into a fresh editor. Loading validates the authored records and document schema before publishing the value. Treat `meta.authored` as codec-owned data. Local undo starts empty.\n\nIf you install [Comments](/docs/plate/comment), save `editor.plugin(CommentsPlugin).api.toJSON()` atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved `CommentsJSON` to `CommentsPlugin.configure({ initialState: { initialComments } })` in the fresh editor. Live thread targets contain semantic identities; `attachment(id)` reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.\n\nHistorical previews and restores retain current conversations and use only the selected revision's proven targets. See [Comments and versions](/docs/plate/comment#document-history-and-versions).\n\n## Export explicit projections\n\nChoose `accepted` or `proposed` for a clean document, or `review` when the\nformat can preserve review records. Clean projections report diagnostics when\nthey omit pending work or resolve a conflict to one side. Every helper accepts\nor captures a complete document, including named roots and metadata.\n\n### JSON\n\nUse the pure authored projection helpers for detached documents:\n\n```tsx\nimport {\n parseAuthoredDocument,\n projectAuthoredDocument,\n projectAuthoredReview,\n} from \"platejs/authored\";\n\nconst source = editor.read.value();\nconst accepted = projectAuthoredDocument(source, {\n projection: \"accepted\",\n});\nconst review = projectAuthoredReview(source);\nconst json = JSON.stringify(review.review);\nconst document = parseAuthoredDocument(json);\n```\n\n`parseAuthoredDocument` validates the document envelope. Loading it into an\neditor applies the installed application schema and authored-record checks.\n\n### Plain text and Markdown\n\n```tsx\nimport { serializePlainText } from \"platejs\";\n\nconst text = serializePlainText(editor, { projection: \"accepted\" });\nconst markdown = editor.api.markdown.serialize({ projection: \"proposed\" });\n\nif (markdown.ok) downloadMarkdown(markdown.data);\n```\n\nPlain text and Markdown carry visible content only. Feature-owned format\nmappings preserve the structures they can represent and report omitted roots,\nmetadata, pending work, and unsupported nodes. Markdown does not contain a\nhidden Plate document and cannot restore exact review state.\n\n### HTML and DOCX\n\n```tsx\nimport { exportDocx } from \"platejs/docx/export\";\nimport { renderStaticHtml } from \"platejs/static\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst semanticHtml = editor.api.html.serialize({ projection: \"proposed\" });\nconst styledHtml = await renderStaticHtml(editor, {\n presentation: BaseEditorKit,\n projection: \"proposed\",\n});\nconst docx = await exportDocx(editor, {\n presentation: BaseEditorKit,\n projection: \"review\",\n});\n```\n\n`renderStaticHtml` and `exportDocx` draw with the static kit passed as\n`presentation`, because a live editor's components are editing UI. Semantic HTML\nuses feature mappings and no application styling. Both HTML forms carry visible\naccepted or proposed content only. A successful DOCX result returns a `Blob` in\n`docx.blob`. Every serializer returns structured `diagnostics`.\n\n| Format | Accepted or proposed | Review |\n| ------ | -------------------- | ------ |\n| JSON | Plain projected document | Canonical authored document |\n| Plain text | Structural text | Not supported |\n| Markdown | Plain Markdown | Not supported; save authored JSON |\n| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |\n| Static HTML | Component-rendered HTML | Not supported; save authored JSON |\n| DOCX | Projected Word document | Word revisions; save authored JSON for exact state |\n\nDOCX review export reports `lossy-content` or `unsupported-content` when Word\ncannot express a native construct. Save authored JSON beside the file to keep\nthat construct exactly.\n\n## Collaboration\n\nInstall the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.\n\nThe Yjs adapter applies authored operations and decisions in one remote transaction. Its checkpoints preserve pending work and each peer's retained content. Retention policy is local: an archival peer can retain closed bodies while another peer compacts them.\n\nSee [Collaboration](/docs/plate/yjs) for setup and compaction ownership.\n\n## API Reference\n\n### `AuthoredPlugin`\n\nImport the standard descriptor from `platejs/authored`. It records each change as the Plate editor's `userId`, or as the local user, `'local'`, when the editor has none. `SuggestionPlugin` and `AIChatPlugin` depend on this descriptor.\n\n| Option | Type | Purpose |\n| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |\n| `initialState.retainHistory` | `boolean` | Keep closed edit bodies for selective revert. The editor reads it once, when it is created. Defaults to `false`. |\n\n### Transaction, reads, and updates\n\nAfter `const authored = editor.plugin(AuthoredPlugin)`, use these methods:\n\n| Surface | Purpose |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `tx.authored.propose(options?)` | Start a proposal, assign a new `{ changeId }`, or amend an owned pending change. |\n| `authored.read.canPropose()` | Report whether the current identity can author proposals; always true with `AuthoredPlugin`. |\n| `authored.read.view()` | Read this view's intent and projection. |\n| `authored.read.change(id)` | Read one logical change, or `null`. |\n| `authored.read.details(id)` | Read one change with semantic parts and decision history, or `null`. |\n| `authored.read.changes(query?)` | Read a page filtered by author, status, or time. |\n| `authored.read.changesAt(range)` | Read changes intersecting a text range. |\n| `authored.read.select(query)` | Capture a revision-bound selection for a decision. |\n| `authored.read.preview(decision)` | Check an accept or reject decision without applying it. |\n| `authored.update.decide(decision)` | Accept or reject the selected changes atomically. |\n| `authored.update.resolve(decision)` | Decide a complete conflict component explicitly. |\n| `authored.update.revert({ selection })` | Compensate retained accepted contributions. |\n| `authored.api.setView(view)` | Change the policy on this exact view. |\n| `authored.api.subscribeChanges(listener)` | Subscribe to immutable affected change IDs and decoration node keys for this view. |\n\n`subscribeChanges` is a low-level integration hook for semantic decorators. Suggestion applications use `useSuggestionChanges(path)` instead.\n\n### Types and format helpers\n\n`platejs/authored` exports the authored plugin descriptors, change and decision\ntypes, immutable projection results and diagnostics, and\n`createAuthoredReviewDocument` for trusted cumulative imported revisions.\n\n`projectAuthoredDocument(document, { projection })` returns one accepted or\nproposed `document`, the exact captured `review` document, diagnostics, and\nseparate `unresolved.pending` and `unresolved.conflicted` counts.\n`projectAuthoredReview(document)` also exposes stable visible markup segments\nand property counterparts. Both helpers are pure document operations: they do\nnot construct, activate, or subscribe an editor runtime.\n\n`projectAuthoredRange(projection, range)` maps proposed coordinates into the\naccepted document captured by the same projection. `parseAuthoredDocument`\nparses a JSON document envelope; the target editor remains responsible for its\napplication schema.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/authored-changes.mdx" } @@ -624,7 +624,7 @@ "files": [ { "path": "../../content/docs/(guides)/serializing.mdx", - "content": "---\ntitle: Serializing\ndescription: Save complete Plate documents and convert them to text, HTML, Markdown, or DOCX.\n---\n\nSave Plate JSON when another Plate editor must recover the editable document.\nUse a format parser or serializer when content crosses an application boundary.\n\n## Choose a surface\n\n| Task | Surface |\n| --- | --- |\n| Persist the complete editor document | `editor.read.value()` |\n| Persist exact proposals and decisions | `projectAuthoredReview` and `parseAuthoredDocument` |\n| Convert semantic HTML | `platejs/html`; Node parsing uses `platejs/html/server` |\n| Convert Markdown | `platejs/markdown` |\n| Import or export Word | `platejs/docx/import` and `platejs/docx/export` |\n| Render component-styled HTML | `platejs/static` |\n| Exchange browser clipboard MIME payloads | `DataTransferFormat` through `platejs/dom` |\n\nThere is no universal document converter. Each format owns its syntax,\ndiagnostics, loss policy, limits, and retained source.\n\n## Save Plate JSON\n\n`editor.read.value()` includes the primary children, named roots, and persistent\nmetadata.\n\n```ts\nconst document = editor.read.value();\nconst schema = editor.read.schema.identity();\n\nawait saveDocument({ document, schema });\n```\n\nSaving only `children` drops roots and metadata. Persist an application schema\nidentity when stored documents require versioned migration. Validate migrated\ndata before publishing it to an editor.\n\nExact authored review state is canonical JSON-compatible model data:\n\n```ts\nimport { parseAuthoredDocument, projectAuthoredReview } from 'platejs/authored';\n\nconst snapshot = projectAuthoredReview(editor.read.value());\nconst json = JSON.stringify(snapshot.review);\nconst document = parseAuthoredDocument(json);\n```\n\nHTML, Markdown, and DOCX do not hide an authored document in their output.\n\n## Serialize plain text\n\n```ts\nimport { serializePlainText } from 'platejs';\n\nconst result = serializePlainText(editor, { projection: 'accepted' });\n```\n\nFeature plugins contribute structural `plainText` mappings through\n`formats`/`defineFormats`. Plain text is one-way output; it does not become a\nuniversal parser or persistence format.\n\n## Convert HTML\n\nUse direct functions for detached work:\n\n```ts\nimport { parseHtml, serializeHtml } from 'platejs/html';\n\nconst parsed = parseHtml(source, { plugins: EditorKit });\nconst output = serializeHtml(document, {\n plugins: EditorKit,\n projection: 'accepted',\n});\n```\n\nIn Node.js, import `parseHtml` from `platejs/html/server`. Install `linkedom`\nfor that server-only DOM adapter.\n\nInstall `HtmlPlugin` when a configured editor should expose\n`editor.api.html.parse`, `parseSlice`, and `serialize`. See [HTML](/docs/plate/html).\n\n## Convert Markdown\n\n```ts\nimport { parseMarkdown, serializeMarkdown } from 'platejs/markdown';\n\nconst parsed = parseMarkdown(source, { plugins: EditorKit });\nconst output = serializeMarkdown(document, {\n plugins: EditorKit,\n projection: 'proposed',\n});\n```\n\nInstall `MarkdownPlugin` for `editor.api.markdown.parse`, `parseSlice`,\n`parseInline`, and `serialize`. See [Markdown](/docs/plate/markdown).\n\n## Convert DOCX\n\nDOCX import is detached. Supply the complete plugin target before the operation\nstarts, then publish a successful document explicitly.\n\n```ts\nimport { importDocx } from 'platejs/docx/import';\n\nconst result = await importDocx(file, { plugins: EditorKit });\n\nif (result.ok) {\n editor.update((tx) => tx.value.replace(result.document));\n}\n```\n\nDOCX export captures a configured editor because static rendering, comments,\nretained source, and Word revisions are part of its package workflow.\n\n```ts\nimport { exportDocx } from 'platejs/docx/export';\n\nconst result = await exportDocx(editor, { projection: 'review' });\n```\n\nA Word file contains the visible Word document, its comments, and its tracked\nrevisions. Save Plate JSON beside it when another Plate editor must recover\nnamed roots, metadata, or exact authored change records.\n\nSee [DOCX](/docs/plate/docx) for cancellation, package limits, comments, and retained\nsource.\n\n## Handle results\n\nHTML, Markdown, and DOCX use format-specific diagnosed results. Success carries\nwarnings and the requested document, slice, string, or Blob. Failure carries a\nnonempty error-first diagnostic tuple and no publishable carrier.\n\n```ts\nconst result = editor.api.markdown.serialize({ projection: 'accepted' });\n\nshowDiagnostics(result.diagnostics);\nif (!result.ok) return;\n\ndownloadMarkdown(result.data);\n```\n\nExpected source failure and representational loss return diagnostics.\nConfiguration errors, mapping bugs, and invalid serializer model data throw.\n`lossPolicy` defaults to `'reject'`; opt into `'allow'` only when the caller\naccepts the exact reported loss.\n\n## Define feature mappings\n\nFeature plugins declare semantic mappings through `formats` and\n`defineFormats` using the `html`, `markdown`, and `plainText` keys. The narrow\nauthoring callback cannot read a live editor or store.\n\nWhole browser MIME payloads use `DataTransferFormat`, not feature mappings.\nClipboard insertion remains destination-aware and can preserve native open\nedges and detached roots. See [Plugin Methods](/docs/plate/plugin-methods) and\n[Clipboard](/docs/plate/clipboard).\n\n## Document and slice authority\n\nA complete parse returns `EditorDocumentValue`. Direct syntax slice or inline\nparsing returns a closed, rootless `ContentSlice`. The insertion owner fits that\nslice once the destination is known.\n\n```ts\nconst result = editor.api.markdown.parseSlice(source);\n\nif (result.ok) editor.update.slice.replace(result.slice);\n```\n\nDo not turn a complete document into `children` before replacement; that drops\nnamed roots and metadata. Do not invent a destination during detached parsing.\n", + "content": "---\ntitle: Serializing\ndescription: Save complete Plate documents and convert them to text, HTML, Markdown, or DOCX.\n---\n\nSave Plate JSON when another Plate editor must recover the editable document.\nUse a format parser or serializer when content crosses an application boundary.\n\n## Choose a surface\n\n| Task | Surface |\n| --- | --- |\n| Persist the complete editor document | `editor.read.value()` |\n| Persist exact proposals and decisions | `projectAuthoredReview` and `parseAuthoredDocument` |\n| Convert semantic HTML | `platejs/html`; Node parsing uses `platejs/html/server` |\n| Convert Markdown | `platejs/markdown` |\n| Import or export Word | `platejs/docx/import` and `platejs/docx/export` |\n| Render component-styled HTML | `platejs/static` |\n| Exchange browser clipboard MIME payloads | `DataTransferFormat` through `platejs/dom` |\n\nThere is no universal document converter. Each format owns its syntax,\ndiagnostics, loss policy, limits, and retained source.\n\n## Save Plate JSON\n\n`editor.read.value()` includes the primary children, named roots, and persistent\nmetadata.\n\n```ts\nconst document = editor.read.value();\nconst schema = editor.read.schema.identity();\n\nawait saveDocument({ document, schema });\n```\n\nSaving only `children` drops roots and metadata. Persist an application schema\nidentity when stored documents require versioned migration. Validate migrated\ndata before publishing it to an editor.\n\nExact authored review state is canonical JSON-compatible model data:\n\n```ts\nimport { parseAuthoredDocument, projectAuthoredReview } from 'platejs/authored';\n\nconst snapshot = projectAuthoredReview(editor.read.value());\nconst json = JSON.stringify(snapshot.review);\nconst document = parseAuthoredDocument(json);\n```\n\nHTML, Markdown, and DOCX do not hide an authored document in their output.\n\n## Serialize plain text\n\n```ts\nimport { serializePlainText } from 'platejs';\n\nconst result = serializePlainText(editor, { projection: 'accepted' });\n```\n\nFeature plugins contribute structural `plainText` mappings through\n`formats`/`defineFormats`. Plain text is one-way output; it does not become a\nuniversal parser or persistence format.\n\n## Convert HTML\n\nUse direct functions for detached work:\n\n```ts\nimport { parseHtml, serializeHtml } from 'platejs/html';\n\nconst parsed = parseHtml(source, { plugins: EditorKit });\nconst output = serializeHtml(document, {\n plugins: EditorKit,\n projection: 'accepted',\n});\n```\n\nIn Node.js, import `parseHtml` from `platejs/html/server`. Install `linkedom`\nfor that server-only DOM adapter.\n\nInstall `HtmlPlugin` when a configured editor should expose\n`editor.api.html.parse`, `parseSlice`, and `serialize`. See [HTML](/docs/plate/html).\n\n## Convert Markdown\n\n```ts\nimport { parseMarkdown, serializeMarkdown } from 'platejs/markdown';\n\nconst parsed = parseMarkdown(source, { plugins: EditorKit });\nconst output = serializeMarkdown(document, {\n plugins: EditorKit,\n projection: 'proposed',\n});\n```\n\nInstall `MarkdownPlugin` for `editor.api.markdown.parse`, `parseSlice`,\n`parseInline`, and `serialize`. See [Markdown](/docs/plate/markdown).\n\n## Convert DOCX\n\nDOCX import is detached. Supply the complete plugin target before the operation\nstarts, then publish a successful document explicitly.\n\n```ts\nimport { importDocx } from 'platejs/docx/import';\n\nconst result = await importDocx(file, { plugins: EditorKit });\n\nif (result.ok) {\n editor.update((tx) => tx.value.replace(result.document));\n}\n```\n\nDOCX export captures a configured editor because static rendering, comments,\nretained source, and Word revisions are part of its package workflow.\n\n```ts\nimport { exportDocx } from 'platejs/docx/export';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst result = await exportDocx(editor, {\n presentation: BaseEditorKit,\n projection: 'review',\n});\n```\n\nA live editor's components are editing UI, so pass the app's static kit as\n`presentation` to draw the file.\n\nA Word file contains the visible Word document, its comments, and its tracked\nrevisions. Save Plate JSON beside it when another Plate editor must recover\nnamed roots, metadata, or exact authored change records.\n\nSee [DOCX](/docs/plate/docx) for cancellation, package limits, comments, and retained\nsource.\n\n## Handle results\n\nHTML, Markdown, and DOCX use format-specific diagnosed results. Success carries\nwarnings and the requested document, slice, string, or Blob. Failure carries a\nnonempty error-first diagnostic tuple and no publishable carrier.\n\n```ts\nconst result = editor.api.markdown.serialize({ projection: 'accepted' });\n\nshowDiagnostics(result.diagnostics);\nif (!result.ok) return;\n\ndownloadMarkdown(result.data);\n```\n\nExpected source failure and representational loss return diagnostics.\nConfiguration errors, mapping bugs, and invalid serializer model data throw.\n`lossPolicy` defaults to `'reject'`; opt into `'allow'` only when the caller\naccepts the exact reported loss.\n\n## Define feature mappings\n\nFeature plugins declare semantic mappings through `formats` and\n`defineFormats` using the `html`, `markdown`, and `plainText` keys. The narrow\nauthoring callback cannot read a live editor or store.\n\nWhole browser MIME payloads use `DataTransferFormat`, not feature mappings.\nClipboard insertion remains destination-aware and can preserve native open\nedges and detached roots. See [Plugin Methods](/docs/plate/plugin-methods) and\n[Clipboard](/docs/plate/clipboard).\n\n## Document and slice authority\n\nA complete parse returns `EditorDocumentValue`. Direct syntax slice or inline\nparsing returns a closed, rootless `ContentSlice`. The insertion owner fits that\nslice once the destination is known.\n\n```ts\nconst result = editor.api.markdown.parseSlice(source);\n\nif (result.ok) editor.update.slice.replace(result.slice);\n```\n\nDo not turn a complete document into `children` before replacement; that drops\nnamed roots and metadata. Do not invent a destination during detached parsing.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/serializing.mdx" } @@ -638,7 +638,7 @@ "files": [ { "path": "../../content/docs/(guides)/static.mdx", - "content": "---\ntitle: Static Rendering\ndescription: A minimal, memoized, read-only version of Plate with RSC/SSR support.\n---\n\n`` is a **fast, read-only** React component for rendering Plate content, optimized for **server-side** or **React Server Component** (RSC) environments. It avoids client-side editing logic and memoizes node renders for better performance compared to using [``](/docs/plate/api/core/plate-components) in read-only mode.\n\nIt is the rendering path behind `renderStaticHtml` and fits server or RSC\nsurfaces that need a non-interactive Plate view.\n\n## Key advantages\n\n- **Server-Safe:** No browser API dependencies; works in SSR/RSC.\n- **No Plate Editor Overhead:** Excludes interactive features like selections or event handlers.\n- **Memoized Rendering:** Uses structural identity to re-render only changed nodes.\n- **Partial Re-Renders:** Changes in one part of the document don't force a full re-render.\n- **Lightweight:** Smaller bundle size as it omits interactive editor code.\n\n## When to use ``\n\n- Generating HTML with [HTML Serialization](/docs/plate/html).\n- Displaying server-rendered previews in Next.js (especially with RSC).\n- Building static sites with read-only Plate content.\n- Optimizing performance-critical read-only views.\n- Rendering AI-streaming content.\n\n\n For interactive read-only features (like comment popovers or selections), use the standard `` component in the browser. For purely server-rendered, non-interactive content, `` is the recommended choice.\n\n\n## Kit usage\n\n\n\n### Installation\n\n`BaseEditorKit` includes base plugins configured for static rendering.\n\n\n\n### Add kit\n\n```tsx\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n initialValue: [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'This content is rendered statically.' }] },\n ],\n});\n\n// Render statically\nexport default function MyStaticPage() {\n return ;\n}\n```\n\n### Example\n\nSee a complete server-side static rendering example:\n\n\n\n\n\n## Manual usage\n\n\n\n### Create a Plate editor\n\nInitialize a Plate editor instance using `createEditor` with component-bound plugins. This is analogous to using `useCreateEditor` for the interactive `` component.\n\n```tsx title=\"lib/plate-static-editor.ts\"\nimport { createEditor } from 'platejs';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n// Import any other desired base plugins, such as MarkdownPlugin.\n// Ensure you are NOT importing from /react subpaths for server environments.\n\nconst editor = createEditor({\n plugins: [\n ...BaseBasicBlocksKit,\n // Add other base plugins here.\n ],\n initialValue: [\n {\n type: 'paragraph',\n children: [{ text: 'Hello from a static Plate editor!' }],\n },\n ],\n});\n```\n\n### Define static node components\n\nIf your interactive editor uses client-side components (e.g., with `use client` or event handlers), you **must** create static, server-safe equivalents. These components should render pure HTML without browser-specific logic.\n\n```tsx title=\"components/editor/paragraph-static.tsx\"\nimport React from 'react';\nimport type { BaseParagraphPlugin } from 'platejs';\nimport { EditorElement, type EditorElementProps } from 'platejs/static';\n\nexport function ParagraphElementStatic(\n props: EditorElementProps\n) {\n return (\n \n {props.children}\n \n );\n}\n```\nCreate similar static components for headings, images, links, etc.\n\n### Bind static components\n\nConfigure each server-safe component on its owning base plugin.\n\n```ts title=\"components/static-editor-kit.ts\"\nimport { BaseHeadingPlugin, BaseParagraphPlugin } from 'platejs';\nimport { ParagraphElementStatic } from './ui/paragraph-static';\nimport { HeadingElementStatic } from './ui/heading-static';\n\nexport const StaticEditorKit = [\n BaseParagraphPlugin.configure({ component: ParagraphElementStatic }),\n BaseHeadingPlugin.configure({ component: HeadingElementStatic }),\n];\n```\n\n### Render ``\n\nUse `` with an editor whose plugin descriptors own their static components.\n\n```tsx title=\"app/my-static-page/page.tsx (RSC Example)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { StaticEditorKit } from '@/components/static-editor-kit';\n\nexport default async function MyStaticPage() {\n // Example: Fetch or define editor value\n const initialValue = [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'Content rendered statically.' }] },\n ];\n\n const editor = createEditor({\n plugins: StaticEditorKit,\n initialValue,\n });\n\n return (\n \n );\n}\n```\n\n### Memoization details\n\n`` enhances performance through memoization:\n- Static element and leaf rendering uses `React.memo`.\n- **Reference Equality:** A block renders again only when it, a block\n before it, or a decoration inside it changed, so a renderer may read its\n own block and earlier ones, such as list numbering.\n- **Later content:** An element whose rendering reads content after it, such\n as a table of contents, declares `render: { readsDocument: true }` on its\n plugin and renders again whenever the document changes.\n\n\n\n## Render a document\n\nPass `document` to render a document through the editor's plugins without\nediting the editor: no transaction, history or selection change. One configured\neditor can render a streamed preview or a stored revision this way:\n\n```tsx\n\n```\n\nThe document must be valid for the editor's schema. The editor validates a\ndocument from `editor.read.value()` once and any other document object on every\nrender, because that object can change between renders.\n`EditorPreview` takes the same `document`, and copying from it copies the\nrendered document.\n\nEach render reads the decorations of every top-level block, so rendering again\nis also how decorations that depend on other state refresh. A component that\nre-renders for other reasons, such as each streamed chunk, should memoize the\nelement on its document:\n\n```tsx\nconst preview = useMemo(\n () => ,\n [editor, document]\n);\n```\n\nEverything read through the rendered editor sees the document: `editor.read`,\nplugin reads such as `editor.plugin(TocPlugin).read.headings()`, and plugin\nAPIs. Plugins get that editor from their render, decorate, read or API\ncontext. An editor captured when a plugin was created, as in\n`.extend(({ editor }) => …)`, is the editor you passed and still holds its own\nvalue. Reading it from plugin code that reads, decorates or renders the\ndocument throws an error that names the fix. React runs component bodies after\nthat code returns, so a component reads through its `editor` prop.\n\n## Client-side alternative: `EditorPreview`\n\nFor cases where you need **minimal interactivity** with static content, use ``. This component wraps `` and adds client-side event handlers for user interactions while maintaining the performance benefits of static rendering.\n\n### Example: server component with both static views\n\n```tsx title=\"app/document/page.tsx\"\nimport { createStaticEditor, EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\nimport { InteractiveViewer } from './interactive-viewer';\n\nexport default async function DocumentPage() {\n const content = await fetchDocument(); // Your document data\n // Server-side static editor\n const editor = createStaticEditor({\n plugins: BaseEditorKit,\n initialValue: content,\n });\n\n return (\n
\n {/* Pure static rendering - no interactivity */}\n
\n

Static View (Server Rendered)

\n \n
\n\n {/* Interactive view - rendered on client */}\n
\n

Interactive View

\n \n
\n
\n );\n}\n```\n\n### Example: client component with EditorPreview\n\n```tsx title=\"app/document/interactive-viewer.tsx\"\n'use client';\n\nimport { EditorPreview, useStaticEditor } from 'platejs/react';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nexport function InteractiveViewer({ value }) {\n const editor = useStaticEditor({\n plugins: BaseEditorKit,\n initialValue: value,\n });\n\n return ;\n}\n```\n\n### Key features of `EditorPreview`\n\n- **Client-side only**: Requires `'use client'` directive\n- **Adds interactivity**: Enables user interactions with the content (e.g., text selection, copying, future interactions like tooltips, highlights, etc.)\n- **Minimal overhead**: Still uses `EditorStatic` internally for rendering\n- **Use with `useStaticEditor`**: Creates a static editor optimized for view-only React components\n- **Exact copy payloads**: `EditorPreview` resolves selection against its own\n static host and writes the same model slice, HTML, and plain-text payloads as\n a mounted editor\n\n\n `EditorPreview` cannot be used in Server Components. If you're passing an editor from a server component to a client component, you'll encounter serialization errors. Use `EditorStatic` on the server side, or create the editor client-side with `useStaticEditor`.\n\n\n## `EditorStatic` vs. `EditorPreview` vs. `EditorRoot` + `readOnly`\n\n| Aspect | `` | `` | `` + `readOnly` |\n| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |\n| **Environment** | Server/Client (SSR/RSC safe) | Client-only | Client-only |\n| **Interactivity** | None | Minimal (selection, copy, toolbar, etc.) | Full interactive features (browser-only) |\n| **Browser APIs** | Not used | Minimal (event handlers) | Full usage |\n| **Performance** | Best - static HTML only | Good - static rendering + event delegation | Heavier - full editor internals |\n| **Bundle Size** | Smallest | Small | Largest |\n| **Use Cases** | Server rendering, HTML export | Client-side content with basic interactions | Full read-only editor with all features |\n| **Recommendation** | SSR/RSC without any interactions | Client-side content needing light interactivity | Client-side with complex interactive needs |\n\n## RSC/SSR example\n\nIn a Next.js App Router (or similar RSC environment), `` can be used directly in Server Components:\n\n```tsx title=\"app/preview/page.tsx (RSC)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nexport default async function Page() {\n // Fetch or define content server-side\n const serverContent = [\n { type: 'heading', level: 1, children: [{ text: 'Rendered on the Server! 🎉' }] },\n { type: 'paragraph', children: [{ text: 'This content is static and server-rendered.' }] },\n ];\n\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: serverContent,\n });\n\n return (\n \n );\n}\n```\nThis renders the content to HTML on the server without needing a client-side JavaScript bundle for `EditorStatic` itself.\n\n## Pairing with `renderStaticHtml`\n\nFor server-rendering the static Plate tree to an HTML string, use\n`renderStaticHtml`. It renders `` through React DOM Server; it is\nnot a semantic HTML interchange serializer.\n\n`EditorStatic` renders configured node components and render attributes. It\nomits editing-surface slots such as `slots.wrapContent`; wrap `result.data` in\napplication presentation when the exported document needs an outer shell.\n\n```ts title=\"lib/html-serializer.ts\"\nimport { createEditor } from 'platejs';\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nasync function getDocumentAsHtml(value: any[]) {\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: value,\n });\n\n const result = await renderStaticHtml(editor, {\n // component: EditorStatic, // Optional: Defaults to EditorStatic\n props: { className: 'prose max-w-none' }, // Example: Pass props to the root div\n });\n\n return result.data;\n}\n\n// Example Usage:\n// const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ];\n// getDocumentAsHtml(value).then(console.log);\n```\nFor more details, see the [HTML Serialization guide](/docs/plate/html).\n\n## API Reference\n\n### `` props\n\n```ts\nimport type React from 'react';\nimport type { Editor, EditorDocumentValue } from 'platejs';\n\ninterface EditorStaticProps\n extends React.HTMLAttributes {\n /**\n * A document to render instead of the editor's own. The editor supplies\n * the plugins and is not edited.\n */\n document?: EditorDocumentValue;\n\n /**\n * The Plate editor instance, created via `createEditor`.\n * Must include component-bound plugins relevant to the rendered content.\n */\n editor: E;\n\n /** Inline CSS styles for the root `div` element. */\n style?: React.CSSProperties;\n\n // Other HTMLDivElement attributes like `className`, `id`, etc., are also supported.\n}\n```\n\n- **`document`**: A document rendered through the editor's plugins instead of the editor's own value; see [Render a document](#render-a-document).\n- **`editor`**: An editor created with `createEditor` or `createStaticEditor`, including component-bound plugins required by the value.\n\n## Next steps\n\n- Explore [HTML Serialization](/docs/plate/html) for exporting content.\n- Learn about using Plate in [React Server Components](/docs/plate/installation/rsc).\n- Refer to individual plugin documentation for their base (non-React) imports.\n", + "content": "---\ntitle: Static Rendering\ndescription: A minimal, memoized, read-only version of Plate with RSC/SSR support.\n---\n\n`` is a **fast, read-only** React component for rendering Plate content, optimized for **server-side** or **React Server Component** (RSC) environments. It avoids client-side editing logic and memoizes node renders for better performance compared to using [``](/docs/plate/api/core/plate-components) in read-only mode.\n\nIt is the rendering path behind `renderStaticHtml` and fits server or RSC\nsurfaces that need a non-interactive Plate view.\n\n## Key advantages\n\n- **Server-Safe:** No browser API dependencies; works in SSR/RSC.\n- **No Plate Editor Overhead:** Excludes interactive features like selections or event handlers.\n- **Memoized Rendering:** Uses structural identity to re-render only changed nodes.\n- **Partial Re-Renders:** Changes in one part of the document don't force a full re-render.\n- **Lightweight:** Smaller bundle size as it omits interactive editor code.\n\n## When to use ``\n\n- Generating HTML with [HTML Serialization](/docs/plate/html).\n- Displaying server-rendered previews in Next.js (especially with RSC).\n- Building static sites with read-only Plate content.\n- Optimizing performance-critical read-only views.\n- Rendering AI-streaming content.\n\n\n For interactive read-only features (like comment popovers or selections), use the standard `` component in the browser. For purely server-rendered, non-interactive content, `` is the recommended choice.\n\n\n## Kit usage\n\n\n\n### Installation\n\n`BaseEditorKit` includes base plugins configured for static rendering.\n\n\n\n### Add kit\n\n```tsx\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n initialValue: [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'This content is rendered statically.' }] },\n ],\n});\n\n// Render statically\nexport default function MyStaticPage() {\n return ;\n}\n```\n\n### Example\n\nSee a complete server-side static rendering example:\n\n\n\n\n\n## Manual usage\n\n\n\n### Create a Plate editor\n\nInitialize a Plate editor instance using `createEditor` with component-bound plugins. This is analogous to using `useCreateEditor` for the interactive `` component.\n\n```tsx title=\"lib/plate-static-editor.ts\"\nimport { createEditor } from 'platejs';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n// Import any other desired base plugins, such as MarkdownPlugin.\n// Ensure you are NOT importing from /react subpaths for server environments.\n\nconst editor = createEditor({\n plugins: [\n ...BaseBasicBlocksKit,\n // Add other base plugins here.\n ],\n initialValue: [\n {\n type: 'paragraph',\n children: [{ text: 'Hello from a static Plate editor!' }],\n },\n ],\n});\n```\n\n### Define static node components\n\nIf your interactive editor uses client-side components (e.g., with `use client` or event handlers), you **must** create static, server-safe equivalents. These components should render pure HTML without browser-specific logic.\n\n```tsx title=\"components/editor/paragraph-static.tsx\"\nimport React from 'react';\nimport type { BaseParagraphPlugin } from 'platejs';\nimport { EditorElement, type EditorElementProps } from 'platejs/static';\n\nexport function ParagraphElementStatic(\n props: EditorElementProps\n) {\n return (\n \n {props.children}\n \n );\n}\n```\nCreate similar static components for headings, images, links, etc.\n\n### Bind static components\n\nConfigure each server-safe component on its owning base plugin.\n\n```ts title=\"components/static-editor-kit.ts\"\nimport { BaseHeadingPlugin, BaseParagraphPlugin } from 'platejs';\nimport { ParagraphElementStatic } from './ui/paragraph-static';\nimport { HeadingElementStatic } from './ui/heading-static';\n\nexport const StaticEditorKit = [\n BaseParagraphPlugin.configure({ component: ParagraphElementStatic }),\n BaseHeadingPlugin.configure({ component: HeadingElementStatic }),\n];\n```\n\n### Render ``\n\nUse `` with an editor whose plugin descriptors own their static components.\n\n```tsx title=\"app/my-static-page/page.tsx (RSC Example)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { StaticEditorKit } from '@/components/static-editor-kit';\n\nexport default async function MyStaticPage() {\n // Example: Fetch or define editor value\n const initialValue = [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'Content rendered statically.' }] },\n ];\n\n const editor = createEditor({\n plugins: StaticEditorKit,\n initialValue,\n });\n\n return (\n \n );\n}\n```\n\n### Memoization details\n\n`` enhances performance through memoization:\n- Static element and leaf rendering uses `React.memo`.\n- **Reference Equality:** A block renders again only when it, a block\n before it, or a decoration inside it changed, so a renderer may read its\n own block and earlier ones, such as list numbering.\n- **Later content:** An element whose rendering reads content after it, such\n as a table of contents, declares `render: { readsDocument: true }` on its\n plugin and renders again whenever the document changes.\n\n\n\n## Render a document\n\nPass `document` to render a document through the editor's plugins without\nediting the editor: no transaction, history or selection change. One configured\neditor can render a streamed preview or a stored revision this way:\n\n```tsx\n\n```\n\nThe document must be valid for the editor's schema. The editor validates a\ndocument from `editor.read.value()` once and any other document object on every\nrender, because that object can change between renders.\n`EditorPreview` takes the same `document`, and copying from it copies the\nrendered document.\n\nEach render reads the decorations of every top-level block, so rendering again\nis also how decorations that depend on other state refresh. A component that\nre-renders for other reasons, such as each streamed chunk, should memoize the\nelement on its document:\n\n```tsx\nconst preview = useMemo(\n () => ,\n [editor, document]\n);\n```\n\nEverything read through the rendered editor sees the document: `editor.read`,\nplugin reads such as `editor.plugin(TocPlugin).read.headings()`, and plugin\nAPIs. Plugins get that editor from their render, decorate, read or API\ncontext. An editor captured when a plugin was created, as in\n`.extend(({ editor }) => …)`, is the editor you passed and still holds its own\nvalue. Reading it from plugin code that reads, decorates or renders the\ndocument throws an error that names the fix. React runs component bodies after\nthat code returns, so a component reads through its `editor` prop.\n\n## Client-side alternative: `EditorPreview`\n\nFor cases where you need **minimal interactivity** with static content, use ``. This component wraps `` and adds client-side event handlers for user interactions while maintaining the performance benefits of static rendering.\n\n### Example: server component with both static views\n\n```tsx title=\"app/document/page.tsx\"\nimport { createStaticEditor, EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\nimport { InteractiveViewer } from './interactive-viewer';\n\nexport default async function DocumentPage() {\n const content = await fetchDocument(); // Your document data\n // Server-side static editor\n const editor = createStaticEditor({\n plugins: BaseEditorKit,\n initialValue: content,\n });\n\n return (\n
\n {/* Pure static rendering - no interactivity */}\n
\n

Static View (Server Rendered)

\n \n
\n\n {/* Interactive view - rendered on client */}\n
\n

Interactive View

\n \n
\n
\n );\n}\n```\n\n### Example: client component with EditorPreview\n\n```tsx title=\"app/document/interactive-viewer.tsx\"\n'use client';\n\nimport { EditorPreview, useStaticEditor } from 'platejs/react';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nexport function InteractiveViewer({ value }) {\n const editor = useStaticEditor({\n plugins: BaseEditorKit,\n initialValue: value,\n });\n\n return ;\n}\n```\n\n### Key features of `EditorPreview`\n\n- **Client-side only**: Requires `'use client'` directive\n- **Adds interactivity**: Enables user interactions with the content (e.g., text selection, copying, future interactions like tooltips, highlights, etc.)\n- **Minimal overhead**: Still uses `EditorStatic` internally for rendering\n- **Use with `useStaticEditor`**: Creates a static editor optimized for view-only React components\n- **Exact copy payloads**: `EditorPreview` resolves selection against its own\n static host and writes the same model slice, HTML, and plain-text payloads as\n a mounted editor\n\n\n `EditorPreview` cannot be used in Server Components. If you're passing an editor from a server component to a client component, you'll encounter serialization errors. Use `EditorStatic` on the server side, or create the editor client-side with `useStaticEditor`.\n\n\n## `EditorStatic` vs. `EditorPreview` vs. `EditorRoot` + `readOnly`\n\n| Aspect | `` | `` | `` + `readOnly` |\n| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |\n| **Environment** | Server/Client (SSR/RSC safe) | Client-only | Client-only |\n| **Interactivity** | None | Minimal (selection, copy, toolbar, etc.) | Full interactive features (browser-only) |\n| **Browser APIs** | Not used | Minimal (event handlers) | Full usage |\n| **Performance** | Best - static HTML only | Good - static rendering + event delegation | Heavier - full editor internals |\n| **Bundle Size** | Smallest | Small | Largest |\n| **Use Cases** | Server rendering, HTML export | Client-side content with basic interactions | Full read-only editor with all features |\n| **Recommendation** | SSR/RSC without any interactions | Client-side content needing light interactivity | Client-side with complex interactive needs |\n\n## RSC/SSR example\n\nIn a Next.js App Router (or similar RSC environment), `` can be used directly in Server Components:\n\n```tsx title=\"app/preview/page.tsx (RSC)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nexport default async function Page() {\n // Fetch or define content server-side\n const serverContent = [\n { type: 'heading', level: 1, children: [{ text: 'Rendered on the Server! 🎉' }] },\n { type: 'paragraph', children: [{ text: 'This content is static and server-rendered.' }] },\n ];\n\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: serverContent,\n });\n\n return (\n \n );\n}\n```\nThis renders the content to HTML on the server without needing a client-side JavaScript bundle for `EditorStatic` itself.\n\n## Pairing with `renderStaticHtml`\n\nFor server-rendering the static Plate tree to an HTML string, use\n`renderStaticHtml`. It renders `` through React DOM Server; it is\nnot a semantic HTML interchange serializer.\n\n`EditorStatic` renders configured node components and render attributes. It\nomits editing-surface slots such as `slots.wrapContent`; wrap `result.data` in\napplication presentation when the exported document needs an outer shell.\n\n```ts title=\"lib/html-serializer.ts\"\nimport { createEditor } from 'platejs';\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nasync function getDocumentAsHtml(value: any[]) {\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: value,\n });\n\n const result = await renderStaticHtml(editor, {\n // component: EditorStatic, // Optional: Defaults to EditorStatic\n props: { className: 'prose max-w-none' }, // Example: Pass props to the root div\n });\n\n return result.data;\n}\n\n// Example Usage:\n// const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ];\n// getDocumentAsHtml(value).then(console.log);\n```\n\n`props` takes appearance props for the root element, such as `className` or\n`style`. It throws a `TypeError` for `editor` or `document`. Pass the editor as\nthe first argument and another document as the `document` option.\n\n### Export a live editor\n\nA live editor's components are editing UI that needs a mounted Plate editor.\nPass the app's static kit as `presentation` to draw the export with its static\ncomponents while the live editor keeps the document and plugin settings:\n\n```ts\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst result = await renderStaticHtml(editor, {\n presentation: BaseEditorKit,\n});\n```\n\nFor each element component, mark component, and wrapper slot function an\ninstalled plugin declares, the plugin of the same name in `presentation`\nsupplies the drawing, also on an element that no installed plugin renders.\nDrawings the installed plugin marks `editOnly` are left out. A drawing\n`presentation` lacks is left out too, so the content renders without it, and\n`result.diagnostics` names the plugin in a `missing-static-presentation`\nwarning. An installed `afterNodeChildren` written as a component, such as\n`React.memo`, counts as a drawing, so `presentation` needs a function for it.\nAn installed tag name, such as `component: 'p'`, stays.\n\nDeclare drawings as values and read plugin state through the `editor` prop.\n`presentation` compiles without activating its plugins. Drawings created in a\n`configure((ctx) => ...)` callback are not supported. Such a drawing can throw\nwhen it draws, which rejects the export, or draw the presentation's own\nconfiguration. Pass a stable array, because each new array is compiled again.\n\nFor more details, see the [HTML Serialization guide](/docs/plate/html).\n\n## API Reference\n\n### `` props\n\n```ts\nimport type React from 'react';\nimport type { Editor, EditorDocumentValue } from 'platejs';\n\ninterface EditorStaticProps\n extends React.HTMLAttributes {\n /**\n * A document to render instead of the editor's own. The editor supplies\n * the plugins and is not edited.\n */\n document?: EditorDocumentValue;\n\n /**\n * The Plate editor instance, created via `createEditor`.\n * Must include component-bound plugins relevant to the rendered content.\n */\n editor: E;\n\n /** Inline CSS styles for the root `div` element. */\n style?: React.CSSProperties;\n\n // Other HTMLDivElement attributes like `className`, `id`, etc., are also supported.\n}\n```\n\n- **`document`**: A document rendered through the editor's plugins instead of the editor's own value; see [Render a document](#render-a-document).\n- **`editor`**: An editor created with `createEditor` or `createStaticEditor`, including component-bound plugins required by the value.\n\n## Next steps\n\n- Explore [HTML Serialization](/docs/plate/html) for exporting content.\n- Learn about using Plate in [React Server Components](/docs/plate/installation/rsc).\n- Refer to individual plugin documentation for their base (non-React) imports.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/static.mdx" } @@ -1436,7 +1436,7 @@ "files": [ { "path": "../../content/docs/(plugins)/(serializing)/docx.mdx", - "content": "---\ntitle: DOCX\ndescription: Paste Word content, import bounded DOCX files, and export an explicit document projection.\n---\n\nUse `WordPastePlugin` for clipboard content. Use `importDocx` and `exportDocx` for files. The three operations have separate entrypoints and dependencies.\n\n\n\n\n\n## Features\n\n- Normalize HTML and RTF pasted from Microsoft Word\n- Import a bounded DOCX package into one complete editor document\n- Preserve supported Word revisions and comment ranges as structured data\n- Export one captured editor snapshot as an accepted, proposed, or review DOCX\n\n\n\n\n Paste, file import, and file download use browser APIs. Keep these operations\n in client code. Export uses React static rendering, so pass the descriptors\n and static components that define your document output.\n\n\n## Kit usage\n\nThe app-local `DocxKit` installs `WordPastePlugin`. File import and export are standalone functions.\n\n\n\n### Installation\n\n```bash\nnpm install platejs @tanstack/react-virtual juice validator\n```\n\n### Add kit\n\n\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { createEditor } from \"platejs/react\";\nimport { DocxKit } from \"@/components/editor/docx\";\n\nconst editor = createEditor({\n plugins: [...DocxKit],\n});\n```\n\n\n\n## Paste from Word\n\n`WordPastePlugin` inlines pasted CSS and normalizes Word HTML and RTF before the installed Plate format mappings decode the content.\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { WordPastePlugin } from \"platejs/docx/paste\";\nimport { createEditor } from \"platejs/react\";\n\nconst editor = createEditor({\n plugins: [WordPastePlugin],\n});\n```\n\nClipboard paste does not upload embedded images or preserve Word table geometry. Configure those destination policies in their owning media and table features.\n\n## Import a DOCX file\n\n`importDocx` reads a `Blob` or `ArrayBuffer` without modifying the editor. On success, replace the complete editor value so document metadata, named roots, and authored review state stay coherent.\n\nInside a mounted React control, get that complete editor with\n`useModelEditor()`. `useEditor()` returns the provider-selected mounted command\nview, whose root and authored projection belong to that view.\n\n```bash\nnpm install platejs @tanstack/react-virtual mammoth validator\n```\n\n```tsx title=\"components/editor/import-docx.tsx\"\nimport { importDocx } from \"platejs/docx/import\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n});\n\nexport async function importFile(file: File) {\n const result = await importDocx(file, {\n plugins: BaseEditorKit,\n });\n\n if (!result.ok) {\n return { diagnostics: result.diagnostics };\n }\n\n editor.update.value.replace(result.document);\n\n return {\n comments: result.comments,\n diagnostics: result.diagnostics,\n };\n}\n```\n\nAn expected file, package, limit, or decode failure returns `{ ok: false, diagnostics }` and no document. An aborted import rejects with the signal reason. Invalid options and internal invariant failures throw.\n\n### Retain the source for later export\n\nSet `retainSource: true` when the same editor imports, edits, and exports a Word file. A successful import then includes `source`. It is a `DocxSource` when exact export can return the package unchanged. Otherwise it is `null`, with a `source-unavailable` warning that names the first part exact export cannot admit. Keep the source in application state, pass it to `exportDocx`, and dispose it when the document closes or another file replaces it.\n\n```tsx title=\"components/editor/docx-roundtrip.tsx\" showLineNumbers\nimport { exportDocx } from \"platejs/docx/export\";\nimport { importDocx } from \"platejs/docx/import\";\n\nconst imported = await importDocx(file, {\n plugins: BaseEditorKit,\n retainSource: true,\n});\n\nif (!imported.ok) return imported.diagnostics;\n\neditor.update.value.replace(imported.document);\n\nconst exported = await exportDocx(editor, {\n projection: \"review\",\n source: imported.source,\n stylesheet,\n});\n\nimported.source?.dispose();\n```\n\nExact export admits a closed set of passive package content:\n\n- the main document at `word/document.xml`, styles, settings, numbering, fonts, and theme;\n- headers, footers, footnotes, endnotes, and comments;\n- core, extended, and custom document properties and a thumbnail;\n- AVIF, BMP, GIF, JPEG, PNG, and WebP images reached through internal relationships;\n- hyperlinks whose destination is absolute and meets the navigation floor.\n\n`PAGE` is the only recognized field instruction. Any other part, relationship, markup namespace, field instruction, or XML processing instruction leaves `source` null. That includes unsafe or relative hyperlinks, other external relationships, macros, ActiveX controls, OLE objects, imported HTML chunks, external templates, custom XML data, and SVG or EMF images. A null source does not mean the import lost content; exporting without a source generates the document from editor content.\n\n`DocxSource` retains the admitted compressed package plus immutable document, comment, schema, and limit correspondence. It does not retain expanded ZIP entries or an editor reference. `dispose()` is idempotent. An export that already acquired the source can finish after disposal; later exports report `source-unavailable` and generate a fresh DOCX.\n\n### Import comments\n\nThe importer returns comment format facts without writing app comment storage. Each `DocxComment` can include a rich Plate `body`, an exact `target.range` in proposed-projection coordinates, Word author and date metadata, a durable ID, a parent ID, and resolved state. Map those records to your app identities explicitly.\n\n### Import tracked revisions\n\nSupported Word insertions, deletions, moves, run property changes, and paragraph property changes become authored changes. Revision IDs follow Word source order and structural dependencies; dates remain metadata. A missing or invalid date uses the authored unknown-time value and produces a diagnostic.\n\nIf a revision cannot map to the installed schema, the importer keeps the Word-visible proposed content and reports the loss in `diagnostics`.\n\n### Import list numbering\n\nNumbered and bulleted Word paragraphs become list items, and each item joins the list of its Word numbering definition. Word counts every instance of a definition in one sequence per level. A list therefore continues past other paragraphs and across instances, and lists of different definitions that interleave keep their own numbers. A level restarts after a paragraph at the level its `lvlRestart` names or a shallower one, which by default is the level just above it. An instance's start override resets its level once, at the instance's first paragraph at that level. That item imports with the override as `listRestart`, and the items after it count on from it, so an inserted item renumbers the rest the way Word does. Each item's `listLevels` hold its definition's level starts, `lvlRestart` rules and level text, with its instance's format and level text overrides applied. Word ignores a start or `lvlRestart` inside a level override, and so does the import. A paragraph whose instance shows its level as bullets imports as a bulleted item and does not count.\n\n## Export a DOCX file\n\n`exportDocx` captures the editor once before asynchronous rendering. Pass the visible projection explicitly.\n\n```bash\nnpm install platejs @tanstack/react-virtual color-name html-to-vdom jszip juice mime-types virtual-dom xmlbuilder2\n```\n\n```tsx title=\"components/editor/export-docx.tsx\"\nimport { exportDocx } from \"platejs/docx/export\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\nimport { DOCX_EXPORT_STYLES } from \"@/components/editor/docx-export\";\n\nconst editor = createEditor({ plugins: BaseEditorKit });\n\nexport async function downloadEditorDocx() {\n const result = await exportDocx(editor, {\n orientation: \"portrait\",\n projection: \"proposed\",\n stylesheet: DOCX_EXPORT_STYLES,\n title: \"Document\",\n });\n\n if (!result.ok) return result.diagnostics;\n\n const url = URL.createObjectURL(result.blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"document.docx\";\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n\n return result.diagnostics;\n}\n```\n\nChoose `accepted` to omit pending changes, `proposed` to apply them, or `review` to write Word tracked-change markup. Accepted and proposed exports warn about omitted pending changes and conflicts resolved to one side. A review export with any conflict returns `ok: false` and an `authored-conflict` error because Word revisions cannot represent that state faithfully.\n\nA review file contains what Word displays: text, comments, and tracked revisions with their authors. Importing it restores those revisions as authored changes. Export does not write named roots, document metadata, or exact authored change records, such as their original IDs, to the file. To keep them, save the authored JSON document beside the file, as [Serializing](/docs/plate/serializing) describes:\n\n```tsx\nimport { parseAuthoredDocument, projectAuthoredReview } from \"platejs/authored\";\n\nconst json = JSON.stringify(projectAuthoredReview(editor.read.value()).review);\nconst document = parseAuthoredDocument(json);\n```\n\n### Content loss\n\n`exportDocx` rejects content loss by default. When an image cannot be embedded, or a supplied comment has no representable range, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: \"allow\"` to download the file and receive the same loss as warnings:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n lossPolicy: \"allow\",\n projection: \"review\",\n});\n```\n\nNamed roots, document metadata, removed link destinations, and other properties DOCX cannot represent are warnings under both policies. An `accepted` export omits a comment whose annotated content that projection excludes, and reports a warning, as Word does when the annotated suggestion is rejected.\n\n### Lists\n\nEach Plate list becomes one Word list. Its `listLevels` give the list's level starts, `lvlRestart` rules and level text. An item with `listRestart`, or one whose number Word would not reach, gets a numbering instance of its own that overrides that level's start, as Word's Restart Numbering writes it. The items after it count on from it in the list's own instance. Word and a re-import then show the numbers the editor shows, and the restart stays on that item. Word draws one marker style per list level, so an item whose `listStyle` differs from the first item at its level keeps that first style and reports a `list-marker` warning under both policies. A Word list level is numbered or bulleted, as its first item is, and the exporter writes one set of level rules per list. An item of the other kind at its level is therefore not part of its list in Word, and an item whose `listLevels` differ from its list's first item takes that item's rules. Each reports a `list-structure` warning, and `lossPolicy: 'reject'` fails the export. Word has no start that applies only when an item opens its level, so an item with `listStart` exports its shown number and reports a `list-start` warning under both policies.\n\n### Links and images\n\nExport writes a link destination only when it is absolute and meets the navigation floor, such as `https:` or `mailto:`. It writes bookmark links such as `#introduction` as Word anchors. An unsafe destination, or a relative link that has no document base in a Word file, keeps its text without the destination.\n\nImages are embedded as PNG, JPEG, GIF, or BMP. Set `allowRemoteImages: true` to fetch HTTP(S) images during export. These images are omitted and leave their alt text:\n\n- an image with an unsafe, relative, or blob source;\n- a remote image when `allowRemoteImages` is off;\n- an image that cannot be fetched;\n- an image in any other format.\n\nBefore returning a generated file, export checks the written package against the same passive vocabulary that retained sources use. Output outside it is withheld with an `invalid-package` error. An unchanged source-aware export returns the retained file, which passed the same check when it was imported.\n\n### Source-aware export\n\nWith a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, or `pageSize` override. Static renderer options are bypassed on this path because no content is regenerated.\n\nIf an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting:\n\n- headers and footers from a document with one section, including their images and hyperlinks;\n- custom document properties.\n\nThe regenerated package has its own core document properties. It omits source body XML, body-only parts such as footnotes and body images, and headers and footers from a document with multiple sections. It also omits the source core and extended properties and the package thumbnail: the core properties, such as the author, title and creation date, the extended properties, which count pages and words and hold details such as the company name, and the thumbnail, which pictures the body. Diagnostics report why the source was rewritten and identify each omitted source part. A custom property linked to body text keeps the value cached in the source file, even when the edit changed that text.\n\nWhen regeneration is required, omitted `comments` means that no comments are generated. Pass the current app-owned comment set explicitly to retain comments after an edit. Imported comment ranges are not relocated automatically.\n\n### Export comments\n\nPass comments whose ranges address the editor's proposed projection. Export maps those ranges into accepted or review output before rendering:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n projection: \"review\",\n});\n```\n\nThe same configured static renderer handles the main document and rich comment bodies. Under the default `lossPolicy`, a comment without a complete range fails the export; with `lossPolicy: \"allow\"` the export omits it with a warning.\n\n### Static rendering and styles\n\nDOCX owns its semantic static mappings for code blocks, columns, equations,\ncallouts, headings, and tables of contents. The app supplies presentation CSS\nand may pass a custom root `component` when it needs a different wrapper.\n`DOCX_EXPORT_STYLES` is the registry's copied presentation preset.\n\n\n\n## Format support\n\n| Content | Behavior |\n| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n| Paragraphs, headings, supported marks and properties | Decode through the installed HTML mappings and export through the configured static renderer |\n| Lists, links, bookmarks, rows, cells, and spans | Preserve supported semantic structure; omit Word layout details with diagnostics |\n| Tracked revisions | Preserve supported insert, delete, move, run-property, and paragraph-property changes |\n| Main-body comments | Preserve rich bodies, ranges, author/date metadata, durable IDs, replies, and resolved state when present |\n| Embedded images during import | Omit the resource and return a `resource-omitted` diagnostic |\n| Links and images during export | Write absolute navigation-safe links and PNG, JPEG, GIF, or BMP images; report removed destinations and omitted images |\n| Single-section headers and footers | Keep safe closed source subgraphs during source-aware export; do not map them into the editor document |\n| Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics |\n| Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content |\n| Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON |\n| Custom Plate, math, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics |\n\n## API reference\n\n### Entrypoints\n\n| Surface | Import path |\n| ------------------------------- | --------------------- |\n| `WordPastePlugin` | `platejs/docx/paste` |\n| `importDocx` and import types | `platejs/docx/import` |\n| `exportDocx` and export types | `platejs/docx/export` |\n\n### `importDocx`\n\n```ts\nimportDocx(\n source: ArrayBuffer | Blob,\n options: DocxImportOptions\n): Promise\n```\n\n`DocxImportOptions` requires `plugins` and accepts `schema`, a cooperative\n`signal`, `lossPolicy`, partial `limits` overrides, and\n`retainSource`. Import captures that detached target before its first await and\nnever reads a live editor or mutable plugin store. Every limit must be a\npositive safe integer. Literal `retainSource: true` conditionally adds\n`source: DocxSource | null` to a successful result; omitted or literal `false`\ndoes not.\n\n| Default limit | Value |\n| ------------------ | ------: |\n| `maxInputBytes` | 32 MiB |\n| `maxEntries` | 1,024 |\n| `maxEntryBytes` | 32 MiB |\n| `maxExpandedBytes` | 128 MiB |\n| `maxRelationships` | 4,096 |\n| `maxRevisions` | 2,000 |\n| `maxComments` | 5,000 |\n| `maxXmlDepth` | 128 |\n| `maxXmlNodes` | 500,000 |\n\nA successful result contains `document`, `comments`, and structured `diagnostics`. A retained success also contains `source`, which is `null` when exact export cannot admit the package. A failed result contains only `diagnostics`.\n\n### `DocxSource`\n\n```ts\nclass DocxSource {\n private constructor();\n dispose(): void;\n}\n```\n\nOnly `importDocx` creates a `DocxSource`. The object is process-local and cannot be serialized or reattached. To restore source-aware export after application reload, keep the original file and import it again.\n\n### `exportDocx`\n\n```ts\nexportDocx(\n editor: Editor,\n options: DocxExportOptions\n): Promise\n```\n\n| Option | Type | Description |\n| ----------------------- | ---------------------------------------- | -------------------------------------------------- |\n| `projection` | `'accepted' \\| 'proposed' \\| 'review'` | Required visible document projection |\n| `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates |\n| `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` |\n| `component` | `React.ComponentType` | Optional static editor wrapper |\n| `fontFamily` | `string` | Document body font |\n| `lossPolicy` | `'allow' \\| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` |\n| `margins` | `Margins` | Page margins in twentieths of a point |\n| `orientation` | `'landscape' \\| 'portrait'` | Page orientation |\n| `pageSize` | `PageSize` | Page dimensions in twentieths of a point |\n| `signal` | `AbortSignal` | Cooperative cancellation signal |\n| `source` | `DocxSource \\| null` | Retained source correspondence for exact or safe export |\n| `stylesheet` | `string` | CSS applied before DOCX conversion |\n| `title` | `string` | Document metadata title |\n\nA successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`.\n\nSee [Authored Changes](/docs/plate/authored-changes) for the editor's accepted, proposed, and review projections.\n", + "content": "---\ntitle: DOCX\ndescription: Paste Word content, import bounded DOCX files, and export an explicit document projection.\n---\n\nUse `WordPastePlugin` for clipboard content. Use `importDocx` and `exportDocx` for files. The three operations have separate entrypoints and dependencies.\n\n\n\n\n\n## Features\n\n- Normalize HTML and RTF pasted from Microsoft Word\n- Import a bounded DOCX package into one complete editor document\n- Preserve supported Word revisions and comment ranges as structured data\n- Export one captured editor snapshot as an accepted, proposed, or review DOCX\n\n\n\n\n Paste, file import, and file download use browser APIs. Keep these operations\n in client code. Export uses React static rendering, so pass the descriptors\n and static components that define your document output.\n\n\n## Kit usage\n\nThe app-local `DocxKit` installs `WordPastePlugin`. File import and export are standalone functions.\n\n\n\n### Installation\n\n```bash\nnpm install platejs @tanstack/react-virtual juice validator\n```\n\n### Add kit\n\n\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { createEditor } from \"platejs/react\";\nimport { DocxKit } from \"@/components/editor/docx\";\n\nconst editor = createEditor({\n plugins: [...DocxKit],\n});\n```\n\n\n\n## Paste from Word\n\n`WordPastePlugin` inlines pasted CSS and normalizes Word HTML and RTF before the installed Plate format mappings decode the content.\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { WordPastePlugin } from \"platejs/docx/paste\";\nimport { createEditor } from \"platejs/react\";\n\nconst editor = createEditor({\n plugins: [WordPastePlugin],\n});\n```\n\nClipboard paste does not upload embedded images or preserve Word table geometry. Configure those destination policies in their owning media and table features.\n\n## Import a DOCX file\n\n`importDocx` reads a `Blob` or `ArrayBuffer` without modifying the editor. On success, replace the complete editor value so document metadata, named roots, and authored review state stay coherent.\n\nInside a mounted React control, get that complete editor with\n`useModelEditor()`. `useEditor()` returns the provider-selected mounted command\nview, whose root and authored projection belong to that view.\n\n```bash\nnpm install platejs @tanstack/react-virtual mammoth validator\n```\n\n```tsx title=\"components/editor/import-docx.tsx\"\nimport { importDocx } from \"platejs/docx/import\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n});\n\nexport async function importFile(file: File) {\n const result = await importDocx(file, {\n plugins: BaseEditorKit,\n });\n\n if (!result.ok) {\n return { diagnostics: result.diagnostics };\n }\n\n editor.update.value.replace(result.document);\n\n return {\n comments: result.comments,\n diagnostics: result.diagnostics,\n };\n}\n```\n\nAn expected file, package, limit, or decode failure returns `{ ok: false, diagnostics }` and no document. An aborted import rejects with the signal reason. Invalid options and internal invariant failures throw.\n\n### Retain the source for later export\n\nSet `retainSource: true` when the same editor imports, edits, and exports a Word file. A successful import then includes `source`. It is a `DocxSource` when exact export can return the package unchanged. Otherwise it is `null`, with a `source-unavailable` warning that names the first part exact export cannot admit. Keep the source in application state, pass it to `exportDocx`, and dispose it when the document closes or another file replaces it.\n\n```tsx title=\"components/editor/docx-roundtrip.tsx\" showLineNumbers\nimport { exportDocx } from \"platejs/docx/export\";\nimport { importDocx } from \"platejs/docx/import\";\n\nconst imported = await importDocx(file, {\n plugins: BaseEditorKit,\n retainSource: true,\n});\n\nif (!imported.ok) return imported.diagnostics;\n\neditor.update.value.replace(imported.document);\n\nconst exported = await exportDocx(editor, {\n projection: \"review\",\n source: imported.source,\n});\n\nimported.source?.dispose();\n```\n\nExact export admits a closed set of passive package content:\n\n- the main document at `word/document.xml`, styles, settings, numbering, fonts, and theme;\n- headers, footers, footnotes, endnotes, and comments;\n- core, extended, and custom document properties and a thumbnail;\n- AVIF, BMP, GIF, JPEG, PNG, and WebP images reached through internal relationships;\n- hyperlinks whose destination is absolute and meets the navigation floor.\n\n`PAGE` is the only recognized field instruction. Any other part, relationship, markup namespace, field instruction, or XML processing instruction leaves `source` null. That includes unsafe or relative hyperlinks, other external relationships, macros, ActiveX controls, OLE objects, imported HTML chunks, external templates, custom XML data, and SVG or EMF images. A null source does not mean the import lost content; exporting without a source generates the document from editor content.\n\n`DocxSource` retains the admitted compressed package plus immutable document, comment, schema, and limit correspondence. It does not retain expanded ZIP entries or an editor reference. `dispose()` is idempotent. An export that already acquired the source can finish after disposal; later exports report `source-unavailable` and generate a fresh DOCX.\n\n### Import comments\n\nThe importer returns comment format facts without writing app comment storage. Each `DocxComment` can include a rich Plate `body`, an exact `target.range` in proposed-projection coordinates, Word author and date metadata, a durable ID, a parent ID, and resolved state. Map those records to your app identities explicitly.\n\n### Import tracked revisions\n\nSupported Word insertions, deletions, moves, run property changes, and paragraph property changes become authored changes. Revision IDs follow Word source order and structural dependencies; dates remain metadata. A missing or invalid date uses the authored unknown-time value and produces a diagnostic.\n\nIf a revision cannot map to the installed schema, the importer keeps the Word-visible proposed content and reports the loss in `diagnostics`.\n\n### Import list numbering\n\nNumbered and bulleted Word paragraphs become list items, and each item joins the list of its Word numbering definition. Word counts every instance of a definition in one sequence per level. A list therefore continues past other paragraphs and across instances, and lists of different definitions that interleave keep their own numbers. A level restarts after a paragraph at the level its `lvlRestart` names or a shallower one, which by default is the level just above it. An instance's start override resets its level once, at the instance's first paragraph at that level. That item imports with the override as `listRestart`, and the items after it count on from it, so an inserted item renumbers the rest the way Word does. Each item's `listLevels` hold its definition's level starts, `lvlRestart` rules and level text, with its instance's format and level text overrides applied. Word ignores a start or `lvlRestart` inside a level override, and so does the import. A paragraph whose instance shows its level as bullets imports as a bulleted item and does not count.\n\n## Export a DOCX file\n\n`exportDocx` captures the editor once before asynchronous rendering. Pass the visible projection explicitly.\n\n```bash\nnpm install platejs @tanstack/react-virtual color-name html-to-vdom jszip juice mime-types virtual-dom xmlbuilder2\n```\n\n```tsx title=\"components/editor/export-docx.tsx\"\nimport { exportDocx } from \"platejs/docx/export\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\nimport { DOCX_EXPORT_STYLES } from \"@/components/editor/docx-export\";\n\nconst editor = createEditor({ plugins: BaseEditorKit });\n\nexport async function downloadEditorDocx() {\n const result = await exportDocx(editor, {\n orientation: \"portrait\",\n projection: \"proposed\",\n stylesheet: DOCX_EXPORT_STYLES,\n title: \"Document\",\n });\n\n if (!result.ok) return result.diagnostics;\n\n const url = URL.createObjectURL(result.blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"document.docx\";\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n\n return result.diagnostics;\n}\n```\n\nChoose `accepted` to omit pending changes, `proposed` to apply them, or `review` to write Word tracked-change markup. Accepted and proposed exports warn about omitted pending changes and conflicts resolved to one side. A review export with any conflict returns `ok: false` and an `authored-conflict` error because Word revisions cannot represent that state faithfully.\n\nA review file contains what Word displays: text, comments, and tracked revisions with their authors. Importing it restores those revisions as authored changes. Export does not write named roots, document metadata, or exact authored change records, such as their original IDs, to the file. To keep them, save the authored JSON document beside the file, as [Serializing](/docs/plate/serializing) describes:\n\n```tsx\nimport { parseAuthoredDocument, projectAuthoredReview } from \"platejs/authored\";\n\nconst json = JSON.stringify(projectAuthoredReview(editor.read.value()).review);\nconst document = parseAuthoredDocument(json);\n```\n\n### Content loss\n\n`exportDocx` rejects content loss by default. When an image cannot be embedded, a supplied comment has no representable range, or a node has no drawing in `presentation`, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: \"allow\"` to download the file and receive the same loss as warnings:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n lossPolicy: \"allow\",\n projection: \"review\",\n});\n```\n\nNamed roots, document metadata, removed link destinations, and other properties DOCX cannot represent are warnings under both policies. An `accepted` export omits a comment whose annotated content that projection excludes, and reports a warning, as Word does when the annotated suggestion is rejected.\n\n### Lists\n\nEach Plate list becomes one Word list. Its `listLevels` give the list's level starts, `lvlRestart` rules and level text. An item with `listRestart`, or one whose number Word would not reach, gets a numbering instance of its own that overrides that level's start, as Word's Restart Numbering writes it. The items after it count on from it in the list's own instance. Word and a re-import then show the numbers the editor shows, and the restart stays on that item. Word draws one marker style per list level, so an item whose `listStyle` differs from the first item at its level keeps that first style and reports a `list-marker` warning under both policies. A Word list level is numbered or bulleted, as its first item is, and the exporter writes one set of level rules per list. An item of the other kind at its level is therefore not part of its list in Word, and an item whose `listLevels` differ from its list's first item takes that item's rules. Each reports a `list-structure` warning, and `lossPolicy: 'reject'` fails the export. Word has no start that applies only when an item opens its level, so an item with `listStart` exports its shown number and reports a `list-start` warning under both policies.\n\n### Links and images\n\nExport writes a link destination only when it is absolute and meets the navigation floor, such as `https:` or `mailto:`. It writes bookmark links such as `#introduction` as Word anchors. An unsafe destination, or a relative link that has no document base in a Word file, keeps its text without the destination.\n\nImages are embedded as PNG, JPEG, GIF, or BMP. Set `allowRemoteImages: true` to fetch HTTP(S) images during export. These images are omitted and leave their alt text:\n\n- an image with an unsafe, relative, or blob source;\n- a remote image when `allowRemoteImages` is off;\n- an image that cannot be fetched;\n- an image in any other format.\n\nBefore returning a generated file, export checks the written package against the same passive vocabulary that retained sources use. Output outside it is withheld with an `invalid-package` error. An unchanged source-aware export returns the retained file, which passed the same check when it was imported.\n\n### Source-aware export\n\nWith a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, `pageSize`, `stylesheet`, or `fontFamily` option. `component` and `presentation` do not apply on this path, because export regenerates nothing.\n\nIf an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting:\n\n- headers and footers from a document with one section, including their images and hyperlinks;\n- custom document properties.\n\nThe regenerated package has its own core document properties. It omits source body XML, body-only parts such as footnotes and body images, and headers and footers from a document with multiple sections. It also omits the source core and extended properties and the package thumbnail: the core properties, such as the author, title and creation date, the extended properties, which count pages and words and hold details such as the company name, and the thumbnail, which pictures the body. Diagnostics report why the source was rewritten and identify each omitted source part. A custom property linked to body text keeps the value cached in the source file, even when the edit changed that text.\n\nWhen regeneration is required, omitted `comments` means that no comments are generated. Pass the current app-owned comment set explicitly to retain comments after an edit. Imported comment ranges are not relocated automatically.\n\n### Export comments\n\nPass comments whose ranges address the editor's proposed projection. Export maps those ranges into accepted or review output before rendering:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n projection: \"review\",\n});\n```\n\nThe same configured static renderer handles the main document and rich comment bodies. Under the default `lossPolicy`, a comment without a complete range fails the export; with `lossPolicy: \"allow\"` the export omits it with a warning.\n\n### Static rendering and styles\n\nDOCX owns its semantic static mappings for code blocks, columns, equations,\ncallouts, headings, and tables of contents. They set no color, font, or font\nsize beyond an element's own values, such as a callout's color. The app's\n`stylesheet` sets them and can select:\n\n- `editor-`, the class on each drawn element, such as `.editor-codeBlock`;\n- `table > tbody > tr > td` inside a callout, whose first cell holds the icon;\n- `[data-docx-preserve-whitespace]`, the text of a code block;\n- `[data-empty]`, the placeholder of an empty equation or table of contents.\n\nThe app may pass a custom root `component` when it needs a different wrapper.\n`DOCX_EXPORT_STYLES` is the registry's copied presentation preset.\n\nEquations export as Word math built from KaTeX's MathML. An equation that uses\nnotation the converter does not cover, such as a matrix, exports its TeX as\ntext inside a Word equation. A task list item, an `li` with `data-checked`, exports with a ☑ or ☐\nlist marker.\n\nA live editor's components are editing UI. Pass the app's static kit as\n`presentation` so the body and every comment body draw with static components\nof the same plugin name. A plugin with no static version exports its content\nwithout its component or wrapper and reports a `missing-static-presentation`\ndiagnostic, which counts as lost content under `lossPolicy`.\n\n\n\n## Format support\n\n| Content | Behavior |\n| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n| Paragraphs, headings, supported marks and properties | Decode through the installed HTML mappings and export through the configured static renderer |\n| Lists, links, bookmarks, rows, cells, and spans | Preserve supported semantic structure; omit Word layout details with diagnostics |\n| Tracked revisions | Preserve supported insert, delete, move, run-property, and paragraph-property changes |\n| Main-body comments | Preserve rich bodies, ranges, author/date metadata, durable IDs, replies, and resolved state when present |\n| Embedded images during import | Omit the resource and return a `resource-omitted` diagnostic |\n| Links and images during export | Write absolute navigation-safe links and PNG, JPEG, GIF, or BMP images; report removed destinations and omitted images |\n| Single-section headers and footers | Keep safe closed source subgraphs during source-aware export; do not map them into the editor document |\n| Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics |\n| Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content |\n| Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON |\n| Equations | Export as Word math, or as TeX inside a Word equation when the converter does not cover the notation. Import reads Word math into `equation` and `inlineEquation` nodes, with TeX rebuilt from the equation's structure, or into TeX text when those plugins are not installed. A display equation that shares its paragraph with a comment, tracked change or text, or sits in a list item or an indented paragraph, imports inline |\n| Task lists | Give an item drawn as an `li` with `data-checked` a ☑ or ☐ list marker |\n| Custom Plate, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics |\n\n## API reference\n\n### Entrypoints\n\n| Surface | Import path |\n| ------------------------------- | --------------------- |\n| `WordPastePlugin` | `platejs/docx/paste` |\n| `importDocx` and import types | `platejs/docx/import` |\n| `exportDocx` and export types | `platejs/docx/export` |\n\n### `importDocx`\n\n```ts\nimportDocx(\n source: ArrayBuffer | Blob,\n options: DocxImportOptions\n): Promise\n```\n\n`DocxImportOptions` requires `plugins` and accepts `schema`, a cooperative\n`signal`, `lossPolicy`, partial `limits` overrides, and\n`retainSource`. Import captures that detached target before its first await and\nnever reads a live editor or mutable plugin store. Every limit must be a\npositive safe integer. Literal `retainSource: true` conditionally adds\n`source: DocxSource | null` to a successful result; omitted or literal `false`\ndoes not.\n\n| Default limit | Value |\n| ------------------ | ------: |\n| `maxInputBytes` | 32 MiB |\n| `maxEntries` | 1,024 |\n| `maxEntryBytes` | 32 MiB |\n| `maxExpandedBytes` | 128 MiB |\n| `maxRelationships` | 4,096 |\n| `maxRevisions` | 2,000 |\n| `maxComments` | 5,000 |\n| `maxXmlDepth` | 128 |\n| `maxXmlNodes` | 500,000 |\n\nA successful result contains `document`, `comments`, and structured `diagnostics`. A retained success also contains `source`, which is `null` when exact export cannot admit the package. A failed result contains only `diagnostics`.\n\n### `DocxSource`\n\n```ts\nclass DocxSource {\n private constructor();\n dispose(): void;\n}\n```\n\nOnly `importDocx` creates a `DocxSource`. The object is process-local and cannot be serialized or reattached. To restore source-aware export after application reload, keep the original file and import it again.\n\n### `exportDocx`\n\n```ts\nexportDocx(\n editor: Editor,\n options: DocxExportOptions\n): Promise\n```\n\n| Option | Type | Description |\n| ----------------------- | ---------------------------------------- | -------------------------------------------------- |\n| `projection` | `'accepted' \\| 'proposed' \\| 'review'` | Required visible document projection |\n| `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates |\n| `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` |\n| `component` | `React.ComponentType` | Optional static editor wrapper |\n| `fontFamily` | `string` | Document body font; rebuilds an unchanged `source` |\n| `lossPolicy` | `'allow' \\| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` |\n| `margins` | `Margins` | Page margins in twentieths of a point |\n| `orientation` | `'landscape' \\| 'portrait'` | Page orientation |\n| `pageSize` | `PageSize` | Page dimensions in twentieths of a point |\n| `presentation` | `readonly BasePluginInput[]` | Static plugins that draw the body and comment bodies, matched by plugin name |\n| `signal` | `AbortSignal` | Cooperative cancellation signal |\n| `source` | `DocxSource \\| null` | Retained source correspondence for exact or safe export |\n| `stylesheet` | `string` | CSS that sets the Word look; rebuilds an unchanged `source` |\n| `title` | `string` | Document metadata title |\n\nA successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`.\n\nSee [Authored Changes](/docs/plate/authored-changes) for the editor's accepted, proposed, and review projections.\n", "type": "registry:file", "target": "content/docs/plate/(plugins)/(serializing)/docx.mdx" } @@ -1898,7 +1898,7 @@ "files": [ { "path": "../../content/docs/examples/export.mdx", - "content": "---\ntitle: Export\ndescription: Export a Plate document to HTML, Markdown, or Word.\ndocs:\n - route: https://pro.platejs.org/docs/examples/export\n title: Plus\n---\n\nThe registry `export-toolbar-button` downloads the current document as HTML,\nMarkdown, or Word from the browser. The demo mounts it in the fixed toolbar.\n\n## Demo\n\n\n\n## Toolbar source\n\nInstall the [Export Toolbar Button](/docs/plate/components/export-toolbar-button)\ncomponent to add the menu to an editor toolbar.\n\n\n\n## Export formats\n\n| Format | Implementation | Output |\n| -------- | -------------- | ------ |\n| HTML | `renderStaticHtml(model, { component: EditorStatic, projection })` from `platejs/static` | `plate.html` |\n| Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` |\n| Word | `exportDocx(model, { component: EditorStatic, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` |\n\nEvery format captures the same complete document and explicit authored\nprojection from `useModelEditor()`. Export reads model content rather than the\nDOM, so selection, placeholders, and review decorations do not enter the file.\nThe current editor supplies its compiled schema, format mappings, and static component\nbindings; export does not create a second conversion editor.\n\nThe HTML file inlines the app's own stylesheets and copies the page's `body`\nclasses. Cross-origin stylesheets stay as links.\n\n## Suggestions\n\nWhen the document has unresolved [suggestions](/docs/plate/suggestion), the menu shows\nan **Unresolved suggestions** choice:\n\n| Choice | Projection | Result |\n| ------------------------- | ---------- | ------------------------------------------- |\n| Include suggested changes | `proposed` | The document with pending suggestions applied |\n| Exclude suggested changes | `accepted` | The document without pending suggestions |\n\nThe initial choice follows the mounted view. HTML, Markdown, and Word use the\nsame choice. **Export as Word with tracked changes** uses `review`, which writes\npending suggestions as Word revisions. A conflict has no faithful Word\nrevision, so review export returns an error until the conflict is resolved.\n\nWithout unresolved suggestions, Word export uses `review`. A retained,\nunchanged imported DOCX can then be returned byte for byte. Each exporter\nreturns diagnostics, which the menu presents as warning or error toasts.\n\n## DOCX presentation\n\nDOCX owns semantic mappings for Plate code blocks, columns, equations,\ncallouts, headings, and tables of contents. The registry keeps only the copied\nvisual stylesheet:\n\n\n\n## Plus export\n\nPlate Plus includes a server-side export flow for PDF output with page settings.\n\n\n\n## Related\n\n- [DOCX](/docs/plate/docx) covers DOCX paste, file import, and export APIs.\n- [Markdown](/docs/plate/markdown) covers Markdown serialization.\n- [Static Rendering](/docs/plate/static) covers static editor rendering and HTML serialization.\n- [Authored Changes](/docs/plate/authored-changes) covers visible HTML/Markdown projections, authored JSON, and DOCX review.\n", + "content": "---\ntitle: Export\ndescription: Export a Plate document to HTML, Markdown, or Word.\ndocs:\n - route: https://pro.platejs.org/docs/examples/export\n title: Plus\n---\n\nThe registry `export-toolbar-button` downloads the current document as HTML,\nMarkdown, or Word from the browser. The demo mounts it in the fixed toolbar.\n\n## Demo\n\n\n\n## Toolbar source\n\nInstall the [Export Toolbar Button](/docs/plate/components/export-toolbar-button)\ncomponent to add the menu to an editor toolbar.\n\n\n\n## Export formats\n\n| Format | Implementation | Output |\n| -------- | -------------- | ------ |\n| HTML | `renderStaticHtml(model, { component: EditorStatic, presentation: BaseEditorKit, projection })` from `platejs/static` | `plate.html` |\n| Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` |\n| Word | `exportDocx(model, { component: EditorStatic, presentation: BaseEditorKit, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` |\n\nEvery format captures the same complete document and explicit authored\nprojection from `useModelEditor()`. Export reads model content rather than the\nDOM, so selection, placeholders, and review decorations do not enter the file.\nThe current editor supplies its compiled schema, format mappings, and plugin\nsettings; the static kit supplies only the drawings.\n\nThe editor's own components are editing UI, so HTML and Word pass the app's\nstatic kit as `presentation`. Export draws each node with the static component\nof the same plugin name. When a plugin has no static component, export draws its\ncontent without one and adds a `missing-static-presentation` warning.\n\nThe HTML file inlines the app's own stylesheets and copies the page's `body`\nclasses. Cross-origin stylesheets stay as links.\n\n## Suggestions\n\nWhen the document has unresolved [suggestions](/docs/plate/suggestion), the menu shows\nan **Unresolved suggestions** choice:\n\n| Choice | Projection | Result |\n| ------------------------- | ---------- | ------------------------------------------- |\n| Include suggested changes | `proposed` | The document with pending suggestions applied |\n| Exclude suggested changes | `accepted` | The document without pending suggestions |\n\nThe initial choice follows the mounted view. HTML, Markdown, and Word use the\nsame choice. **Export as Word with tracked changes** uses `review`, which writes\npending suggestions as Word revisions. A conflict has no faithful Word\nrevision, so review export returns an error until the conflict is resolved.\n\nWithout unresolved suggestions, Word export uses `review`. A retained,\nunchanged imported DOCX can then be returned byte for byte. Each exporter\nreturns diagnostics, which the menu presents as warning or error toasts.\n\n## DOCX presentation\n\nDOCX owns semantic mappings for Plate code blocks, columns, equations,\ncallouts, headings, and tables of contents. The registry keeps only the copied\nvisual stylesheet:\n\n\n\n## Plus export\n\nPlate Plus includes a server-side export flow for PDF output with page settings.\n\n\n\n## Related\n\n- [DOCX](/docs/plate/docx) covers DOCX paste, file import, and export APIs.\n- [Markdown](/docs/plate/markdown) covers Markdown serialization.\n- [Static Rendering](/docs/plate/static) covers static editor rendering and HTML serialization.\n- [Authored Changes](/docs/plate/authored-changes) covers visible HTML/Markdown projections, authored JSON, and DOCX review.\n", "type": "registry:file", "target": "content/docs/plate/examples/export.mdx" } diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 9ad8cda160..d355de5672 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -302,10 +302,10 @@ ], "registryDependencies": [ "https://platejs.org/r/editor.json", - "https://platejs.org/r/editor-plugins-static.json", "https://platejs.org/r/docx-export.json", "https://platejs.org/r/docx-source.json", "https://platejs.org/r/editor-dropdown-menu.json", + "https://platejs.org/r/editor-plugins-static.json", "https://platejs.org/r/editor-static.json", "https://platejs.org/r/toolbar.json" ], @@ -5261,7 +5261,7 @@ "files": [ { "path": "../../content/docs/(guides)/authored-changes.mdx", - "content": "---\ntitle: Authored Changes\ndescription: Capture attributed edits, review proposals, retain author history, and export explicit projections.\ndocs:\n - route: /docs/suggestion\n title: Suggestions\n - route: /docs/comment\n title: Comments\n - route: /docs/yjs\n title: Collaboration\n---\n\nAuthored changes keep accepted content, proposals, decisions, and stable change identities in one saved document. Use [Suggestions](/docs/plate/suggestion) for the common editing and suggesting modes. This guide covers the underlying records, advanced views, decisions, retained history, and formats.\n\n## Choose the right surface\n\n| Need | Surface |\n| ------------------------------------------------- | ----------------------------------------------------------------------- |\n| Switch between editing and suggesting | `SuggestionPlugin` from `platejs/suggestion/react` |\n| Add copied highlighting and review cards | [SuggestionKit](/docs/plate/suggestion) and [DiscussionKit](/docs/plate/discussion) |\n| Record changes as the editor's user | `AuthoredPlugin` from `platejs/authored` |\n| Retain accepted history for revert | `AuthoredPlugin.configure({ initialState: { retainHistory: true } })` |\n| Read, decide, resolve, or revert changes | The installed authored plugin portal |\n| Attach a conversation | `CommentsPlugin` with a `{ type: 'change', id }` target |\n\nSuggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.\n\n## Install authored changes\n\n`AuthoredPlugin` resolves the author from the Plate editor's `userId` once per transaction:\n\n```tsx title=\"editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst editor = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: [\n { type: \"paragraph\", children: [{ text: \"Review this sentence.\" }] },\n ],\n userId: \"alice\",\n});\n\nconst authored = editor.plugin(AuthoredPlugin);\n```\n\nAn editor without a `userId` writes as the local user, `'local'`, so a solo\neditor needs no setup, AI suggestions included. Pass an authenticated, stable\nuser ID whenever more than one person writes the document.\n\nTo retain accepted history, configure the same descriptor. The editor reads\n`retainHistory` once, when it is created:\n\n```tsx title=\"history-editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst historyEditor = createEditor({\n plugins: [\n BaseParagraphPlugin,\n AuthoredPlugin.configure({ initialState: { retainHistory: true } }),\n ],\n initialValue,\n userId: session.user.id,\n});\n\nconst retainedAuthored = historyEditor.plugin(AuthoredPlugin);\n```\n\n`retainHistory` keeps closed edit bodies needed for selective revert. Omit it when the application only needs pending review and attribution.\n\n## Capture changes\n\nAn editing view records ordinary updates as accepted contributions. Begin an explicit proposal inside the transaction before applying its document mutation:\n\n```tsx\nlet changeId = \"\";\n\neditor.update((tx) => {\n changeId = tx.authored.propose();\n tx.text.insert(\" reviewed\", {\n at: { offset: 8, path: [0, 0] },\n });\n});\n```\n\nPass `{ changeId }` to assign an identity to a new proposal or amend a pending contribution owned by the current author. The current author must own an existing change's latest head.\n\n## Import a compared revision\n\nCompare a fixed draft with the accepted document, then publish its canonical change as one native proposal:\n\n```tsx\nimport { proposeAuthoredComparison } from \"platejs/authored\";\nimport { compare } from \"platejs/diff\";\n\nconst incomingDraft = [\n { type: \"paragraph\", children: [{ text: \"Revised sentence.\" }] },\n];\nconst comparison = await compare({\n before: editor.read.value(),\n after: incomingDraft,\n schema: editor.read.schema,\n});\nconst imported = proposeAuthoredComparison(editor, { comparison });\n```\n\n`groupIds` selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle `stale`, `blocked`, `invalid`, and `unavailable` without treating them as applied proposals. A resolved three-way comparison can use the same call when its selected baseline is the live accepted document.\n\n## Configure a view\n\nSet the starting intent and projection on `EditorRoot`:\n\n```tsx\nimport { AuthoredPlugin } from \"platejs/authored\";\nimport { EditorContent, EditorRoot, useCreateEditor } from \"platejs/react\";\n\nexport function SuggestionEditor() {\n const editor = useCreateEditor({\n plugins: [AuthoredPlugin],\n userId: \"alice\",\n });\n\n return (\n \n \n \n );\n}\n```\n\nThe editor must install an authored plugin, either directly or through `SuggestionPlugin`. Changing the `intent` or `projection` prop updates this view. Rerendering with equivalent values preserves subsequent mode changes made through the mounted view's portal.\n\nFor an interactive mode control, let `SuggestionPlugin` change the intent while preserving the current projection:\n\n```tsx\nimport { SuggestionPlugin } from \"platejs/suggestion/react\";\n\neditor.plugin(SuggestionPlugin).api.setMode(\"suggesting\");\n```\n\nUse the authored portal for advanced projections:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\n\nauthored.api.setView({\n intent: \"propose\",\n projection: \"proposed\",\n});\n```\n\n| Projection | Visible content |\n| ---------- | ----------------------------------------------------------- |\n| `accepted` | Accepted content without pending proposals |\n| `proposed` | The proposed result without retained review fragments |\n| `markup` | Proposed content with retained deletions and move fragments |\n\nUse `intent: 'edit'` with any projection. An editing markup or proposed view keeps pending content visible while edits to independent accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes directly in the same undoable transaction. Partial retained or dependency-spanning targets stay protected; other dependent edits remain proposals with the current author. Use `intent: 'propose'` with `proposed` or `markup` when ordinary input must create proposals.\n\nIntent and projection belong to the exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.\n\n## Review changes\n\nRead a page of pending changes, then capture a revision-bound selection for the decision:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\nconst pending = authored.read.changes({\n authorId: \"alice\",\n limit: 50,\n status: \"pending\",\n});\nconst selection = authored.read.select({\n ids: pending.items.map(({ id }) => id),\n});\nconst result = authored.update.decide({\n action: \"accept\",\n selection,\n});\n```\n\nUse `action: 'reject'` to reject changes. The example selects only the returned page. Pass a non-null page `cursor` to `changes(...)` to read more, or call `select({ authorId: 'alice', status: 'pending' })` to select every current match.\n\n`AuthoredSelection` captures the document ID, change revisions, and current heads. A later amendment makes an old selection `stale`. Dependencies and concurrent conflicts return `blocked` without partially applying the batch.\n\nFor spatial controls, use `change(id)` to read one record and `changesAt(range)` to find records intersecting a text range. Each `AuthoredChange` includes its author, status, kind, dependencies, revision, and current ranges.\n\nRead semantic detail when the reviewer opens one change:\n\n```tsx\nconst details = authored.read.details(changeId);\n\nif (details?.parts.status === \"available\") {\n for (const part of details.parts.items) {\n // Render content, boundary, property, or root facts in review UI.\n }\n}\n```\n\n`details(id)` returns the compact change summary, decision history, and immutable semantic parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Property parts preserve exact before and after values. Root parts describe root creation or deletion.\n\nClosed changes without retained edit bodies return `parts.status: 'unavailable'` with `reason: 'retention'`; their summary and reviews remain readable. Keep list and spatial reads compact, and request details only for the change being reviewed.\n\n### Decision results\n\nAlways inspect the result of `decide`, `resolve`, or `revert`:\n\n| Status | Meaning |\n| ------------- | ------------------------------------------------------------------------------------------------ |\n| `applied` | The operation applied to the returned IDs. |\n| `unchanged` | No change was needed. |\n| `stale` | The selected revisions or heads changed. Read the records again before retrying. |\n| `blocked` | The selection has unresolved dependencies, dependants, or conflicts. Inspect the returned lists. |\n| `invalid` | The selection or document identity is invalid. |\n| `unavailable` | A revert needs content that is no longer retained. |\n\n`authored.read.preview({ action, selection })` checks an accept or reject decision without applying it. An `applied` preview means the decision can apply at that moment; the later `decide` call still needs result handling.\n\nAccepting a change can require its dependencies. Rejecting one can require its dependants. The editor never expands the selection silently. Call `authored.update.resolve({ action, selection })` only after the reviewer chooses a decision for the complete conflict component.\n\n## Retain author history\n\nEnable `retainHistory` before recording work whose content must remain available for selective revert. Loading a compacted document with retention enabled cannot recover discarded edit bodies.\n\nSelect an author's accepted contributions and create a compensating change:\n\n```tsx\nconst selection = retainedAuthored.read.select({\n authorId: \"alice\",\n status: \"accepted\",\n});\nconst result = retainedAuthored.update.revert({ selection });\n```\n\nThe original records keep their authors. The compensation belongs to the current author. A revert can return `blocked` when later dependent work prevents compensation, or `unavailable` when the required content is absent.\n\nPlate History reverses local interaction batches with `editor.api.history.undo()` and `redo()`. Local undo also restores mapped selection. Selective revert records another authored contribution. The [Version History example](/docs/plate/examples/version-history) lets you edit as two authors and revert one retained contribution.\n\n## Save the document\n\nSave the complete result of `editor.read.value()`. It contains accepted content and the `meta.authored` records needed to reconstruct proposals and decisions. Saving only the node array loses those records.\n\n```tsx\nconst savedDocument = editor.read.value();\n\nconst restored = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: savedDocument,\n userId: \"alice\",\n});\n```\n\nUse the same content plugins and schema when loading a revision into a fresh editor. Loading validates the authored records and document schema before publishing the value. Treat `meta.authored` as codec-owned data. Local undo starts empty.\n\nIf you install [Comments](/docs/plate/comment), save `editor.plugin(CommentsPlugin).api.toJSON()` atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved `CommentsJSON` to `CommentsPlugin.configure({ initialState: { initialComments } })` in the fresh editor. Live thread targets contain semantic identities; `attachment(id)` reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.\n\nHistorical previews and restores retain current conversations and use only the selected revision's proven targets. See [Comments and versions](/docs/plate/comment#document-history-and-versions).\n\n## Export explicit projections\n\nChoose `accepted` or `proposed` for a clean document, or `review` when the\nformat can preserve review records. Clean projections report diagnostics when\nthey omit pending work or resolve a conflict to one side. Every helper accepts\nor captures a complete document, including named roots and metadata.\n\n### JSON\n\nUse the pure authored projection helpers for detached documents:\n\n```tsx\nimport {\n parseAuthoredDocument,\n projectAuthoredDocument,\n projectAuthoredReview,\n} from \"platejs/authored\";\n\nconst source = editor.read.value();\nconst accepted = projectAuthoredDocument(source, {\n projection: \"accepted\",\n});\nconst review = projectAuthoredReview(source);\nconst json = JSON.stringify(review.review);\nconst document = parseAuthoredDocument(json);\n```\n\n`parseAuthoredDocument` validates the document envelope. Loading it into an\neditor applies the installed application schema and authored-record checks.\n\n### Plain text and Markdown\n\n```tsx\nimport { serializePlainText } from \"platejs\";\n\nconst text = serializePlainText(editor, { projection: \"accepted\" });\nconst markdown = editor.api.markdown.serialize({ projection: \"proposed\" });\n\nif (markdown.ok) downloadMarkdown(markdown.data);\n```\n\nPlain text and Markdown carry visible content only. Feature-owned format\nmappings preserve the structures they can represent and report omitted roots,\nmetadata, pending work, and unsupported nodes. Markdown does not contain a\nhidden Plate document and cannot restore exact review state.\n\n### HTML and DOCX\n\n```tsx\nimport { exportDocx } from \"platejs/docx/export\";\nimport { renderStaticHtml } from \"platejs/static\";\n\nconst semanticHtml = editor.api.html.serialize({ projection: \"proposed\" });\nconst styledHtml = await renderStaticHtml(editor, { projection: \"proposed\" });\nconst docx = await exportDocx(editor, { projection: \"review\" });\n```\n\n`renderStaticHtml` uses the current editor's static components. Semantic HTML\nuses feature mappings and no application styling. Both HTML forms carry visible\naccepted or proposed content only. A successful DOCX result returns a `Blob` in\n`docx.blob`. Every serializer returns structured `diagnostics`.\n\n| Format | Accepted or proposed | Review |\n| ------ | -------------------- | ------ |\n| JSON | Plain projected document | Canonical authored document |\n| Plain text | Structural text | Not supported |\n| Markdown | Plain Markdown | Not supported; save authored JSON |\n| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |\n| Static HTML | Component-rendered HTML | Not supported; save authored JSON |\n| DOCX | Projected Word document | Word revisions; save authored JSON for exact state |\n\nDOCX review export reports `lossy-content` or `unsupported-content` when Word\ncannot express a native construct. Save authored JSON beside the file to keep\nthat construct exactly.\n\n## Collaboration\n\nInstall the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.\n\nThe Yjs adapter applies authored operations and decisions in one remote transaction. Its checkpoints preserve pending work and each peer's retained content. Retention policy is local: an archival peer can retain closed bodies while another peer compacts them.\n\nSee [Collaboration](/docs/plate/yjs) for setup and compaction ownership.\n\n## API Reference\n\n### `AuthoredPlugin`\n\nImport the standard descriptor from `platejs/authored`. It records each change as the Plate editor's `userId`, or as the local user, `'local'`, when the editor has none. `SuggestionPlugin` and `AIChatPlugin` depend on this descriptor.\n\n| Option | Type | Purpose |\n| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |\n| `initialState.retainHistory` | `boolean` | Keep closed edit bodies for selective revert. The editor reads it once, when it is created. Defaults to `false`. |\n\n### Transaction, reads, and updates\n\nAfter `const authored = editor.plugin(AuthoredPlugin)`, use these methods:\n\n| Surface | Purpose |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `tx.authored.propose(options?)` | Start a proposal, assign a new `{ changeId }`, or amend an owned pending change. |\n| `authored.read.canPropose()` | Report whether the current identity can author proposals; always true with `AuthoredPlugin`. |\n| `authored.read.view()` | Read this view's intent and projection. |\n| `authored.read.change(id)` | Read one logical change, or `null`. |\n| `authored.read.details(id)` | Read one change with semantic parts and decision history, or `null`. |\n| `authored.read.changes(query?)` | Read a page filtered by author, status, or time. |\n| `authored.read.changesAt(range)` | Read changes intersecting a text range. |\n| `authored.read.select(query)` | Capture a revision-bound selection for a decision. |\n| `authored.read.preview(decision)` | Check an accept or reject decision without applying it. |\n| `authored.update.decide(decision)` | Accept or reject the selected changes atomically. |\n| `authored.update.resolve(decision)` | Decide a complete conflict component explicitly. |\n| `authored.update.revert({ selection })` | Compensate retained accepted contributions. |\n| `authored.api.setView(view)` | Change the policy on this exact view. |\n| `authored.api.subscribeChanges(listener)` | Subscribe to immutable affected change IDs and decoration node keys for this view. |\n\n`subscribeChanges` is a low-level integration hook for semantic decorators. Suggestion applications use `useSuggestionChanges(path)` instead.\n\n### Types and format helpers\n\n`platejs/authored` exports the authored plugin descriptors, change and decision\ntypes, immutable projection results and diagnostics, and\n`createAuthoredReviewDocument` for trusted cumulative imported revisions.\n\n`projectAuthoredDocument(document, { projection })` returns one accepted or\nproposed `document`, the exact captured `review` document, diagnostics, and\nseparate `unresolved.pending` and `unresolved.conflicted` counts.\n`projectAuthoredReview(document)` also exposes stable visible markup segments\nand property counterparts. Both helpers are pure document operations: they do\nnot construct, activate, or subscribe an editor runtime.\n\n`projectAuthoredRange(projection, range)` maps proposed coordinates into the\naccepted document captured by the same projection. `parseAuthoredDocument`\nparses a JSON document envelope; the target editor remains responsible for its\napplication schema.\n", + "content": "---\ntitle: Authored Changes\ndescription: Capture attributed edits, review proposals, retain author history, and export explicit projections.\ndocs:\n - route: /docs/suggestion\n title: Suggestions\n - route: /docs/comment\n title: Comments\n - route: /docs/yjs\n title: Collaboration\n---\n\nAuthored changes keep accepted content, proposals, decisions, and stable change identities in one saved document. Use [Suggestions](/docs/plate/suggestion) for the common editing and suggesting modes. This guide covers the underlying records, advanced views, decisions, retained history, and formats.\n\n## Choose the right surface\n\n| Need | Surface |\n| ------------------------------------------------- | ----------------------------------------------------------------------- |\n| Switch between editing and suggesting | `SuggestionPlugin` from `platejs/suggestion/react` |\n| Add copied highlighting and review cards | [SuggestionKit](/docs/plate/suggestion) and [DiscussionKit](/docs/plate/discussion) |\n| Record changes as the editor's user | `AuthoredPlugin` from `platejs/authored` |\n| Retain accepted history for revert | `AuthoredPlugin.configure({ initialState: { retainHistory: true } })` |\n| Read, decide, resolve, or revert changes | The installed authored plugin portal |\n| Attach a conversation | `CommentsPlugin` with a `{ type: 'change', id }` target |\n\nSuggestion is a behavior and presentation layer over authored changes. It does not define another change record, decision result, or persistence format.\n\n## Install authored changes\n\n`AuthoredPlugin` resolves the author from the Plate editor's `userId` once per transaction:\n\n```tsx title=\"editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst editor = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: [\n { type: \"paragraph\", children: [{ text: \"Review this sentence.\" }] },\n ],\n userId: \"alice\",\n});\n\nconst authored = editor.plugin(AuthoredPlugin);\n```\n\nAn editor without a `userId` writes as the local user, `'local'`, so a solo\neditor needs no setup, AI suggestions included. Pass an authenticated, stable\nuser ID whenever more than one person writes the document.\n\nTo retain accepted history, configure the same descriptor. The editor reads\n`retainHistory` once, when it is created:\n\n```tsx title=\"history-editor.ts\" showLineNumbers\nimport { BaseParagraphPlugin, createEditor } from \"platejs\";\nimport { AuthoredPlugin } from \"platejs/authored\";\n\nconst historyEditor = createEditor({\n plugins: [\n BaseParagraphPlugin,\n AuthoredPlugin.configure({ initialState: { retainHistory: true } }),\n ],\n initialValue,\n userId: session.user.id,\n});\n\nconst retainedAuthored = historyEditor.plugin(AuthoredPlugin);\n```\n\n`retainHistory` keeps closed edit bodies needed for selective revert. Omit it when the application only needs pending review and attribution.\n\n## Capture changes\n\nAn editing view records ordinary updates as accepted contributions. Begin an explicit proposal inside the transaction before applying its document mutation:\n\n```tsx\nlet changeId = \"\";\n\neditor.update((tx) => {\n changeId = tx.authored.propose();\n tx.text.insert(\" reviewed\", {\n at: { offset: 8, path: [0, 0] },\n });\n});\n```\n\nPass `{ changeId }` to assign an identity to a new proposal or amend a pending contribution owned by the current author. The current author must own an existing change's latest head.\n\n## Import a compared revision\n\nCompare a fixed draft with the accepted document, then publish its canonical change as one native proposal:\n\n```tsx\nimport { proposeAuthoredComparison } from \"platejs/authored\";\nimport { compare } from \"platejs/diff\";\n\nconst incomingDraft = [\n { type: \"paragraph\", children: [{ text: \"Revised sentence.\" }] },\n];\nconst comparison = await compare({\n before: editor.read.value(),\n after: incomingDraft,\n schema: editor.read.schema,\n});\nconst imported = proposeAuthoredComparison(editor, { comparison });\n```\n\n`groupIds` selects comparison groups and includes their required dependencies. The result maps selected group IDs to the native change ID. Import checks the schema, accepted content, and authored frontier before writing; handle `stale`, `blocked`, `invalid`, and `unavailable` without treating them as applied proposals. A resolved three-way comparison can use the same call when its selected baseline is the live accepted document.\n\n## Configure a view\n\nSet the starting intent and projection on `EditorRoot`:\n\n```tsx\nimport { AuthoredPlugin } from \"platejs/authored\";\nimport { EditorContent, EditorRoot, useCreateEditor } from \"platejs/react\";\n\nexport function SuggestionEditor() {\n const editor = useCreateEditor({\n plugins: [AuthoredPlugin],\n userId: \"alice\",\n });\n\n return (\n \n \n
\n );\n}\n```\n\nThe editor must install an authored plugin, either directly or through `SuggestionPlugin`. Changing the `intent` or `projection` prop updates this view. Rerendering with equivalent values preserves subsequent mode changes made through the mounted view's portal.\n\nFor an interactive mode control, let `SuggestionPlugin` change the intent while preserving the current projection:\n\n```tsx\nimport { SuggestionPlugin } from \"platejs/suggestion/react\";\n\neditor.plugin(SuggestionPlugin).api.setMode(\"suggesting\");\n```\n\nUse the authored portal for advanced projections:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\n\nauthored.api.setView({\n intent: \"propose\",\n projection: \"proposed\",\n});\n```\n\n| Projection | Visible content |\n| ---------- | ----------------------------------------------------------- |\n| `accepted` | Accepted content without pending proposals |\n| `proposed` | The proposed result without retained review fragments |\n| `markup` | Proposed content with retained deletions and move fragments |\n\nUse `intent: 'edit'` with any projection. An editing markup or proposed view keeps pending content visible while edits to independent accepted content publish directly. A replacement that fully contains pending changes resolves them and publishes directly in the same undoable transaction. Partial retained or dependency-spanning targets stay protected; other dependent edits remain proposals with the current author. Use `intent: 'propose'` with `proposed` or `markup` when ordinary input must create proposals.\n\nIntent and projection belong to the exact view. Selection, clipboard input, commands, and anchors use that view's coordinates. Two mounted views over one document can use different policies.\n\n## Review changes\n\nRead a page of pending changes, then capture a revision-bound selection for the decision:\n\n```tsx\nconst authored = editor.plugin(AuthoredPlugin);\nconst pending = authored.read.changes({\n authorId: \"alice\",\n limit: 50,\n status: \"pending\",\n});\nconst selection = authored.read.select({\n ids: pending.items.map(({ id }) => id),\n});\nconst result = authored.update.decide({\n action: \"accept\",\n selection,\n});\n```\n\nUse `action: 'reject'` to reject changes. The example selects only the returned page. Pass a non-null page `cursor` to `changes(...)` to read more, or call `select({ authorId: 'alice', status: 'pending' })` to select every current match.\n\n`AuthoredSelection` captures the document ID, change revisions, and current heads. A later amendment makes an old selection `stale`. Dependencies and concurrent conflicts return `blocked` without partially applying the batch.\n\nFor spatial controls, use `change(id)` to read one record and `changesAt(range)` to find records intersecting a text range. Each `AuthoredChange` includes its author, status, kind, dependencies, revision, and current ranges.\n\nRead semantic detail when the reviewer opens one change:\n\n```tsx\nconst details = authored.read.details(changeId);\n\nif (details?.parts.status === \"available\") {\n for (const part of details.parts.items) {\n // Render content, boundary, property, or root facts in review UI.\n }\n}\n```\n\n`details(id)` returns the compact change summary, decision history, and immutable semantic parts. Content parts include before and after slices. Boundary parts distinguish paragraph splits from joins. Property parts preserve exact before and after values. Root parts describe root creation or deletion.\n\nClosed changes without retained edit bodies return `parts.status: 'unavailable'` with `reason: 'retention'`; their summary and reviews remain readable. Keep list and spatial reads compact, and request details only for the change being reviewed.\n\n### Decision results\n\nAlways inspect the result of `decide`, `resolve`, or `revert`:\n\n| Status | Meaning |\n| ------------- | ------------------------------------------------------------------------------------------------ |\n| `applied` | The operation applied to the returned IDs. |\n| `unchanged` | No change was needed. |\n| `stale` | The selected revisions or heads changed. Read the records again before retrying. |\n| `blocked` | The selection has unresolved dependencies, dependants, or conflicts. Inspect the returned lists. |\n| `invalid` | The selection or document identity is invalid. |\n| `unavailable` | A revert needs content that is no longer retained. |\n\n`authored.read.preview({ action, selection })` checks an accept or reject decision without applying it. An `applied` preview means the decision can apply at that moment; the later `decide` call still needs result handling.\n\nAccepting a change can require its dependencies. Rejecting one can require its dependants. The editor never expands the selection silently. Call `authored.update.resolve({ action, selection })` only after the reviewer chooses a decision for the complete conflict component.\n\n## Retain author history\n\nEnable `retainHistory` before recording work whose content must remain available for selective revert. Loading a compacted document with retention enabled cannot recover discarded edit bodies.\n\nSelect an author's accepted contributions and create a compensating change:\n\n```tsx\nconst selection = retainedAuthored.read.select({\n authorId: \"alice\",\n status: \"accepted\",\n});\nconst result = retainedAuthored.update.revert({ selection });\n```\n\nThe original records keep their authors. The compensation belongs to the current author. A revert can return `blocked` when later dependent work prevents compensation, or `unavailable` when the required content is absent.\n\nPlate History reverses local interaction batches with `editor.api.history.undo()` and `redo()`. Local undo also restores mapped selection. Selective revert records another authored contribution. The [Version History example](/docs/plate/examples/version-history) lets you edit as two authors and revert one retained contribution.\n\n## Save the document\n\nSave the complete result of `editor.read.value()`. It contains accepted content and the `meta.authored` records needed to reconstruct proposals and decisions. Saving only the node array loses those records.\n\n```tsx\nconst savedDocument = editor.read.value();\n\nconst restored = createEditor({\n plugins: [BaseParagraphPlugin, AuthoredPlugin],\n initialValue: savedDocument,\n userId: \"alice\",\n});\n```\n\nUse the same content plugins and schema when loading a revision into a fresh editor. Loading validates the authored records and document schema before publishing the value. Treat `meta.authored` as codec-owned data. Local undo starts empty.\n\nIf you install [Comments](/docs/plate/comment), save `editor.plugin(CommentsPlugin).api.toJSON()` atomically with the document and its application revision and conversation generation. Verify that association, then pass the saved `CommentsJSON` to `CommentsPlugin.configure({ initialState: { initialComments } })` in the fresh editor. Live thread targets contain semantic identities; `attachment(id)` reads their placement. Accepting, rejecting, and undoing a suggestion decision preserve its discussion. Resolving or reopening a thread stays outside document undo.\n\nHistorical previews and restores retain current conversations and use only the selected revision's proven targets. See [Comments and versions](/docs/plate/comment#document-history-and-versions).\n\n## Export explicit projections\n\nChoose `accepted` or `proposed` for a clean document, or `review` when the\nformat can preserve review records. Clean projections report diagnostics when\nthey omit pending work or resolve a conflict to one side. Every helper accepts\nor captures a complete document, including named roots and metadata.\n\n### JSON\n\nUse the pure authored projection helpers for detached documents:\n\n```tsx\nimport {\n parseAuthoredDocument,\n projectAuthoredDocument,\n projectAuthoredReview,\n} from \"platejs/authored\";\n\nconst source = editor.read.value();\nconst accepted = projectAuthoredDocument(source, {\n projection: \"accepted\",\n});\nconst review = projectAuthoredReview(source);\nconst json = JSON.stringify(review.review);\nconst document = parseAuthoredDocument(json);\n```\n\n`parseAuthoredDocument` validates the document envelope. Loading it into an\neditor applies the installed application schema and authored-record checks.\n\n### Plain text and Markdown\n\n```tsx\nimport { serializePlainText } from \"platejs\";\n\nconst text = serializePlainText(editor, { projection: \"accepted\" });\nconst markdown = editor.api.markdown.serialize({ projection: \"proposed\" });\n\nif (markdown.ok) downloadMarkdown(markdown.data);\n```\n\nPlain text and Markdown carry visible content only. Feature-owned format\nmappings preserve the structures they can represent and report omitted roots,\nmetadata, pending work, and unsupported nodes. Markdown does not contain a\nhidden Plate document and cannot restore exact review state.\n\n### HTML and DOCX\n\n```tsx\nimport { exportDocx } from \"platejs/docx/export\";\nimport { renderStaticHtml } from \"platejs/static\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst semanticHtml = editor.api.html.serialize({ projection: \"proposed\" });\nconst styledHtml = await renderStaticHtml(editor, {\n presentation: BaseEditorKit,\n projection: \"proposed\",\n});\nconst docx = await exportDocx(editor, {\n presentation: BaseEditorKit,\n projection: \"review\",\n});\n```\n\n`renderStaticHtml` and `exportDocx` draw with the static kit passed as\n`presentation`, because a live editor's components are editing UI. Semantic HTML\nuses feature mappings and no application styling. Both HTML forms carry visible\naccepted or proposed content only. A successful DOCX result returns a `Blob` in\n`docx.blob`. Every serializer returns structured `diagnostics`.\n\n| Format | Accepted or proposed | Review |\n| ------ | -------------------- | ------ |\n| JSON | Plain projected document | Canonical authored document |\n| Plain text | Structural text | Not supported |\n| Markdown | Plain Markdown | Not supported; save authored JSON |\n| Semantic HTML | Feature-mapped HTML | Not supported; save authored JSON |\n| Static HTML | Component-rendered HTML | Not supported; save authored JSON |\n| DOCX | Projected Word document | Word revisions; save authored JSON for exact state |\n\nDOCX review export reports `lossy-content` or `unsupported-content` when Word\ncannot express a native construct. Save authored JSON beside the file to keep\nthat construct exactly.\n\n## Collaboration\n\nInstall the authored plugin and Yjs on every peer, with matching document and schema identities. Plate supplies local History by default.\n\nThe Yjs adapter applies authored operations and decisions in one remote transaction. Its checkpoints preserve pending work and each peer's retained content. Retention policy is local: an archival peer can retain closed bodies while another peer compacts them.\n\nSee [Collaboration](/docs/plate/yjs) for setup and compaction ownership.\n\n## API Reference\n\n### `AuthoredPlugin`\n\nImport the standard descriptor from `platejs/authored`. It records each change as the Plate editor's `userId`, or as the local user, `'local'`, when the editor has none. `SuggestionPlugin` and `AIChatPlugin` depend on this descriptor.\n\n| Option | Type | Purpose |\n| ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |\n| `initialState.retainHistory` | `boolean` | Keep closed edit bodies for selective revert. The editor reads it once, when it is created. Defaults to `false`. |\n\n### Transaction, reads, and updates\n\nAfter `const authored = editor.plugin(AuthoredPlugin)`, use these methods:\n\n| Surface | Purpose |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `tx.authored.propose(options?)` | Start a proposal, assign a new `{ changeId }`, or amend an owned pending change. |\n| `authored.read.canPropose()` | Report whether the current identity can author proposals; always true with `AuthoredPlugin`. |\n| `authored.read.view()` | Read this view's intent and projection. |\n| `authored.read.change(id)` | Read one logical change, or `null`. |\n| `authored.read.details(id)` | Read one change with semantic parts and decision history, or `null`. |\n| `authored.read.changes(query?)` | Read a page filtered by author, status, or time. |\n| `authored.read.changesAt(range)` | Read changes intersecting a text range. |\n| `authored.read.select(query)` | Capture a revision-bound selection for a decision. |\n| `authored.read.preview(decision)` | Check an accept or reject decision without applying it. |\n| `authored.update.decide(decision)` | Accept or reject the selected changes atomically. |\n| `authored.update.resolve(decision)` | Decide a complete conflict component explicitly. |\n| `authored.update.revert({ selection })` | Compensate retained accepted contributions. |\n| `authored.api.setView(view)` | Change the policy on this exact view. |\n| `authored.api.subscribeChanges(listener)` | Subscribe to immutable affected change IDs and decoration node keys for this view. |\n\n`subscribeChanges` is a low-level integration hook for semantic decorators. Suggestion applications use `useSuggestionChanges(path)` instead.\n\n### Types and format helpers\n\n`platejs/authored` exports the authored plugin descriptors, change and decision\ntypes, immutable projection results and diagnostics, and\n`createAuthoredReviewDocument` for trusted cumulative imported revisions.\n\n`projectAuthoredDocument(document, { projection })` returns one accepted or\nproposed `document`, the exact captured `review` document, diagnostics, and\nseparate `unresolved.pending` and `unresolved.conflicted` counts.\n`projectAuthoredReview(document)` also exposes stable visible markup segments\nand property counterparts. Both helpers are pure document operations: they do\nnot construct, activate, or subscribe an editor runtime.\n\n`projectAuthoredRange(projection, range)` maps proposed coordinates into the\naccepted document captured by the same projection. `parseAuthoredDocument`\nparses a JSON document envelope; the target editor remains responsible for its\napplication schema.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/authored-changes.mdx" } @@ -5667,7 +5667,7 @@ "files": [ { "path": "../../content/docs/(guides)/serializing.mdx", - "content": "---\ntitle: Serializing\ndescription: Save complete Plate documents and convert them to text, HTML, Markdown, or DOCX.\n---\n\nSave Plate JSON when another Plate editor must recover the editable document.\nUse a format parser or serializer when content crosses an application boundary.\n\n## Choose a surface\n\n| Task | Surface |\n| --- | --- |\n| Persist the complete editor document | `editor.read.value()` |\n| Persist exact proposals and decisions | `projectAuthoredReview` and `parseAuthoredDocument` |\n| Convert semantic HTML | `platejs/html`; Node parsing uses `platejs/html/server` |\n| Convert Markdown | `platejs/markdown` |\n| Import or export Word | `platejs/docx/import` and `platejs/docx/export` |\n| Render component-styled HTML | `platejs/static` |\n| Exchange browser clipboard MIME payloads | `DataTransferFormat` through `platejs/dom` |\n\nThere is no universal document converter. Each format owns its syntax,\ndiagnostics, loss policy, limits, and retained source.\n\n## Save Plate JSON\n\n`editor.read.value()` includes the primary children, named roots, and persistent\nmetadata.\n\n```ts\nconst document = editor.read.value();\nconst schema = editor.read.schema.identity();\n\nawait saveDocument({ document, schema });\n```\n\nSaving only `children` drops roots and metadata. Persist an application schema\nidentity when stored documents require versioned migration. Validate migrated\ndata before publishing it to an editor.\n\nExact authored review state is canonical JSON-compatible model data:\n\n```ts\nimport { parseAuthoredDocument, projectAuthoredReview } from 'platejs/authored';\n\nconst snapshot = projectAuthoredReview(editor.read.value());\nconst json = JSON.stringify(snapshot.review);\nconst document = parseAuthoredDocument(json);\n```\n\nHTML, Markdown, and DOCX do not hide an authored document in their output.\n\n## Serialize plain text\n\n```ts\nimport { serializePlainText } from 'platejs';\n\nconst result = serializePlainText(editor, { projection: 'accepted' });\n```\n\nFeature plugins contribute structural `plainText` mappings through\n`formats`/`defineFormats`. Plain text is one-way output; it does not become a\nuniversal parser or persistence format.\n\n## Convert HTML\n\nUse direct functions for detached work:\n\n```ts\nimport { parseHtml, serializeHtml } from 'platejs/html';\n\nconst parsed = parseHtml(source, { plugins: EditorKit });\nconst output = serializeHtml(document, {\n plugins: EditorKit,\n projection: 'accepted',\n});\n```\n\nIn Node.js, import `parseHtml` from `platejs/html/server`. Install `linkedom`\nfor that server-only DOM adapter.\n\nInstall `HtmlPlugin` when a configured editor should expose\n`editor.api.html.parse`, `parseSlice`, and `serialize`. See [HTML](/docs/plate/html).\n\n## Convert Markdown\n\n```ts\nimport { parseMarkdown, serializeMarkdown } from 'platejs/markdown';\n\nconst parsed = parseMarkdown(source, { plugins: EditorKit });\nconst output = serializeMarkdown(document, {\n plugins: EditorKit,\n projection: 'proposed',\n});\n```\n\nInstall `MarkdownPlugin` for `editor.api.markdown.parse`, `parseSlice`,\n`parseInline`, and `serialize`. See [Markdown](/docs/plate/markdown).\n\n## Convert DOCX\n\nDOCX import is detached. Supply the complete plugin target before the operation\nstarts, then publish a successful document explicitly.\n\n```ts\nimport { importDocx } from 'platejs/docx/import';\n\nconst result = await importDocx(file, { plugins: EditorKit });\n\nif (result.ok) {\n editor.update((tx) => tx.value.replace(result.document));\n}\n```\n\nDOCX export captures a configured editor because static rendering, comments,\nretained source, and Word revisions are part of its package workflow.\n\n```ts\nimport { exportDocx } from 'platejs/docx/export';\n\nconst result = await exportDocx(editor, { projection: 'review' });\n```\n\nA Word file contains the visible Word document, its comments, and its tracked\nrevisions. Save Plate JSON beside it when another Plate editor must recover\nnamed roots, metadata, or exact authored change records.\n\nSee [DOCX](/docs/plate/docx) for cancellation, package limits, comments, and retained\nsource.\n\n## Handle results\n\nHTML, Markdown, and DOCX use format-specific diagnosed results. Success carries\nwarnings and the requested document, slice, string, or Blob. Failure carries a\nnonempty error-first diagnostic tuple and no publishable carrier.\n\n```ts\nconst result = editor.api.markdown.serialize({ projection: 'accepted' });\n\nshowDiagnostics(result.diagnostics);\nif (!result.ok) return;\n\ndownloadMarkdown(result.data);\n```\n\nExpected source failure and representational loss return diagnostics.\nConfiguration errors, mapping bugs, and invalid serializer model data throw.\n`lossPolicy` defaults to `'reject'`; opt into `'allow'` only when the caller\naccepts the exact reported loss.\n\n## Define feature mappings\n\nFeature plugins declare semantic mappings through `formats` and\n`defineFormats` using the `html`, `markdown`, and `plainText` keys. The narrow\nauthoring callback cannot read a live editor or store.\n\nWhole browser MIME payloads use `DataTransferFormat`, not feature mappings.\nClipboard insertion remains destination-aware and can preserve native open\nedges and detached roots. See [Plugin Methods](/docs/plate/plugin-methods) and\n[Clipboard](/docs/plate/clipboard).\n\n## Document and slice authority\n\nA complete parse returns `EditorDocumentValue`. Direct syntax slice or inline\nparsing returns a closed, rootless `ContentSlice`. The insertion owner fits that\nslice once the destination is known.\n\n```ts\nconst result = editor.api.markdown.parseSlice(source);\n\nif (result.ok) editor.update.slice.replace(result.slice);\n```\n\nDo not turn a complete document into `children` before replacement; that drops\nnamed roots and metadata. Do not invent a destination during detached parsing.\n", + "content": "---\ntitle: Serializing\ndescription: Save complete Plate documents and convert them to text, HTML, Markdown, or DOCX.\n---\n\nSave Plate JSON when another Plate editor must recover the editable document.\nUse a format parser or serializer when content crosses an application boundary.\n\n## Choose a surface\n\n| Task | Surface |\n| --- | --- |\n| Persist the complete editor document | `editor.read.value()` |\n| Persist exact proposals and decisions | `projectAuthoredReview` and `parseAuthoredDocument` |\n| Convert semantic HTML | `platejs/html`; Node parsing uses `platejs/html/server` |\n| Convert Markdown | `platejs/markdown` |\n| Import or export Word | `platejs/docx/import` and `platejs/docx/export` |\n| Render component-styled HTML | `platejs/static` |\n| Exchange browser clipboard MIME payloads | `DataTransferFormat` through `platejs/dom` |\n\nThere is no universal document converter. Each format owns its syntax,\ndiagnostics, loss policy, limits, and retained source.\n\n## Save Plate JSON\n\n`editor.read.value()` includes the primary children, named roots, and persistent\nmetadata.\n\n```ts\nconst document = editor.read.value();\nconst schema = editor.read.schema.identity();\n\nawait saveDocument({ document, schema });\n```\n\nSaving only `children` drops roots and metadata. Persist an application schema\nidentity when stored documents require versioned migration. Validate migrated\ndata before publishing it to an editor.\n\nExact authored review state is canonical JSON-compatible model data:\n\n```ts\nimport { parseAuthoredDocument, projectAuthoredReview } from 'platejs/authored';\n\nconst snapshot = projectAuthoredReview(editor.read.value());\nconst json = JSON.stringify(snapshot.review);\nconst document = parseAuthoredDocument(json);\n```\n\nHTML, Markdown, and DOCX do not hide an authored document in their output.\n\n## Serialize plain text\n\n```ts\nimport { serializePlainText } from 'platejs';\n\nconst result = serializePlainText(editor, { projection: 'accepted' });\n```\n\nFeature plugins contribute structural `plainText` mappings through\n`formats`/`defineFormats`. Plain text is one-way output; it does not become a\nuniversal parser or persistence format.\n\n## Convert HTML\n\nUse direct functions for detached work:\n\n```ts\nimport { parseHtml, serializeHtml } from 'platejs/html';\n\nconst parsed = parseHtml(source, { plugins: EditorKit });\nconst output = serializeHtml(document, {\n plugins: EditorKit,\n projection: 'accepted',\n});\n```\n\nIn Node.js, import `parseHtml` from `platejs/html/server`. Install `linkedom`\nfor that server-only DOM adapter.\n\nInstall `HtmlPlugin` when a configured editor should expose\n`editor.api.html.parse`, `parseSlice`, and `serialize`. See [HTML](/docs/plate/html).\n\n## Convert Markdown\n\n```ts\nimport { parseMarkdown, serializeMarkdown } from 'platejs/markdown';\n\nconst parsed = parseMarkdown(source, { plugins: EditorKit });\nconst output = serializeMarkdown(document, {\n plugins: EditorKit,\n projection: 'proposed',\n});\n```\n\nInstall `MarkdownPlugin` for `editor.api.markdown.parse`, `parseSlice`,\n`parseInline`, and `serialize`. See [Markdown](/docs/plate/markdown).\n\n## Convert DOCX\n\nDOCX import is detached. Supply the complete plugin target before the operation\nstarts, then publish a successful document explicitly.\n\n```ts\nimport { importDocx } from 'platejs/docx/import';\n\nconst result = await importDocx(file, { plugins: EditorKit });\n\nif (result.ok) {\n editor.update((tx) => tx.value.replace(result.document));\n}\n```\n\nDOCX export captures a configured editor because static rendering, comments,\nretained source, and Word revisions are part of its package workflow.\n\n```ts\nimport { exportDocx } from 'platejs/docx/export';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst result = await exportDocx(editor, {\n presentation: BaseEditorKit,\n projection: 'review',\n});\n```\n\nA live editor's components are editing UI, so pass the app's static kit as\n`presentation` to draw the file.\n\nA Word file contains the visible Word document, its comments, and its tracked\nrevisions. Save Plate JSON beside it when another Plate editor must recover\nnamed roots, metadata, or exact authored change records.\n\nSee [DOCX](/docs/plate/docx) for cancellation, package limits, comments, and retained\nsource.\n\n## Handle results\n\nHTML, Markdown, and DOCX use format-specific diagnosed results. Success carries\nwarnings and the requested document, slice, string, or Blob. Failure carries a\nnonempty error-first diagnostic tuple and no publishable carrier.\n\n```ts\nconst result = editor.api.markdown.serialize({ projection: 'accepted' });\n\nshowDiagnostics(result.diagnostics);\nif (!result.ok) return;\n\ndownloadMarkdown(result.data);\n```\n\nExpected source failure and representational loss return diagnostics.\nConfiguration errors, mapping bugs, and invalid serializer model data throw.\n`lossPolicy` defaults to `'reject'`; opt into `'allow'` only when the caller\naccepts the exact reported loss.\n\n## Define feature mappings\n\nFeature plugins declare semantic mappings through `formats` and\n`defineFormats` using the `html`, `markdown`, and `plainText` keys. The narrow\nauthoring callback cannot read a live editor or store.\n\nWhole browser MIME payloads use `DataTransferFormat`, not feature mappings.\nClipboard insertion remains destination-aware and can preserve native open\nedges and detached roots. See [Plugin Methods](/docs/plate/plugin-methods) and\n[Clipboard](/docs/plate/clipboard).\n\n## Document and slice authority\n\nA complete parse returns `EditorDocumentValue`. Direct syntax slice or inline\nparsing returns a closed, rootless `ContentSlice`. The insertion owner fits that\nslice once the destination is known.\n\n```ts\nconst result = editor.api.markdown.parseSlice(source);\n\nif (result.ok) editor.update.slice.replace(result.slice);\n```\n\nDo not turn a complete document into `children` before replacement; that drops\nnamed roots and metadata. Do not invent a destination during detached parsing.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/serializing.mdx" } @@ -5681,7 +5681,7 @@ "files": [ { "path": "../../content/docs/(guides)/static.mdx", - "content": "---\ntitle: Static Rendering\ndescription: A minimal, memoized, read-only version of Plate with RSC/SSR support.\n---\n\n`` is a **fast, read-only** React component for rendering Plate content, optimized for **server-side** or **React Server Component** (RSC) environments. It avoids client-side editing logic and memoizes node renders for better performance compared to using [``](/docs/plate/api/core/plate-components) in read-only mode.\n\nIt is the rendering path behind `renderStaticHtml` and fits server or RSC\nsurfaces that need a non-interactive Plate view.\n\n## Key advantages\n\n- **Server-Safe:** No browser API dependencies; works in SSR/RSC.\n- **No Plate Editor Overhead:** Excludes interactive features like selections or event handlers.\n- **Memoized Rendering:** Uses structural identity to re-render only changed nodes.\n- **Partial Re-Renders:** Changes in one part of the document don't force a full re-render.\n- **Lightweight:** Smaller bundle size as it omits interactive editor code.\n\n## When to use ``\n\n- Generating HTML with [HTML Serialization](/docs/plate/html).\n- Displaying server-rendered previews in Next.js (especially with RSC).\n- Building static sites with read-only Plate content.\n- Optimizing performance-critical read-only views.\n- Rendering AI-streaming content.\n\n\n For interactive read-only features (like comment popovers or selections), use the standard `` component in the browser. For purely server-rendered, non-interactive content, `` is the recommended choice.\n\n\n## Kit usage\n\n\n\n### Installation\n\n`BaseEditorKit` includes base plugins configured for static rendering.\n\n\n\n### Add kit\n\n```tsx\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n initialValue: [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'This content is rendered statically.' }] },\n ],\n});\n\n// Render statically\nexport default function MyStaticPage() {\n return ;\n}\n```\n\n### Example\n\nSee a complete server-side static rendering example:\n\n\n\n\n\n## Manual usage\n\n\n\n### Create a Plate editor\n\nInitialize a Plate editor instance using `createEditor` with component-bound plugins. This is analogous to using `useCreateEditor` for the interactive `` component.\n\n```tsx title=\"lib/plate-static-editor.ts\"\nimport { createEditor } from 'platejs';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n// Import any other desired base plugins, such as MarkdownPlugin.\n// Ensure you are NOT importing from /react subpaths for server environments.\n\nconst editor = createEditor({\n plugins: [\n ...BaseBasicBlocksKit,\n // Add other base plugins here.\n ],\n initialValue: [\n {\n type: 'paragraph',\n children: [{ text: 'Hello from a static Plate editor!' }],\n },\n ],\n});\n```\n\n### Define static node components\n\nIf your interactive editor uses client-side components (e.g., with `use client` or event handlers), you **must** create static, server-safe equivalents. These components should render pure HTML without browser-specific logic.\n\n```tsx title=\"components/editor/paragraph-static.tsx\"\nimport React from 'react';\nimport type { BaseParagraphPlugin } from 'platejs';\nimport { EditorElement, type EditorElementProps } from 'platejs/static';\n\nexport function ParagraphElementStatic(\n props: EditorElementProps\n) {\n return (\n \n {props.children}\n \n );\n}\n```\nCreate similar static components for headings, images, links, etc.\n\n### Bind static components\n\nConfigure each server-safe component on its owning base plugin.\n\n```ts title=\"components/static-editor-kit.ts\"\nimport { BaseHeadingPlugin, BaseParagraphPlugin } from 'platejs';\nimport { ParagraphElementStatic } from './ui/paragraph-static';\nimport { HeadingElementStatic } from './ui/heading-static';\n\nexport const StaticEditorKit = [\n BaseParagraphPlugin.configure({ component: ParagraphElementStatic }),\n BaseHeadingPlugin.configure({ component: HeadingElementStatic }),\n];\n```\n\n### Render ``\n\nUse `` with an editor whose plugin descriptors own their static components.\n\n```tsx title=\"app/my-static-page/page.tsx (RSC Example)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { StaticEditorKit } from '@/components/static-editor-kit';\n\nexport default async function MyStaticPage() {\n // Example: Fetch or define editor value\n const initialValue = [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'Content rendered statically.' }] },\n ];\n\n const editor = createEditor({\n plugins: StaticEditorKit,\n initialValue,\n });\n\n return (\n \n );\n}\n```\n\n### Memoization details\n\n`` enhances performance through memoization:\n- Static element and leaf rendering uses `React.memo`.\n- **Reference Equality:** A block renders again only when it, a block\n before it, or a decoration inside it changed, so a renderer may read its\n own block and earlier ones, such as list numbering.\n- **Later content:** An element whose rendering reads content after it, such\n as a table of contents, declares `render: { readsDocument: true }` on its\n plugin and renders again whenever the document changes.\n\n\n\n## Render a document\n\nPass `document` to render a document through the editor's plugins without\nediting the editor: no transaction, history or selection change. One configured\neditor can render a streamed preview or a stored revision this way:\n\n```tsx\n\n```\n\nThe document must be valid for the editor's schema. The editor validates a\ndocument from `editor.read.value()` once and any other document object on every\nrender, because that object can change between renders.\n`EditorPreview` takes the same `document`, and copying from it copies the\nrendered document.\n\nEach render reads the decorations of every top-level block, so rendering again\nis also how decorations that depend on other state refresh. A component that\nre-renders for other reasons, such as each streamed chunk, should memoize the\nelement on its document:\n\n```tsx\nconst preview = useMemo(\n () => ,\n [editor, document]\n);\n```\n\nEverything read through the rendered editor sees the document: `editor.read`,\nplugin reads such as `editor.plugin(TocPlugin).read.headings()`, and plugin\nAPIs. Plugins get that editor from their render, decorate, read or API\ncontext. An editor captured when a plugin was created, as in\n`.extend(({ editor }) => …)`, is the editor you passed and still holds its own\nvalue. Reading it from plugin code that reads, decorates or renders the\ndocument throws an error that names the fix. React runs component bodies after\nthat code returns, so a component reads through its `editor` prop.\n\n## Client-side alternative: `EditorPreview`\n\nFor cases where you need **minimal interactivity** with static content, use ``. This component wraps `` and adds client-side event handlers for user interactions while maintaining the performance benefits of static rendering.\n\n### Example: server component with both static views\n\n```tsx title=\"app/document/page.tsx\"\nimport { createStaticEditor, EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\nimport { InteractiveViewer } from './interactive-viewer';\n\nexport default async function DocumentPage() {\n const content = await fetchDocument(); // Your document data\n // Server-side static editor\n const editor = createStaticEditor({\n plugins: BaseEditorKit,\n initialValue: content,\n });\n\n return (\n
\n {/* Pure static rendering - no interactivity */}\n
\n

Static View (Server Rendered)

\n \n
\n\n {/* Interactive view - rendered on client */}\n
\n

Interactive View

\n \n
\n
\n );\n}\n```\n\n### Example: client component with EditorPreview\n\n```tsx title=\"app/document/interactive-viewer.tsx\"\n'use client';\n\nimport { EditorPreview, useStaticEditor } from 'platejs/react';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nexport function InteractiveViewer({ value }) {\n const editor = useStaticEditor({\n plugins: BaseEditorKit,\n initialValue: value,\n });\n\n return ;\n}\n```\n\n### Key features of `EditorPreview`\n\n- **Client-side only**: Requires `'use client'` directive\n- **Adds interactivity**: Enables user interactions with the content (e.g., text selection, copying, future interactions like tooltips, highlights, etc.)\n- **Minimal overhead**: Still uses `EditorStatic` internally for rendering\n- **Use with `useStaticEditor`**: Creates a static editor optimized for view-only React components\n- **Exact copy payloads**: `EditorPreview` resolves selection against its own\n static host and writes the same model slice, HTML, and plain-text payloads as\n a mounted editor\n\n\n `EditorPreview` cannot be used in Server Components. If you're passing an editor from a server component to a client component, you'll encounter serialization errors. Use `EditorStatic` on the server side, or create the editor client-side with `useStaticEditor`.\n\n\n## `EditorStatic` vs. `EditorPreview` vs. `EditorRoot` + `readOnly`\n\n| Aspect | `` | `` | `` + `readOnly` |\n| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |\n| **Environment** | Server/Client (SSR/RSC safe) | Client-only | Client-only |\n| **Interactivity** | None | Minimal (selection, copy, toolbar, etc.) | Full interactive features (browser-only) |\n| **Browser APIs** | Not used | Minimal (event handlers) | Full usage |\n| **Performance** | Best - static HTML only | Good - static rendering + event delegation | Heavier - full editor internals |\n| **Bundle Size** | Smallest | Small | Largest |\n| **Use Cases** | Server rendering, HTML export | Client-side content with basic interactions | Full read-only editor with all features |\n| **Recommendation** | SSR/RSC without any interactions | Client-side content needing light interactivity | Client-side with complex interactive needs |\n\n## RSC/SSR example\n\nIn a Next.js App Router (or similar RSC environment), `` can be used directly in Server Components:\n\n```tsx title=\"app/preview/page.tsx (RSC)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nexport default async function Page() {\n // Fetch or define content server-side\n const serverContent = [\n { type: 'heading', level: 1, children: [{ text: 'Rendered on the Server! 🎉' }] },\n { type: 'paragraph', children: [{ text: 'This content is static and server-rendered.' }] },\n ];\n\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: serverContent,\n });\n\n return (\n \n );\n}\n```\nThis renders the content to HTML on the server without needing a client-side JavaScript bundle for `EditorStatic` itself.\n\n## Pairing with `renderStaticHtml`\n\nFor server-rendering the static Plate tree to an HTML string, use\n`renderStaticHtml`. It renders `` through React DOM Server; it is\nnot a semantic HTML interchange serializer.\n\n`EditorStatic` renders configured node components and render attributes. It\nomits editing-surface slots such as `slots.wrapContent`; wrap `result.data` in\napplication presentation when the exported document needs an outer shell.\n\n```ts title=\"lib/html-serializer.ts\"\nimport { createEditor } from 'platejs';\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nasync function getDocumentAsHtml(value: any[]) {\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: value,\n });\n\n const result = await renderStaticHtml(editor, {\n // component: EditorStatic, // Optional: Defaults to EditorStatic\n props: { className: 'prose max-w-none' }, // Example: Pass props to the root div\n });\n\n return result.data;\n}\n\n// Example Usage:\n// const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ];\n// getDocumentAsHtml(value).then(console.log);\n```\nFor more details, see the [HTML Serialization guide](/docs/plate/html).\n\n## API Reference\n\n### `` props\n\n```ts\nimport type React from 'react';\nimport type { Editor, EditorDocumentValue } from 'platejs';\n\ninterface EditorStaticProps\n extends React.HTMLAttributes {\n /**\n * A document to render instead of the editor's own. The editor supplies\n * the plugins and is not edited.\n */\n document?: EditorDocumentValue;\n\n /**\n * The Plate editor instance, created via `createEditor`.\n * Must include component-bound plugins relevant to the rendered content.\n */\n editor: E;\n\n /** Inline CSS styles for the root `div` element. */\n style?: React.CSSProperties;\n\n // Other HTMLDivElement attributes like `className`, `id`, etc., are also supported.\n}\n```\n\n- **`document`**: A document rendered through the editor's plugins instead of the editor's own value; see [Render a document](#render-a-document).\n- **`editor`**: An editor created with `createEditor` or `createStaticEditor`, including component-bound plugins required by the value.\n\n## Next steps\n\n- Explore [HTML Serialization](/docs/plate/html) for exporting content.\n- Learn about using Plate in [React Server Components](/docs/plate/installation/rsc).\n- Refer to individual plugin documentation for their base (non-React) imports.\n", + "content": "---\ntitle: Static Rendering\ndescription: A minimal, memoized, read-only version of Plate with RSC/SSR support.\n---\n\n`` is a **fast, read-only** React component for rendering Plate content, optimized for **server-side** or **React Server Component** (RSC) environments. It avoids client-side editing logic and memoizes node renders for better performance compared to using [``](/docs/plate/api/core/plate-components) in read-only mode.\n\nIt is the rendering path behind `renderStaticHtml` and fits server or RSC\nsurfaces that need a non-interactive Plate view.\n\n## Key advantages\n\n- **Server-Safe:** No browser API dependencies; works in SSR/RSC.\n- **No Plate Editor Overhead:** Excludes interactive features like selections or event handlers.\n- **Memoized Rendering:** Uses structural identity to re-render only changed nodes.\n- **Partial Re-Renders:** Changes in one part of the document don't force a full re-render.\n- **Lightweight:** Smaller bundle size as it omits interactive editor code.\n\n## When to use ``\n\n- Generating HTML with [HTML Serialization](/docs/plate/html).\n- Displaying server-rendered previews in Next.js (especially with RSC).\n- Building static sites with read-only Plate content.\n- Optimizing performance-critical read-only views.\n- Rendering AI-streaming content.\n\n\n For interactive read-only features (like comment popovers or selections), use the standard `` component in the browser. For purely server-rendered, non-interactive content, `` is the recommended choice.\n\n\n## Kit usage\n\n\n\n### Installation\n\n`BaseEditorKit` includes base plugins configured for static rendering.\n\n\n\n### Add kit\n\n```tsx\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n initialValue: [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'This content is rendered statically.' }] },\n ],\n});\n\n// Render statically\nexport default function MyStaticPage() {\n return ;\n}\n```\n\n### Example\n\nSee a complete server-side static rendering example:\n\n\n\n\n\n## Manual usage\n\n\n\n### Create a Plate editor\n\nInitialize a Plate editor instance using `createEditor` with component-bound plugins. This is analogous to using `useCreateEditor` for the interactive `` component.\n\n```tsx title=\"lib/plate-static-editor.ts\"\nimport { createEditor } from 'platejs';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n// Import any other desired base plugins, such as MarkdownPlugin.\n// Ensure you are NOT importing from /react subpaths for server environments.\n\nconst editor = createEditor({\n plugins: [\n ...BaseBasicBlocksKit,\n // Add other base plugins here.\n ],\n initialValue: [\n {\n type: 'paragraph',\n children: [{ text: 'Hello from a static Plate editor!' }],\n },\n ],\n});\n```\n\n### Define static node components\n\nIf your interactive editor uses client-side components (e.g., with `use client` or event handlers), you **must** create static, server-safe equivalents. These components should render pure HTML without browser-specific logic.\n\n```tsx title=\"components/editor/paragraph-static.tsx\"\nimport React from 'react';\nimport type { BaseParagraphPlugin } from 'platejs';\nimport { EditorElement, type EditorElementProps } from 'platejs/static';\n\nexport function ParagraphElementStatic(\n props: EditorElementProps\n) {\n return (\n \n {props.children}\n \n );\n}\n```\nCreate similar static components for headings, images, links, etc.\n\n### Bind static components\n\nConfigure each server-safe component on its owning base plugin.\n\n```ts title=\"components/static-editor-kit.ts\"\nimport { BaseHeadingPlugin, BaseParagraphPlugin } from 'platejs';\nimport { ParagraphElementStatic } from './ui/paragraph-static';\nimport { HeadingElementStatic } from './ui/heading-static';\n\nexport const StaticEditorKit = [\n BaseParagraphPlugin.configure({ component: ParagraphElementStatic }),\n BaseHeadingPlugin.configure({ component: HeadingElementStatic }),\n];\n```\n\n### Render ``\n\nUse `` with an editor whose plugin descriptors own their static components.\n\n```tsx title=\"app/my-static-page/page.tsx (RSC Example)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { StaticEditorKit } from '@/components/static-editor-kit';\n\nexport default async function MyStaticPage() {\n // Example: Fetch or define editor value\n const initialValue = [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'Content rendered statically.' }] },\n ];\n\n const editor = createEditor({\n plugins: StaticEditorKit,\n initialValue,\n });\n\n return (\n \n );\n}\n```\n\n### Memoization details\n\n`` enhances performance through memoization:\n- Static element and leaf rendering uses `React.memo`.\n- **Reference Equality:** A block renders again only when it, a block\n before it, or a decoration inside it changed, so a renderer may read its\n own block and earlier ones, such as list numbering.\n- **Later content:** An element whose rendering reads content after it, such\n as a table of contents, declares `render: { readsDocument: true }` on its\n plugin and renders again whenever the document changes.\n\n\n\n## Render a document\n\nPass `document` to render a document through the editor's plugins without\nediting the editor: no transaction, history or selection change. One configured\neditor can render a streamed preview or a stored revision this way:\n\n```tsx\n\n```\n\nThe document must be valid for the editor's schema. The editor validates a\ndocument from `editor.read.value()` once and any other document object on every\nrender, because that object can change between renders.\n`EditorPreview` takes the same `document`, and copying from it copies the\nrendered document.\n\nEach render reads the decorations of every top-level block, so rendering again\nis also how decorations that depend on other state refresh. A component that\nre-renders for other reasons, such as each streamed chunk, should memoize the\nelement on its document:\n\n```tsx\nconst preview = useMemo(\n () => ,\n [editor, document]\n);\n```\n\nEverything read through the rendered editor sees the document: `editor.read`,\nplugin reads such as `editor.plugin(TocPlugin).read.headings()`, and plugin\nAPIs. Plugins get that editor from their render, decorate, read or API\ncontext. An editor captured when a plugin was created, as in\n`.extend(({ editor }) => …)`, is the editor you passed and still holds its own\nvalue. Reading it from plugin code that reads, decorates or renders the\ndocument throws an error that names the fix. React runs component bodies after\nthat code returns, so a component reads through its `editor` prop.\n\n## Client-side alternative: `EditorPreview`\n\nFor cases where you need **minimal interactivity** with static content, use ``. This component wraps `` and adds client-side event handlers for user interactions while maintaining the performance benefits of static rendering.\n\n### Example: server component with both static views\n\n```tsx title=\"app/document/page.tsx\"\nimport { createStaticEditor, EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\nimport { InteractiveViewer } from './interactive-viewer';\n\nexport default async function DocumentPage() {\n const content = await fetchDocument(); // Your document data\n // Server-side static editor\n const editor = createStaticEditor({\n plugins: BaseEditorKit,\n initialValue: content,\n });\n\n return (\n
\n {/* Pure static rendering - no interactivity */}\n
\n

Static View (Server Rendered)

\n \n
\n\n {/* Interactive view - rendered on client */}\n
\n

Interactive View

\n \n
\n
\n );\n}\n```\n\n### Example: client component with EditorPreview\n\n```tsx title=\"app/document/interactive-viewer.tsx\"\n'use client';\n\nimport { EditorPreview, useStaticEditor } from 'platejs/react';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nexport function InteractiveViewer({ value }) {\n const editor = useStaticEditor({\n plugins: BaseEditorKit,\n initialValue: value,\n });\n\n return ;\n}\n```\n\n### Key features of `EditorPreview`\n\n- **Client-side only**: Requires `'use client'` directive\n- **Adds interactivity**: Enables user interactions with the content (e.g., text selection, copying, future interactions like tooltips, highlights, etc.)\n- **Minimal overhead**: Still uses `EditorStatic` internally for rendering\n- **Use with `useStaticEditor`**: Creates a static editor optimized for view-only React components\n- **Exact copy payloads**: `EditorPreview` resolves selection against its own\n static host and writes the same model slice, HTML, and plain-text payloads as\n a mounted editor\n\n\n `EditorPreview` cannot be used in Server Components. If you're passing an editor from a server component to a client component, you'll encounter serialization errors. Use `EditorStatic` on the server side, or create the editor client-side with `useStaticEditor`.\n\n\n## `EditorStatic` vs. `EditorPreview` vs. `EditorRoot` + `readOnly`\n\n| Aspect | `` | `` | `` + `readOnly` |\n| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |\n| **Environment** | Server/Client (SSR/RSC safe) | Client-only | Client-only |\n| **Interactivity** | None | Minimal (selection, copy, toolbar, etc.) | Full interactive features (browser-only) |\n| **Browser APIs** | Not used | Minimal (event handlers) | Full usage |\n| **Performance** | Best - static HTML only | Good - static rendering + event delegation | Heavier - full editor internals |\n| **Bundle Size** | Smallest | Small | Largest |\n| **Use Cases** | Server rendering, HTML export | Client-side content with basic interactions | Full read-only editor with all features |\n| **Recommendation** | SSR/RSC without any interactions | Client-side content needing light interactivity | Client-side with complex interactive needs |\n\n## RSC/SSR example\n\nIn a Next.js App Router (or similar RSC environment), `` can be used directly in Server Components:\n\n```tsx title=\"app/preview/page.tsx (RSC)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nexport default async function Page() {\n // Fetch or define content server-side\n const serverContent = [\n { type: 'heading', level: 1, children: [{ text: 'Rendered on the Server! 🎉' }] },\n { type: 'paragraph', children: [{ text: 'This content is static and server-rendered.' }] },\n ];\n\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: serverContent,\n });\n\n return (\n \n );\n}\n```\nThis renders the content to HTML on the server without needing a client-side JavaScript bundle for `EditorStatic` itself.\n\n## Pairing with `renderStaticHtml`\n\nFor server-rendering the static Plate tree to an HTML string, use\n`renderStaticHtml`. It renders `` through React DOM Server; it is\nnot a semantic HTML interchange serializer.\n\n`EditorStatic` renders configured node components and render attributes. It\nomits editing-surface slots such as `slots.wrapContent`; wrap `result.data` in\napplication presentation when the exported document needs an outer shell.\n\n```ts title=\"lib/html-serializer.ts\"\nimport { createEditor } from 'platejs';\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nasync function getDocumentAsHtml(value: any[]) {\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: value,\n });\n\n const result = await renderStaticHtml(editor, {\n // component: EditorStatic, // Optional: Defaults to EditorStatic\n props: { className: 'prose max-w-none' }, // Example: Pass props to the root div\n });\n\n return result.data;\n}\n\n// Example Usage:\n// const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ];\n// getDocumentAsHtml(value).then(console.log);\n```\n\n`props` takes appearance props for the root element, such as `className` or\n`style`. It throws a `TypeError` for `editor` or `document`. Pass the editor as\nthe first argument and another document as the `document` option.\n\n### Export a live editor\n\nA live editor's components are editing UI that needs a mounted Plate editor.\nPass the app's static kit as `presentation` to draw the export with its static\ncomponents while the live editor keeps the document and plugin settings:\n\n```ts\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst result = await renderStaticHtml(editor, {\n presentation: BaseEditorKit,\n});\n```\n\nFor each element component, mark component, and wrapper slot function an\ninstalled plugin declares, the plugin of the same name in `presentation`\nsupplies the drawing, also on an element that no installed plugin renders.\nDrawings the installed plugin marks `editOnly` are left out. A drawing\n`presentation` lacks is left out too, so the content renders without it, and\n`result.diagnostics` names the plugin in a `missing-static-presentation`\nwarning. An installed `afterNodeChildren` written as a component, such as\n`React.memo`, counts as a drawing, so `presentation` needs a function for it.\nAn installed tag name, such as `component: 'p'`, stays.\n\nDeclare drawings as values and read plugin state through the `editor` prop.\n`presentation` compiles without activating its plugins. Drawings created in a\n`configure((ctx) => ...)` callback are not supported. Such a drawing can throw\nwhen it draws, which rejects the export, or draw the presentation's own\nconfiguration. Pass a stable array, because each new array is compiled again.\n\nFor more details, see the [HTML Serialization guide](/docs/plate/html).\n\n## API Reference\n\n### `` props\n\n```ts\nimport type React from 'react';\nimport type { Editor, EditorDocumentValue } from 'platejs';\n\ninterface EditorStaticProps\n extends React.HTMLAttributes {\n /**\n * A document to render instead of the editor's own. The editor supplies\n * the plugins and is not edited.\n */\n document?: EditorDocumentValue;\n\n /**\n * The Plate editor instance, created via `createEditor`.\n * Must include component-bound plugins relevant to the rendered content.\n */\n editor: E;\n\n /** Inline CSS styles for the root `div` element. */\n style?: React.CSSProperties;\n\n // Other HTMLDivElement attributes like `className`, `id`, etc., are also supported.\n}\n```\n\n- **`document`**: A document rendered through the editor's plugins instead of the editor's own value; see [Render a document](#render-a-document).\n- **`editor`**: An editor created with `createEditor` or `createStaticEditor`, including component-bound plugins required by the value.\n\n## Next steps\n\n- Explore [HTML Serialization](/docs/plate/html) for exporting content.\n- Learn about using Plate in [React Server Components](/docs/plate/installation/rsc).\n- Refer to individual plugin documentation for their base (non-React) imports.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/static.mdx" } @@ -6479,7 +6479,7 @@ "files": [ { "path": "../../content/docs/(plugins)/(serializing)/docx.mdx", - "content": "---\ntitle: DOCX\ndescription: Paste Word content, import bounded DOCX files, and export an explicit document projection.\n---\n\nUse `WordPastePlugin` for clipboard content. Use `importDocx` and `exportDocx` for files. The three operations have separate entrypoints and dependencies.\n\n\n\n\n\n## Features\n\n- Normalize HTML and RTF pasted from Microsoft Word\n- Import a bounded DOCX package into one complete editor document\n- Preserve supported Word revisions and comment ranges as structured data\n- Export one captured editor snapshot as an accepted, proposed, or review DOCX\n\n\n\n\n Paste, file import, and file download use browser APIs. Keep these operations\n in client code. Export uses React static rendering, so pass the descriptors\n and static components that define your document output.\n\n\n## Kit usage\n\nThe app-local `DocxKit` installs `WordPastePlugin`. File import and export are standalone functions.\n\n\n\n### Installation\n\n```bash\nnpm install platejs @tanstack/react-virtual juice validator\n```\n\n### Add kit\n\n\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { createEditor } from \"platejs/react\";\nimport { DocxKit } from \"@/components/editor/docx\";\n\nconst editor = createEditor({\n plugins: [...DocxKit],\n});\n```\n\n\n\n## Paste from Word\n\n`WordPastePlugin` inlines pasted CSS and normalizes Word HTML and RTF before the installed Plate format mappings decode the content.\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { WordPastePlugin } from \"platejs/docx/paste\";\nimport { createEditor } from \"platejs/react\";\n\nconst editor = createEditor({\n plugins: [WordPastePlugin],\n});\n```\n\nClipboard paste does not upload embedded images or preserve Word table geometry. Configure those destination policies in their owning media and table features.\n\n## Import a DOCX file\n\n`importDocx` reads a `Blob` or `ArrayBuffer` without modifying the editor. On success, replace the complete editor value so document metadata, named roots, and authored review state stay coherent.\n\nInside a mounted React control, get that complete editor with\n`useModelEditor()`. `useEditor()` returns the provider-selected mounted command\nview, whose root and authored projection belong to that view.\n\n```bash\nnpm install platejs @tanstack/react-virtual mammoth validator\n```\n\n```tsx title=\"components/editor/import-docx.tsx\"\nimport { importDocx } from \"platejs/docx/import\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n});\n\nexport async function importFile(file: File) {\n const result = await importDocx(file, {\n plugins: BaseEditorKit,\n });\n\n if (!result.ok) {\n return { diagnostics: result.diagnostics };\n }\n\n editor.update.value.replace(result.document);\n\n return {\n comments: result.comments,\n diagnostics: result.diagnostics,\n };\n}\n```\n\nAn expected file, package, limit, or decode failure returns `{ ok: false, diagnostics }` and no document. An aborted import rejects with the signal reason. Invalid options and internal invariant failures throw.\n\n### Retain the source for later export\n\nSet `retainSource: true` when the same editor imports, edits, and exports a Word file. A successful import then includes `source`. It is a `DocxSource` when exact export can return the package unchanged. Otherwise it is `null`, with a `source-unavailable` warning that names the first part exact export cannot admit. Keep the source in application state, pass it to `exportDocx`, and dispose it when the document closes or another file replaces it.\n\n```tsx title=\"components/editor/docx-roundtrip.tsx\" showLineNumbers\nimport { exportDocx } from \"platejs/docx/export\";\nimport { importDocx } from \"platejs/docx/import\";\n\nconst imported = await importDocx(file, {\n plugins: BaseEditorKit,\n retainSource: true,\n});\n\nif (!imported.ok) return imported.diagnostics;\n\neditor.update.value.replace(imported.document);\n\nconst exported = await exportDocx(editor, {\n projection: \"review\",\n source: imported.source,\n stylesheet,\n});\n\nimported.source?.dispose();\n```\n\nExact export admits a closed set of passive package content:\n\n- the main document at `word/document.xml`, styles, settings, numbering, fonts, and theme;\n- headers, footers, footnotes, endnotes, and comments;\n- core, extended, and custom document properties and a thumbnail;\n- AVIF, BMP, GIF, JPEG, PNG, and WebP images reached through internal relationships;\n- hyperlinks whose destination is absolute and meets the navigation floor.\n\n`PAGE` is the only recognized field instruction. Any other part, relationship, markup namespace, field instruction, or XML processing instruction leaves `source` null. That includes unsafe or relative hyperlinks, other external relationships, macros, ActiveX controls, OLE objects, imported HTML chunks, external templates, custom XML data, and SVG or EMF images. A null source does not mean the import lost content; exporting without a source generates the document from editor content.\n\n`DocxSource` retains the admitted compressed package plus immutable document, comment, schema, and limit correspondence. It does not retain expanded ZIP entries or an editor reference. `dispose()` is idempotent. An export that already acquired the source can finish after disposal; later exports report `source-unavailable` and generate a fresh DOCX.\n\n### Import comments\n\nThe importer returns comment format facts without writing app comment storage. Each `DocxComment` can include a rich Plate `body`, an exact `target.range` in proposed-projection coordinates, Word author and date metadata, a durable ID, a parent ID, and resolved state. Map those records to your app identities explicitly.\n\n### Import tracked revisions\n\nSupported Word insertions, deletions, moves, run property changes, and paragraph property changes become authored changes. Revision IDs follow Word source order and structural dependencies; dates remain metadata. A missing or invalid date uses the authored unknown-time value and produces a diagnostic.\n\nIf a revision cannot map to the installed schema, the importer keeps the Word-visible proposed content and reports the loss in `diagnostics`.\n\n### Import list numbering\n\nNumbered and bulleted Word paragraphs become list items, and each item joins the list of its Word numbering definition. Word counts every instance of a definition in one sequence per level. A list therefore continues past other paragraphs and across instances, and lists of different definitions that interleave keep their own numbers. A level restarts after a paragraph at the level its `lvlRestart` names or a shallower one, which by default is the level just above it. An instance's start override resets its level once, at the instance's first paragraph at that level. That item imports with the override as `listRestart`, and the items after it count on from it, so an inserted item renumbers the rest the way Word does. Each item's `listLevels` hold its definition's level starts, `lvlRestart` rules and level text, with its instance's format and level text overrides applied. Word ignores a start or `lvlRestart` inside a level override, and so does the import. A paragraph whose instance shows its level as bullets imports as a bulleted item and does not count.\n\n## Export a DOCX file\n\n`exportDocx` captures the editor once before asynchronous rendering. Pass the visible projection explicitly.\n\n```bash\nnpm install platejs @tanstack/react-virtual color-name html-to-vdom jszip juice mime-types virtual-dom xmlbuilder2\n```\n\n```tsx title=\"components/editor/export-docx.tsx\"\nimport { exportDocx } from \"platejs/docx/export\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\nimport { DOCX_EXPORT_STYLES } from \"@/components/editor/docx-export\";\n\nconst editor = createEditor({ plugins: BaseEditorKit });\n\nexport async function downloadEditorDocx() {\n const result = await exportDocx(editor, {\n orientation: \"portrait\",\n projection: \"proposed\",\n stylesheet: DOCX_EXPORT_STYLES,\n title: \"Document\",\n });\n\n if (!result.ok) return result.diagnostics;\n\n const url = URL.createObjectURL(result.blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"document.docx\";\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n\n return result.diagnostics;\n}\n```\n\nChoose `accepted` to omit pending changes, `proposed` to apply them, or `review` to write Word tracked-change markup. Accepted and proposed exports warn about omitted pending changes and conflicts resolved to one side. A review export with any conflict returns `ok: false` and an `authored-conflict` error because Word revisions cannot represent that state faithfully.\n\nA review file contains what Word displays: text, comments, and tracked revisions with their authors. Importing it restores those revisions as authored changes. Export does not write named roots, document metadata, or exact authored change records, such as their original IDs, to the file. To keep them, save the authored JSON document beside the file, as [Serializing](/docs/plate/serializing) describes:\n\n```tsx\nimport { parseAuthoredDocument, projectAuthoredReview } from \"platejs/authored\";\n\nconst json = JSON.stringify(projectAuthoredReview(editor.read.value()).review);\nconst document = parseAuthoredDocument(json);\n```\n\n### Content loss\n\n`exportDocx` rejects content loss by default. When an image cannot be embedded, or a supplied comment has no representable range, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: \"allow\"` to download the file and receive the same loss as warnings:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n lossPolicy: \"allow\",\n projection: \"review\",\n});\n```\n\nNamed roots, document metadata, removed link destinations, and other properties DOCX cannot represent are warnings under both policies. An `accepted` export omits a comment whose annotated content that projection excludes, and reports a warning, as Word does when the annotated suggestion is rejected.\n\n### Lists\n\nEach Plate list becomes one Word list. Its `listLevels` give the list's level starts, `lvlRestart` rules and level text. An item with `listRestart`, or one whose number Word would not reach, gets a numbering instance of its own that overrides that level's start, as Word's Restart Numbering writes it. The items after it count on from it in the list's own instance. Word and a re-import then show the numbers the editor shows, and the restart stays on that item. Word draws one marker style per list level, so an item whose `listStyle` differs from the first item at its level keeps that first style and reports a `list-marker` warning under both policies. A Word list level is numbered or bulleted, as its first item is, and the exporter writes one set of level rules per list. An item of the other kind at its level is therefore not part of its list in Word, and an item whose `listLevels` differ from its list's first item takes that item's rules. Each reports a `list-structure` warning, and `lossPolicy: 'reject'` fails the export. Word has no start that applies only when an item opens its level, so an item with `listStart` exports its shown number and reports a `list-start` warning under both policies.\n\n### Links and images\n\nExport writes a link destination only when it is absolute and meets the navigation floor, such as `https:` or `mailto:`. It writes bookmark links such as `#introduction` as Word anchors. An unsafe destination, or a relative link that has no document base in a Word file, keeps its text without the destination.\n\nImages are embedded as PNG, JPEG, GIF, or BMP. Set `allowRemoteImages: true` to fetch HTTP(S) images during export. These images are omitted and leave their alt text:\n\n- an image with an unsafe, relative, or blob source;\n- a remote image when `allowRemoteImages` is off;\n- an image that cannot be fetched;\n- an image in any other format.\n\nBefore returning a generated file, export checks the written package against the same passive vocabulary that retained sources use. Output outside it is withheld with an `invalid-package` error. An unchanged source-aware export returns the retained file, which passed the same check when it was imported.\n\n### Source-aware export\n\nWith a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, or `pageSize` override. Static renderer options are bypassed on this path because no content is regenerated.\n\nIf an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting:\n\n- headers and footers from a document with one section, including their images and hyperlinks;\n- custom document properties.\n\nThe regenerated package has its own core document properties. It omits source body XML, body-only parts such as footnotes and body images, and headers and footers from a document with multiple sections. It also omits the source core and extended properties and the package thumbnail: the core properties, such as the author, title and creation date, the extended properties, which count pages and words and hold details such as the company name, and the thumbnail, which pictures the body. Diagnostics report why the source was rewritten and identify each omitted source part. A custom property linked to body text keeps the value cached in the source file, even when the edit changed that text.\n\nWhen regeneration is required, omitted `comments` means that no comments are generated. Pass the current app-owned comment set explicitly to retain comments after an edit. Imported comment ranges are not relocated automatically.\n\n### Export comments\n\nPass comments whose ranges address the editor's proposed projection. Export maps those ranges into accepted or review output before rendering:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n projection: \"review\",\n});\n```\n\nThe same configured static renderer handles the main document and rich comment bodies. Under the default `lossPolicy`, a comment without a complete range fails the export; with `lossPolicy: \"allow\"` the export omits it with a warning.\n\n### Static rendering and styles\n\nDOCX owns its semantic static mappings for code blocks, columns, equations,\ncallouts, headings, and tables of contents. The app supplies presentation CSS\nand may pass a custom root `component` when it needs a different wrapper.\n`DOCX_EXPORT_STYLES` is the registry's copied presentation preset.\n\n\n\n## Format support\n\n| Content | Behavior |\n| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n| Paragraphs, headings, supported marks and properties | Decode through the installed HTML mappings and export through the configured static renderer |\n| Lists, links, bookmarks, rows, cells, and spans | Preserve supported semantic structure; omit Word layout details with diagnostics |\n| Tracked revisions | Preserve supported insert, delete, move, run-property, and paragraph-property changes |\n| Main-body comments | Preserve rich bodies, ranges, author/date metadata, durable IDs, replies, and resolved state when present |\n| Embedded images during import | Omit the resource and return a `resource-omitted` diagnostic |\n| Links and images during export | Write absolute navigation-safe links and PNG, JPEG, GIF, or BMP images; report removed destinations and omitted images |\n| Single-section headers and footers | Keep safe closed source subgraphs during source-aware export; do not map them into the editor document |\n| Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics |\n| Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content |\n| Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON |\n| Custom Plate, math, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics |\n\n## API reference\n\n### Entrypoints\n\n| Surface | Import path |\n| ------------------------------- | --------------------- |\n| `WordPastePlugin` | `platejs/docx/paste` |\n| `importDocx` and import types | `platejs/docx/import` |\n| `exportDocx` and export types | `platejs/docx/export` |\n\n### `importDocx`\n\n```ts\nimportDocx(\n source: ArrayBuffer | Blob,\n options: DocxImportOptions\n): Promise\n```\n\n`DocxImportOptions` requires `plugins` and accepts `schema`, a cooperative\n`signal`, `lossPolicy`, partial `limits` overrides, and\n`retainSource`. Import captures that detached target before its first await and\nnever reads a live editor or mutable plugin store. Every limit must be a\npositive safe integer. Literal `retainSource: true` conditionally adds\n`source: DocxSource | null` to a successful result; omitted or literal `false`\ndoes not.\n\n| Default limit | Value |\n| ------------------ | ------: |\n| `maxInputBytes` | 32 MiB |\n| `maxEntries` | 1,024 |\n| `maxEntryBytes` | 32 MiB |\n| `maxExpandedBytes` | 128 MiB |\n| `maxRelationships` | 4,096 |\n| `maxRevisions` | 2,000 |\n| `maxComments` | 5,000 |\n| `maxXmlDepth` | 128 |\n| `maxXmlNodes` | 500,000 |\n\nA successful result contains `document`, `comments`, and structured `diagnostics`. A retained success also contains `source`, which is `null` when exact export cannot admit the package. A failed result contains only `diagnostics`.\n\n### `DocxSource`\n\n```ts\nclass DocxSource {\n private constructor();\n dispose(): void;\n}\n```\n\nOnly `importDocx` creates a `DocxSource`. The object is process-local and cannot be serialized or reattached. To restore source-aware export after application reload, keep the original file and import it again.\n\n### `exportDocx`\n\n```ts\nexportDocx(\n editor: Editor,\n options: DocxExportOptions\n): Promise\n```\n\n| Option | Type | Description |\n| ----------------------- | ---------------------------------------- | -------------------------------------------------- |\n| `projection` | `'accepted' \\| 'proposed' \\| 'review'` | Required visible document projection |\n| `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates |\n| `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` |\n| `component` | `React.ComponentType` | Optional static editor wrapper |\n| `fontFamily` | `string` | Document body font |\n| `lossPolicy` | `'allow' \\| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` |\n| `margins` | `Margins` | Page margins in twentieths of a point |\n| `orientation` | `'landscape' \\| 'portrait'` | Page orientation |\n| `pageSize` | `PageSize` | Page dimensions in twentieths of a point |\n| `signal` | `AbortSignal` | Cooperative cancellation signal |\n| `source` | `DocxSource \\| null` | Retained source correspondence for exact or safe export |\n| `stylesheet` | `string` | CSS applied before DOCX conversion |\n| `title` | `string` | Document metadata title |\n\nA successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`.\n\nSee [Authored Changes](/docs/plate/authored-changes) for the editor's accepted, proposed, and review projections.\n", + "content": "---\ntitle: DOCX\ndescription: Paste Word content, import bounded DOCX files, and export an explicit document projection.\n---\n\nUse `WordPastePlugin` for clipboard content. Use `importDocx` and `exportDocx` for files. The three operations have separate entrypoints and dependencies.\n\n\n\n\n\n## Features\n\n- Normalize HTML and RTF pasted from Microsoft Word\n- Import a bounded DOCX package into one complete editor document\n- Preserve supported Word revisions and comment ranges as structured data\n- Export one captured editor snapshot as an accepted, proposed, or review DOCX\n\n\n\n\n Paste, file import, and file download use browser APIs. Keep these operations\n in client code. Export uses React static rendering, so pass the descriptors\n and static components that define your document output.\n\n\n## Kit usage\n\nThe app-local `DocxKit` installs `WordPastePlugin`. File import and export are standalone functions.\n\n\n\n### Installation\n\n```bash\nnpm install platejs @tanstack/react-virtual juice validator\n```\n\n### Add kit\n\n\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { createEditor } from \"platejs/react\";\nimport { DocxKit } from \"@/components/editor/docx\";\n\nconst editor = createEditor({\n plugins: [...DocxKit],\n});\n```\n\n\n\n## Paste from Word\n\n`WordPastePlugin` inlines pasted CSS and normalizes Word HTML and RTF before the installed Plate format mappings decode the content.\n\n```tsx title=\"components/editor/editor.tsx\"\nimport { WordPastePlugin } from \"platejs/docx/paste\";\nimport { createEditor } from \"platejs/react\";\n\nconst editor = createEditor({\n plugins: [WordPastePlugin],\n});\n```\n\nClipboard paste does not upload embedded images or preserve Word table geometry. Configure those destination policies in their owning media and table features.\n\n## Import a DOCX file\n\n`importDocx` reads a `Blob` or `ArrayBuffer` without modifying the editor. On success, replace the complete editor value so document metadata, named roots, and authored review state stay coherent.\n\nInside a mounted React control, get that complete editor with\n`useModelEditor()`. `useEditor()` returns the provider-selected mounted command\nview, whose root and authored projection belong to that view.\n\n```bash\nnpm install platejs @tanstack/react-virtual mammoth validator\n```\n\n```tsx title=\"components/editor/import-docx.tsx\"\nimport { importDocx } from \"platejs/docx/import\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n});\n\nexport async function importFile(file: File) {\n const result = await importDocx(file, {\n plugins: BaseEditorKit,\n });\n\n if (!result.ok) {\n return { diagnostics: result.diagnostics };\n }\n\n editor.update.value.replace(result.document);\n\n return {\n comments: result.comments,\n diagnostics: result.diagnostics,\n };\n}\n```\n\nAn expected file, package, limit, or decode failure returns `{ ok: false, diagnostics }` and no document. An aborted import rejects with the signal reason. Invalid options and internal invariant failures throw.\n\n### Retain the source for later export\n\nSet `retainSource: true` when the same editor imports, edits, and exports a Word file. A successful import then includes `source`. It is a `DocxSource` when exact export can return the package unchanged. Otherwise it is `null`, with a `source-unavailable` warning that names the first part exact export cannot admit. Keep the source in application state, pass it to `exportDocx`, and dispose it when the document closes or another file replaces it.\n\n```tsx title=\"components/editor/docx-roundtrip.tsx\" showLineNumbers\nimport { exportDocx } from \"platejs/docx/export\";\nimport { importDocx } from \"platejs/docx/import\";\n\nconst imported = await importDocx(file, {\n plugins: BaseEditorKit,\n retainSource: true,\n});\n\nif (!imported.ok) return imported.diagnostics;\n\neditor.update.value.replace(imported.document);\n\nconst exported = await exportDocx(editor, {\n projection: \"review\",\n source: imported.source,\n});\n\nimported.source?.dispose();\n```\n\nExact export admits a closed set of passive package content:\n\n- the main document at `word/document.xml`, styles, settings, numbering, fonts, and theme;\n- headers, footers, footnotes, endnotes, and comments;\n- core, extended, and custom document properties and a thumbnail;\n- AVIF, BMP, GIF, JPEG, PNG, and WebP images reached through internal relationships;\n- hyperlinks whose destination is absolute and meets the navigation floor.\n\n`PAGE` is the only recognized field instruction. Any other part, relationship, markup namespace, field instruction, or XML processing instruction leaves `source` null. That includes unsafe or relative hyperlinks, other external relationships, macros, ActiveX controls, OLE objects, imported HTML chunks, external templates, custom XML data, and SVG or EMF images. A null source does not mean the import lost content; exporting without a source generates the document from editor content.\n\n`DocxSource` retains the admitted compressed package plus immutable document, comment, schema, and limit correspondence. It does not retain expanded ZIP entries or an editor reference. `dispose()` is idempotent. An export that already acquired the source can finish after disposal; later exports report `source-unavailable` and generate a fresh DOCX.\n\n### Import comments\n\nThe importer returns comment format facts without writing app comment storage. Each `DocxComment` can include a rich Plate `body`, an exact `target.range` in proposed-projection coordinates, Word author and date metadata, a durable ID, a parent ID, and resolved state. Map those records to your app identities explicitly.\n\n### Import tracked revisions\n\nSupported Word insertions, deletions, moves, run property changes, and paragraph property changes become authored changes. Revision IDs follow Word source order and structural dependencies; dates remain metadata. A missing or invalid date uses the authored unknown-time value and produces a diagnostic.\n\nIf a revision cannot map to the installed schema, the importer keeps the Word-visible proposed content and reports the loss in `diagnostics`.\n\n### Import list numbering\n\nNumbered and bulleted Word paragraphs become list items, and each item joins the list of its Word numbering definition. Word counts every instance of a definition in one sequence per level. A list therefore continues past other paragraphs and across instances, and lists of different definitions that interleave keep their own numbers. A level restarts after a paragraph at the level its `lvlRestart` names or a shallower one, which by default is the level just above it. An instance's start override resets its level once, at the instance's first paragraph at that level. That item imports with the override as `listRestart`, and the items after it count on from it, so an inserted item renumbers the rest the way Word does. Each item's `listLevels` hold its definition's level starts, `lvlRestart` rules and level text, with its instance's format and level text overrides applied. Word ignores a start or `lvlRestart` inside a level override, and so does the import. A paragraph whose instance shows its level as bullets imports as a bulleted item and does not count.\n\n## Export a DOCX file\n\n`exportDocx` captures the editor once before asynchronous rendering. Pass the visible projection explicitly.\n\n```bash\nnpm install platejs @tanstack/react-virtual color-name html-to-vdom jszip juice mime-types virtual-dom xmlbuilder2\n```\n\n```tsx title=\"components/editor/export-docx.tsx\"\nimport { exportDocx } from \"platejs/docx/export\";\nimport { createEditor } from \"platejs/react\";\nimport { BaseEditorKit } from \"@/components/editor/plugins-static\";\nimport { DOCX_EXPORT_STYLES } from \"@/components/editor/docx-export\";\n\nconst editor = createEditor({ plugins: BaseEditorKit });\n\nexport async function downloadEditorDocx() {\n const result = await exportDocx(editor, {\n orientation: \"portrait\",\n projection: \"proposed\",\n stylesheet: DOCX_EXPORT_STYLES,\n title: \"Document\",\n });\n\n if (!result.ok) return result.diagnostics;\n\n const url = URL.createObjectURL(result.blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"document.docx\";\n document.body.append(link);\n link.click();\n link.remove();\n URL.revokeObjectURL(url);\n\n return result.diagnostics;\n}\n```\n\nChoose `accepted` to omit pending changes, `proposed` to apply them, or `review` to write Word tracked-change markup. Accepted and proposed exports warn about omitted pending changes and conflicts resolved to one side. A review export with any conflict returns `ok: false` and an `authored-conflict` error because Word revisions cannot represent that state faithfully.\n\nA review file contains what Word displays: text, comments, and tracked revisions with their authors. Importing it restores those revisions as authored changes. Export does not write named roots, document metadata, or exact authored change records, such as their original IDs, to the file. To keep them, save the authored JSON document beside the file, as [Serializing](/docs/plate/serializing) describes:\n\n```tsx\nimport { parseAuthoredDocument, projectAuthoredReview } from \"platejs/authored\";\n\nconst json = JSON.stringify(projectAuthoredReview(editor.read.value()).review);\nconst document = parseAuthoredDocument(json);\n```\n\n### Content loss\n\n`exportDocx` rejects content loss by default. When an image cannot be embedded, a supplied comment has no representable range, or a node has no drawing in `presentation`, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: \"allow\"` to download the file and receive the same loss as warnings:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n lossPolicy: \"allow\",\n projection: \"review\",\n});\n```\n\nNamed roots, document metadata, removed link destinations, and other properties DOCX cannot represent are warnings under both policies. An `accepted` export omits a comment whose annotated content that projection excludes, and reports a warning, as Word does when the annotated suggestion is rejected.\n\n### Lists\n\nEach Plate list becomes one Word list. Its `listLevels` give the list's level starts, `lvlRestart` rules and level text. An item with `listRestart`, or one whose number Word would not reach, gets a numbering instance of its own that overrides that level's start, as Word's Restart Numbering writes it. The items after it count on from it in the list's own instance. Word and a re-import then show the numbers the editor shows, and the restart stays on that item. Word draws one marker style per list level, so an item whose `listStyle` differs from the first item at its level keeps that first style and reports a `list-marker` warning under both policies. A Word list level is numbered or bulleted, as its first item is, and the exporter writes one set of level rules per list. An item of the other kind at its level is therefore not part of its list in Word, and an item whose `listLevels` differ from its list's first item takes that item's rules. Each reports a `list-structure` warning, and `lossPolicy: 'reject'` fails the export. Word has no start that applies only when an item opens its level, so an item with `listStart` exports its shown number and reports a `list-start` warning under both policies.\n\n### Links and images\n\nExport writes a link destination only when it is absolute and meets the navigation floor, such as `https:` or `mailto:`. It writes bookmark links such as `#introduction` as Word anchors. An unsafe destination, or a relative link that has no document base in a Word file, keeps its text without the destination.\n\nImages are embedded as PNG, JPEG, GIF, or BMP. Set `allowRemoteImages: true` to fetch HTTP(S) images during export. These images are omitted and leave their alt text:\n\n- an image with an unsafe, relative, or blob source;\n- a remote image when `allowRemoteImages` is off;\n- an image that cannot be fetched;\n- an image in any other format.\n\nBefore returning a generated file, export checks the written package against the same passive vocabulary that retained sources use. Output outside it is withheld with an `invalid-package` error. An unchanged source-aware export returns the retained file, which passed the same check when it was imported.\n\n### Source-aware export\n\nWith a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, `pageSize`, `stylesheet`, or `fontFamily` option. `component` and `presentation` do not apply on this path, because export regenerates nothing.\n\nIf an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting:\n\n- headers and footers from a document with one section, including their images and hyperlinks;\n- custom document properties.\n\nThe regenerated package has its own core document properties. It omits source body XML, body-only parts such as footnotes and body images, and headers and footers from a document with multiple sections. It also omits the source core and extended properties and the package thumbnail: the core properties, such as the author, title and creation date, the extended properties, which count pages and words and hold details such as the company name, and the thumbnail, which pictures the body. Diagnostics report why the source was rewritten and identify each omitted source part. A custom property linked to body text keeps the value cached in the source file, even when the edit changed that text.\n\nWhen regeneration is required, omitted `comments` means that no comments are generated. Pass the current app-owned comment set explicitly to retain comments after an edit. Imported comment ranges are not relocated automatically.\n\n### Export comments\n\nPass comments whose ranges address the editor's proposed projection. Export maps those ranges into accepted or review output before rendering:\n\n```tsx\nconst result = await exportDocx(editor, {\n comments,\n projection: \"review\",\n});\n```\n\nThe same configured static renderer handles the main document and rich comment bodies. Under the default `lossPolicy`, a comment without a complete range fails the export; with `lossPolicy: \"allow\"` the export omits it with a warning.\n\n### Static rendering and styles\n\nDOCX owns its semantic static mappings for code blocks, columns, equations,\ncallouts, headings, and tables of contents. They set no color, font, or font\nsize beyond an element's own values, such as a callout's color. The app's\n`stylesheet` sets them and can select:\n\n- `editor-`, the class on each drawn element, such as `.editor-codeBlock`;\n- `table > tbody > tr > td` inside a callout, whose first cell holds the icon;\n- `[data-docx-preserve-whitespace]`, the text of a code block;\n- `[data-empty]`, the placeholder of an empty equation or table of contents.\n\nThe app may pass a custom root `component` when it needs a different wrapper.\n`DOCX_EXPORT_STYLES` is the registry's copied presentation preset.\n\nEquations export as Word math built from KaTeX's MathML. An equation that uses\nnotation the converter does not cover, such as a matrix, exports its TeX as\ntext inside a Word equation. A task list item, an `li` with `data-checked`, exports with a ☑ or ☐\nlist marker.\n\nA live editor's components are editing UI. Pass the app's static kit as\n`presentation` so the body and every comment body draw with static components\nof the same plugin name. A plugin with no static version exports its content\nwithout its component or wrapper and reports a `missing-static-presentation`\ndiagnostic, which counts as lost content under `lossPolicy`.\n\n\n\n## Format support\n\n| Content | Behavior |\n| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |\n| Paragraphs, headings, supported marks and properties | Decode through the installed HTML mappings and export through the configured static renderer |\n| Lists, links, bookmarks, rows, cells, and spans | Preserve supported semantic structure; omit Word layout details with diagnostics |\n| Tracked revisions | Preserve supported insert, delete, move, run-property, and paragraph-property changes |\n| Main-body comments | Preserve rich bodies, ranges, author/date metadata, durable IDs, replies, and resolved state when present |\n| Embedded images during import | Omit the resource and return a `resource-omitted` diagnostic |\n| Links and images during export | Write absolute navigation-safe links and PNG, JPEG, GIF, or BMP images; report removed destinations and omitted images |\n| Single-section headers and footers | Keep safe closed source subgraphs during source-aware export; do not map them into the editor document |\n| Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics |\n| Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content |\n| Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON |\n| Equations | Export as Word math, or as TeX inside a Word equation when the converter does not cover the notation. Import reads Word math into `equation` and `inlineEquation` nodes, with TeX rebuilt from the equation's structure, or into TeX text when those plugins are not installed. A display equation that shares its paragraph with a comment, tracked change or text, or sits in a list item or an indented paragraph, imports inline |\n| Task lists | Give an item drawn as an `li` with `data-checked` a ☑ or ☐ list marker |\n| Custom Plate, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics |\n\n## API reference\n\n### Entrypoints\n\n| Surface | Import path |\n| ------------------------------- | --------------------- |\n| `WordPastePlugin` | `platejs/docx/paste` |\n| `importDocx` and import types | `platejs/docx/import` |\n| `exportDocx` and export types | `platejs/docx/export` |\n\n### `importDocx`\n\n```ts\nimportDocx(\n source: ArrayBuffer | Blob,\n options: DocxImportOptions\n): Promise\n```\n\n`DocxImportOptions` requires `plugins` and accepts `schema`, a cooperative\n`signal`, `lossPolicy`, partial `limits` overrides, and\n`retainSource`. Import captures that detached target before its first await and\nnever reads a live editor or mutable plugin store. Every limit must be a\npositive safe integer. Literal `retainSource: true` conditionally adds\n`source: DocxSource | null` to a successful result; omitted or literal `false`\ndoes not.\n\n| Default limit | Value |\n| ------------------ | ------: |\n| `maxInputBytes` | 32 MiB |\n| `maxEntries` | 1,024 |\n| `maxEntryBytes` | 32 MiB |\n| `maxExpandedBytes` | 128 MiB |\n| `maxRelationships` | 4,096 |\n| `maxRevisions` | 2,000 |\n| `maxComments` | 5,000 |\n| `maxXmlDepth` | 128 |\n| `maxXmlNodes` | 500,000 |\n\nA successful result contains `document`, `comments`, and structured `diagnostics`. A retained success also contains `source`, which is `null` when exact export cannot admit the package. A failed result contains only `diagnostics`.\n\n### `DocxSource`\n\n```ts\nclass DocxSource {\n private constructor();\n dispose(): void;\n}\n```\n\nOnly `importDocx` creates a `DocxSource`. The object is process-local and cannot be serialized or reattached. To restore source-aware export after application reload, keep the original file and import it again.\n\n### `exportDocx`\n\n```ts\nexportDocx(\n editor: Editor,\n options: DocxExportOptions\n): Promise\n```\n\n| Option | Type | Description |\n| ----------------------- | ---------------------------------------- | -------------------------------------------------- |\n| `projection` | `'accepted' \\| 'proposed' \\| 'review'` | Required visible document projection |\n| `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates |\n| `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` |\n| `component` | `React.ComponentType` | Optional static editor wrapper |\n| `fontFamily` | `string` | Document body font; rebuilds an unchanged `source` |\n| `lossPolicy` | `'allow' \\| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` |\n| `margins` | `Margins` | Page margins in twentieths of a point |\n| `orientation` | `'landscape' \\| 'portrait'` | Page orientation |\n| `pageSize` | `PageSize` | Page dimensions in twentieths of a point |\n| `presentation` | `readonly BasePluginInput[]` | Static plugins that draw the body and comment bodies, matched by plugin name |\n| `signal` | `AbortSignal` | Cooperative cancellation signal |\n| `source` | `DocxSource \\| null` | Retained source correspondence for exact or safe export |\n| `stylesheet` | `string` | CSS that sets the Word look; rebuilds an unchanged `source` |\n| `title` | `string` | Document metadata title |\n\nA successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`.\n\nSee [Authored Changes](/docs/plate/authored-changes) for the editor's accepted, proposed, and review projections.\n", "type": "registry:file", "target": "content/docs/plate/(plugins)/(serializing)/docx.mdx" } @@ -6941,7 +6941,7 @@ "files": [ { "path": "../../content/docs/examples/export.mdx", - "content": "---\ntitle: Export\ndescription: Export a Plate document to HTML, Markdown, or Word.\ndocs:\n - route: https://pro.platejs.org/docs/examples/export\n title: Plus\n---\n\nThe registry `export-toolbar-button` downloads the current document as HTML,\nMarkdown, or Word from the browser. The demo mounts it in the fixed toolbar.\n\n## Demo\n\n\n\n## Toolbar source\n\nInstall the [Export Toolbar Button](/docs/plate/components/export-toolbar-button)\ncomponent to add the menu to an editor toolbar.\n\n\n\n## Export formats\n\n| Format | Implementation | Output |\n| -------- | -------------- | ------ |\n| HTML | `renderStaticHtml(model, { component: EditorStatic, projection })` from `platejs/static` | `plate.html` |\n| Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` |\n| Word | `exportDocx(model, { component: EditorStatic, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` |\n\nEvery format captures the same complete document and explicit authored\nprojection from `useModelEditor()`. Export reads model content rather than the\nDOM, so selection, placeholders, and review decorations do not enter the file.\nThe current editor supplies its compiled schema, format mappings, and static component\nbindings; export does not create a second conversion editor.\n\nThe HTML file inlines the app's own stylesheets and copies the page's `body`\nclasses. Cross-origin stylesheets stay as links.\n\n## Suggestions\n\nWhen the document has unresolved [suggestions](/docs/plate/suggestion), the menu shows\nan **Unresolved suggestions** choice:\n\n| Choice | Projection | Result |\n| ------------------------- | ---------- | ------------------------------------------- |\n| Include suggested changes | `proposed` | The document with pending suggestions applied |\n| Exclude suggested changes | `accepted` | The document without pending suggestions |\n\nThe initial choice follows the mounted view. HTML, Markdown, and Word use the\nsame choice. **Export as Word with tracked changes** uses `review`, which writes\npending suggestions as Word revisions. A conflict has no faithful Word\nrevision, so review export returns an error until the conflict is resolved.\n\nWithout unresolved suggestions, Word export uses `review`. A retained,\nunchanged imported DOCX can then be returned byte for byte. Each exporter\nreturns diagnostics, which the menu presents as warning or error toasts.\n\n## DOCX presentation\n\nDOCX owns semantic mappings for Plate code blocks, columns, equations,\ncallouts, headings, and tables of contents. The registry keeps only the copied\nvisual stylesheet:\n\n\n\n## Plus export\n\nPlate Plus includes a server-side export flow for PDF output with page settings.\n\n\n\n## Related\n\n- [DOCX](/docs/plate/docx) covers DOCX paste, file import, and export APIs.\n- [Markdown](/docs/plate/markdown) covers Markdown serialization.\n- [Static Rendering](/docs/plate/static) covers static editor rendering and HTML serialization.\n- [Authored Changes](/docs/plate/authored-changes) covers visible HTML/Markdown projections, authored JSON, and DOCX review.\n", + "content": "---\ntitle: Export\ndescription: Export a Plate document to HTML, Markdown, or Word.\ndocs:\n - route: https://pro.platejs.org/docs/examples/export\n title: Plus\n---\n\nThe registry `export-toolbar-button` downloads the current document as HTML,\nMarkdown, or Word from the browser. The demo mounts it in the fixed toolbar.\n\n## Demo\n\n\n\n## Toolbar source\n\nInstall the [Export Toolbar Button](/docs/plate/components/export-toolbar-button)\ncomponent to add the menu to an editor toolbar.\n\n\n\n## Export formats\n\n| Format | Implementation | Output |\n| -------- | -------------- | ------ |\n| HTML | `renderStaticHtml(model, { component: EditorStatic, presentation: BaseEditorKit, projection })` from `platejs/static` | `plate.html` |\n| Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` |\n| Word | `exportDocx(model, { component: EditorStatic, presentation: BaseEditorKit, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` |\n\nEvery format captures the same complete document and explicit authored\nprojection from `useModelEditor()`. Export reads model content rather than the\nDOM, so selection, placeholders, and review decorations do not enter the file.\nThe current editor supplies its compiled schema, format mappings, and plugin\nsettings; the static kit supplies only the drawings.\n\nThe editor's own components are editing UI, so HTML and Word pass the app's\nstatic kit as `presentation`. Export draws each node with the static component\nof the same plugin name. When a plugin has no static component, export draws its\ncontent without one and adds a `missing-static-presentation` warning.\n\nThe HTML file inlines the app's own stylesheets and copies the page's `body`\nclasses. Cross-origin stylesheets stay as links.\n\n## Suggestions\n\nWhen the document has unresolved [suggestions](/docs/plate/suggestion), the menu shows\nan **Unresolved suggestions** choice:\n\n| Choice | Projection | Result |\n| ------------------------- | ---------- | ------------------------------------------- |\n| Include suggested changes | `proposed` | The document with pending suggestions applied |\n| Exclude suggested changes | `accepted` | The document without pending suggestions |\n\nThe initial choice follows the mounted view. HTML, Markdown, and Word use the\nsame choice. **Export as Word with tracked changes** uses `review`, which writes\npending suggestions as Word revisions. A conflict has no faithful Word\nrevision, so review export returns an error until the conflict is resolved.\n\nWithout unresolved suggestions, Word export uses `review`. A retained,\nunchanged imported DOCX can then be returned byte for byte. Each exporter\nreturns diagnostics, which the menu presents as warning or error toasts.\n\n## DOCX presentation\n\nDOCX owns semantic mappings for Plate code blocks, columns, equations,\ncallouts, headings, and tables of contents. The registry keeps only the copied\nvisual stylesheet:\n\n\n\n## Plus export\n\nPlate Plus includes a server-side export flow for PDF output with page settings.\n\n\n\n## Related\n\n- [DOCX](/docs/plate/docx) covers DOCX paste, file import, and export APIs.\n- [Markdown](/docs/plate/markdown) covers Markdown serialization.\n- [Static Rendering](/docs/plate/static) covers static editor rendering and HTML serialization.\n- [Authored Changes](/docs/plate/authored-changes) covers visible HTML/Markdown projections, authored JSON, and DOCX review.\n", "type": "registry:file", "target": "content/docs/plate/examples/export.mdx" } diff --git a/apps/www/public/r/serializing-docs.json b/apps/www/public/r/serializing-docs.json index 1c8264a5b5..a26a8ebd29 100644 --- a/apps/www/public/r/serializing-docs.json +++ b/apps/www/public/r/serializing-docs.json @@ -6,7 +6,7 @@ "files": [ { "path": "../../content/docs/(guides)/serializing.mdx", - "content": "---\ntitle: Serializing\ndescription: Save complete Plate documents and convert them to text, HTML, Markdown, or DOCX.\n---\n\nSave Plate JSON when another Plate editor must recover the editable document.\nUse a format parser or serializer when content crosses an application boundary.\n\n## Choose a surface\n\n| Task | Surface |\n| --- | --- |\n| Persist the complete editor document | `editor.read.value()` |\n| Persist exact proposals and decisions | `projectAuthoredReview` and `parseAuthoredDocument` |\n| Convert semantic HTML | `platejs/html`; Node parsing uses `platejs/html/server` |\n| Convert Markdown | `platejs/markdown` |\n| Import or export Word | `platejs/docx/import` and `platejs/docx/export` |\n| Render component-styled HTML | `platejs/static` |\n| Exchange browser clipboard MIME payloads | `DataTransferFormat` through `platejs/dom` |\n\nThere is no universal document converter. Each format owns its syntax,\ndiagnostics, loss policy, limits, and retained source.\n\n## Save Plate JSON\n\n`editor.read.value()` includes the primary children, named roots, and persistent\nmetadata.\n\n```ts\nconst document = editor.read.value();\nconst schema = editor.read.schema.identity();\n\nawait saveDocument({ document, schema });\n```\n\nSaving only `children` drops roots and metadata. Persist an application schema\nidentity when stored documents require versioned migration. Validate migrated\ndata before publishing it to an editor.\n\nExact authored review state is canonical JSON-compatible model data:\n\n```ts\nimport { parseAuthoredDocument, projectAuthoredReview } from 'platejs/authored';\n\nconst snapshot = projectAuthoredReview(editor.read.value());\nconst json = JSON.stringify(snapshot.review);\nconst document = parseAuthoredDocument(json);\n```\n\nHTML, Markdown, and DOCX do not hide an authored document in their output.\n\n## Serialize plain text\n\n```ts\nimport { serializePlainText } from 'platejs';\n\nconst result = serializePlainText(editor, { projection: 'accepted' });\n```\n\nFeature plugins contribute structural `plainText` mappings through\n`formats`/`defineFormats`. Plain text is one-way output; it does not become a\nuniversal parser or persistence format.\n\n## Convert HTML\n\nUse direct functions for detached work:\n\n```ts\nimport { parseHtml, serializeHtml } from 'platejs/html';\n\nconst parsed = parseHtml(source, { plugins: EditorKit });\nconst output = serializeHtml(document, {\n plugins: EditorKit,\n projection: 'accepted',\n});\n```\n\nIn Node.js, import `parseHtml` from `platejs/html/server`. Install `linkedom`\nfor that server-only DOM adapter.\n\nInstall `HtmlPlugin` when a configured editor should expose\n`editor.api.html.parse`, `parseSlice`, and `serialize`. See [HTML](/docs/plate/html).\n\n## Convert Markdown\n\n```ts\nimport { parseMarkdown, serializeMarkdown } from 'platejs/markdown';\n\nconst parsed = parseMarkdown(source, { plugins: EditorKit });\nconst output = serializeMarkdown(document, {\n plugins: EditorKit,\n projection: 'proposed',\n});\n```\n\nInstall `MarkdownPlugin` for `editor.api.markdown.parse`, `parseSlice`,\n`parseInline`, and `serialize`. See [Markdown](/docs/plate/markdown).\n\n## Convert DOCX\n\nDOCX import is detached. Supply the complete plugin target before the operation\nstarts, then publish a successful document explicitly.\n\n```ts\nimport { importDocx } from 'platejs/docx/import';\n\nconst result = await importDocx(file, { plugins: EditorKit });\n\nif (result.ok) {\n editor.update((tx) => tx.value.replace(result.document));\n}\n```\n\nDOCX export captures a configured editor because static rendering, comments,\nretained source, and Word revisions are part of its package workflow.\n\n```ts\nimport { exportDocx } from 'platejs/docx/export';\n\nconst result = await exportDocx(editor, { projection: 'review' });\n```\n\nA Word file contains the visible Word document, its comments, and its tracked\nrevisions. Save Plate JSON beside it when another Plate editor must recover\nnamed roots, metadata, or exact authored change records.\n\nSee [DOCX](/docs/plate/docx) for cancellation, package limits, comments, and retained\nsource.\n\n## Handle results\n\nHTML, Markdown, and DOCX use format-specific diagnosed results. Success carries\nwarnings and the requested document, slice, string, or Blob. Failure carries a\nnonempty error-first diagnostic tuple and no publishable carrier.\n\n```ts\nconst result = editor.api.markdown.serialize({ projection: 'accepted' });\n\nshowDiagnostics(result.diagnostics);\nif (!result.ok) return;\n\ndownloadMarkdown(result.data);\n```\n\nExpected source failure and representational loss return diagnostics.\nConfiguration errors, mapping bugs, and invalid serializer model data throw.\n`lossPolicy` defaults to `'reject'`; opt into `'allow'` only when the caller\naccepts the exact reported loss.\n\n## Define feature mappings\n\nFeature plugins declare semantic mappings through `formats` and\n`defineFormats` using the `html`, `markdown`, and `plainText` keys. The narrow\nauthoring callback cannot read a live editor or store.\n\nWhole browser MIME payloads use `DataTransferFormat`, not feature mappings.\nClipboard insertion remains destination-aware and can preserve native open\nedges and detached roots. See [Plugin Methods](/docs/plate/plugin-methods) and\n[Clipboard](/docs/plate/clipboard).\n\n## Document and slice authority\n\nA complete parse returns `EditorDocumentValue`. Direct syntax slice or inline\nparsing returns a closed, rootless `ContentSlice`. The insertion owner fits that\nslice once the destination is known.\n\n```ts\nconst result = editor.api.markdown.parseSlice(source);\n\nif (result.ok) editor.update.slice.replace(result.slice);\n```\n\nDo not turn a complete document into `children` before replacement; that drops\nnamed roots and metadata. Do not invent a destination during detached parsing.\n", + "content": "---\ntitle: Serializing\ndescription: Save complete Plate documents and convert them to text, HTML, Markdown, or DOCX.\n---\n\nSave Plate JSON when another Plate editor must recover the editable document.\nUse a format parser or serializer when content crosses an application boundary.\n\n## Choose a surface\n\n| Task | Surface |\n| --- | --- |\n| Persist the complete editor document | `editor.read.value()` |\n| Persist exact proposals and decisions | `projectAuthoredReview` and `parseAuthoredDocument` |\n| Convert semantic HTML | `platejs/html`; Node parsing uses `platejs/html/server` |\n| Convert Markdown | `platejs/markdown` |\n| Import or export Word | `platejs/docx/import` and `platejs/docx/export` |\n| Render component-styled HTML | `platejs/static` |\n| Exchange browser clipboard MIME payloads | `DataTransferFormat` through `platejs/dom` |\n\nThere is no universal document converter. Each format owns its syntax,\ndiagnostics, loss policy, limits, and retained source.\n\n## Save Plate JSON\n\n`editor.read.value()` includes the primary children, named roots, and persistent\nmetadata.\n\n```ts\nconst document = editor.read.value();\nconst schema = editor.read.schema.identity();\n\nawait saveDocument({ document, schema });\n```\n\nSaving only `children` drops roots and metadata. Persist an application schema\nidentity when stored documents require versioned migration. Validate migrated\ndata before publishing it to an editor.\n\nExact authored review state is canonical JSON-compatible model data:\n\n```ts\nimport { parseAuthoredDocument, projectAuthoredReview } from 'platejs/authored';\n\nconst snapshot = projectAuthoredReview(editor.read.value());\nconst json = JSON.stringify(snapshot.review);\nconst document = parseAuthoredDocument(json);\n```\n\nHTML, Markdown, and DOCX do not hide an authored document in their output.\n\n## Serialize plain text\n\n```ts\nimport { serializePlainText } from 'platejs';\n\nconst result = serializePlainText(editor, { projection: 'accepted' });\n```\n\nFeature plugins contribute structural `plainText` mappings through\n`formats`/`defineFormats`. Plain text is one-way output; it does not become a\nuniversal parser or persistence format.\n\n## Convert HTML\n\nUse direct functions for detached work:\n\n```ts\nimport { parseHtml, serializeHtml } from 'platejs/html';\n\nconst parsed = parseHtml(source, { plugins: EditorKit });\nconst output = serializeHtml(document, {\n plugins: EditorKit,\n projection: 'accepted',\n});\n```\n\nIn Node.js, import `parseHtml` from `platejs/html/server`. Install `linkedom`\nfor that server-only DOM adapter.\n\nInstall `HtmlPlugin` when a configured editor should expose\n`editor.api.html.parse`, `parseSlice`, and `serialize`. See [HTML](/docs/plate/html).\n\n## Convert Markdown\n\n```ts\nimport { parseMarkdown, serializeMarkdown } from 'platejs/markdown';\n\nconst parsed = parseMarkdown(source, { plugins: EditorKit });\nconst output = serializeMarkdown(document, {\n plugins: EditorKit,\n projection: 'proposed',\n});\n```\n\nInstall `MarkdownPlugin` for `editor.api.markdown.parse`, `parseSlice`,\n`parseInline`, and `serialize`. See [Markdown](/docs/plate/markdown).\n\n## Convert DOCX\n\nDOCX import is detached. Supply the complete plugin target before the operation\nstarts, then publish a successful document explicitly.\n\n```ts\nimport { importDocx } from 'platejs/docx/import';\n\nconst result = await importDocx(file, { plugins: EditorKit });\n\nif (result.ok) {\n editor.update((tx) => tx.value.replace(result.document));\n}\n```\n\nDOCX export captures a configured editor because static rendering, comments,\nretained source, and Word revisions are part of its package workflow.\n\n```ts\nimport { exportDocx } from 'platejs/docx/export';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst result = await exportDocx(editor, {\n presentation: BaseEditorKit,\n projection: 'review',\n});\n```\n\nA live editor's components are editing UI, so pass the app's static kit as\n`presentation` to draw the file.\n\nA Word file contains the visible Word document, its comments, and its tracked\nrevisions. Save Plate JSON beside it when another Plate editor must recover\nnamed roots, metadata, or exact authored change records.\n\nSee [DOCX](/docs/plate/docx) for cancellation, package limits, comments, and retained\nsource.\n\n## Handle results\n\nHTML, Markdown, and DOCX use format-specific diagnosed results. Success carries\nwarnings and the requested document, slice, string, or Blob. Failure carries a\nnonempty error-first diagnostic tuple and no publishable carrier.\n\n```ts\nconst result = editor.api.markdown.serialize({ projection: 'accepted' });\n\nshowDiagnostics(result.diagnostics);\nif (!result.ok) return;\n\ndownloadMarkdown(result.data);\n```\n\nExpected source failure and representational loss return diagnostics.\nConfiguration errors, mapping bugs, and invalid serializer model data throw.\n`lossPolicy` defaults to `'reject'`; opt into `'allow'` only when the caller\naccepts the exact reported loss.\n\n## Define feature mappings\n\nFeature plugins declare semantic mappings through `formats` and\n`defineFormats` using the `html`, `markdown`, and `plainText` keys. The narrow\nauthoring callback cannot read a live editor or store.\n\nWhole browser MIME payloads use `DataTransferFormat`, not feature mappings.\nClipboard insertion remains destination-aware and can preserve native open\nedges and detached roots. See [Plugin Methods](/docs/plate/plugin-methods) and\n[Clipboard](/docs/plate/clipboard).\n\n## Document and slice authority\n\nA complete parse returns `EditorDocumentValue`. Direct syntax slice or inline\nparsing returns a closed, rootless `ContentSlice`. The insertion owner fits that\nslice once the destination is known.\n\n```ts\nconst result = editor.api.markdown.parseSlice(source);\n\nif (result.ok) editor.update.slice.replace(result.slice);\n```\n\nDo not turn a complete document into `children` before replacement; that drops\nnamed roots and metadata. Do not invent a destination during detached parsing.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/serializing.mdx" } diff --git a/apps/www/public/r/static-docs.json b/apps/www/public/r/static-docs.json index 9743f09d68..10a023c1d9 100644 --- a/apps/www/public/r/static-docs.json +++ b/apps/www/public/r/static-docs.json @@ -6,7 +6,7 @@ "files": [ { "path": "../../content/docs/(guides)/static.mdx", - "content": "---\ntitle: Static Rendering\ndescription: A minimal, memoized, read-only version of Plate with RSC/SSR support.\n---\n\n`` is a **fast, read-only** React component for rendering Plate content, optimized for **server-side** or **React Server Component** (RSC) environments. It avoids client-side editing logic and memoizes node renders for better performance compared to using [``](/docs/plate/api/core/plate-components) in read-only mode.\n\nIt is the rendering path behind `renderStaticHtml` and fits server or RSC\nsurfaces that need a non-interactive Plate view.\n\n## Key advantages\n\n- **Server-Safe:** No browser API dependencies; works in SSR/RSC.\n- **No Plate Editor Overhead:** Excludes interactive features like selections or event handlers.\n- **Memoized Rendering:** Uses structural identity to re-render only changed nodes.\n- **Partial Re-Renders:** Changes in one part of the document don't force a full re-render.\n- **Lightweight:** Smaller bundle size as it omits interactive editor code.\n\n## When to use ``\n\n- Generating HTML with [HTML Serialization](/docs/plate/html).\n- Displaying server-rendered previews in Next.js (especially with RSC).\n- Building static sites with read-only Plate content.\n- Optimizing performance-critical read-only views.\n- Rendering AI-streaming content.\n\n\n For interactive read-only features (like comment popovers or selections), use the standard `` component in the browser. For purely server-rendered, non-interactive content, `` is the recommended choice.\n\n\n## Kit usage\n\n\n\n### Installation\n\n`BaseEditorKit` includes base plugins configured for static rendering.\n\n\n\n### Add kit\n\n```tsx\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n initialValue: [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'This content is rendered statically.' }] },\n ],\n});\n\n// Render statically\nexport default function MyStaticPage() {\n return ;\n}\n```\n\n### Example\n\nSee a complete server-side static rendering example:\n\n\n\n\n\n## Manual usage\n\n\n\n### Create a Plate editor\n\nInitialize a Plate editor instance using `createEditor` with component-bound plugins. This is analogous to using `useCreateEditor` for the interactive `` component.\n\n```tsx title=\"lib/plate-static-editor.ts\"\nimport { createEditor } from 'platejs';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n// Import any other desired base plugins, such as MarkdownPlugin.\n// Ensure you are NOT importing from /react subpaths for server environments.\n\nconst editor = createEditor({\n plugins: [\n ...BaseBasicBlocksKit,\n // Add other base plugins here.\n ],\n initialValue: [\n {\n type: 'paragraph',\n children: [{ text: 'Hello from a static Plate editor!' }],\n },\n ],\n});\n```\n\n### Define static node components\n\nIf your interactive editor uses client-side components (e.g., with `use client` or event handlers), you **must** create static, server-safe equivalents. These components should render pure HTML without browser-specific logic.\n\n```tsx title=\"components/editor/paragraph-static.tsx\"\nimport React from 'react';\nimport type { BaseParagraphPlugin } from 'platejs';\nimport { EditorElement, type EditorElementProps } from 'platejs/static';\n\nexport function ParagraphElementStatic(\n props: EditorElementProps\n) {\n return (\n \n {props.children}\n \n );\n}\n```\nCreate similar static components for headings, images, links, etc.\n\n### Bind static components\n\nConfigure each server-safe component on its owning base plugin.\n\n```ts title=\"components/static-editor-kit.ts\"\nimport { BaseHeadingPlugin, BaseParagraphPlugin } from 'platejs';\nimport { ParagraphElementStatic } from './ui/paragraph-static';\nimport { HeadingElementStatic } from './ui/heading-static';\n\nexport const StaticEditorKit = [\n BaseParagraphPlugin.configure({ component: ParagraphElementStatic }),\n BaseHeadingPlugin.configure({ component: HeadingElementStatic }),\n];\n```\n\n### Render ``\n\nUse `` with an editor whose plugin descriptors own their static components.\n\n```tsx title=\"app/my-static-page/page.tsx (RSC Example)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { StaticEditorKit } from '@/components/static-editor-kit';\n\nexport default async function MyStaticPage() {\n // Example: Fetch or define editor value\n const initialValue = [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'Content rendered statically.' }] },\n ];\n\n const editor = createEditor({\n plugins: StaticEditorKit,\n initialValue,\n });\n\n return (\n \n );\n}\n```\n\n### Memoization details\n\n`` enhances performance through memoization:\n- Static element and leaf rendering uses `React.memo`.\n- **Reference Equality:** A block renders again only when it, a block\n before it, or a decoration inside it changed, so a renderer may read its\n own block and earlier ones, such as list numbering.\n- **Later content:** An element whose rendering reads content after it, such\n as a table of contents, declares `render: { readsDocument: true }` on its\n plugin and renders again whenever the document changes.\n\n\n\n## Render a document\n\nPass `document` to render a document through the editor's plugins without\nediting the editor: no transaction, history or selection change. One configured\neditor can render a streamed preview or a stored revision this way:\n\n```tsx\n\n```\n\nThe document must be valid for the editor's schema. The editor validates a\ndocument from `editor.read.value()` once and any other document object on every\nrender, because that object can change between renders.\n`EditorPreview` takes the same `document`, and copying from it copies the\nrendered document.\n\nEach render reads the decorations of every top-level block, so rendering again\nis also how decorations that depend on other state refresh. A component that\nre-renders for other reasons, such as each streamed chunk, should memoize the\nelement on its document:\n\n```tsx\nconst preview = useMemo(\n () => ,\n [editor, document]\n);\n```\n\nEverything read through the rendered editor sees the document: `editor.read`,\nplugin reads such as `editor.plugin(TocPlugin).read.headings()`, and plugin\nAPIs. Plugins get that editor from their render, decorate, read or API\ncontext. An editor captured when a plugin was created, as in\n`.extend(({ editor }) => …)`, is the editor you passed and still holds its own\nvalue. Reading it from plugin code that reads, decorates or renders the\ndocument throws an error that names the fix. React runs component bodies after\nthat code returns, so a component reads through its `editor` prop.\n\n## Client-side alternative: `EditorPreview`\n\nFor cases where you need **minimal interactivity** with static content, use ``. This component wraps `` and adds client-side event handlers for user interactions while maintaining the performance benefits of static rendering.\n\n### Example: server component with both static views\n\n```tsx title=\"app/document/page.tsx\"\nimport { createStaticEditor, EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\nimport { InteractiveViewer } from './interactive-viewer';\n\nexport default async function DocumentPage() {\n const content = await fetchDocument(); // Your document data\n // Server-side static editor\n const editor = createStaticEditor({\n plugins: BaseEditorKit,\n initialValue: content,\n });\n\n return (\n
\n {/* Pure static rendering - no interactivity */}\n
\n

Static View (Server Rendered)

\n \n
\n\n {/* Interactive view - rendered on client */}\n
\n

Interactive View

\n \n
\n
\n );\n}\n```\n\n### Example: client component with EditorPreview\n\n```tsx title=\"app/document/interactive-viewer.tsx\"\n'use client';\n\nimport { EditorPreview, useStaticEditor } from 'platejs/react';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nexport function InteractiveViewer({ value }) {\n const editor = useStaticEditor({\n plugins: BaseEditorKit,\n initialValue: value,\n });\n\n return ;\n}\n```\n\n### Key features of `EditorPreview`\n\n- **Client-side only**: Requires `'use client'` directive\n- **Adds interactivity**: Enables user interactions with the content (e.g., text selection, copying, future interactions like tooltips, highlights, etc.)\n- **Minimal overhead**: Still uses `EditorStatic` internally for rendering\n- **Use with `useStaticEditor`**: Creates a static editor optimized for view-only React components\n- **Exact copy payloads**: `EditorPreview` resolves selection against its own\n static host and writes the same model slice, HTML, and plain-text payloads as\n a mounted editor\n\n\n `EditorPreview` cannot be used in Server Components. If you're passing an editor from a server component to a client component, you'll encounter serialization errors. Use `EditorStatic` on the server side, or create the editor client-side with `useStaticEditor`.\n\n\n## `EditorStatic` vs. `EditorPreview` vs. `EditorRoot` + `readOnly`\n\n| Aspect | `` | `` | `` + `readOnly` |\n| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |\n| **Environment** | Server/Client (SSR/RSC safe) | Client-only | Client-only |\n| **Interactivity** | None | Minimal (selection, copy, toolbar, etc.) | Full interactive features (browser-only) |\n| **Browser APIs** | Not used | Minimal (event handlers) | Full usage |\n| **Performance** | Best - static HTML only | Good - static rendering + event delegation | Heavier - full editor internals |\n| **Bundle Size** | Smallest | Small | Largest |\n| **Use Cases** | Server rendering, HTML export | Client-side content with basic interactions | Full read-only editor with all features |\n| **Recommendation** | SSR/RSC without any interactions | Client-side content needing light interactivity | Client-side with complex interactive needs |\n\n## RSC/SSR example\n\nIn a Next.js App Router (or similar RSC environment), `` can be used directly in Server Components:\n\n```tsx title=\"app/preview/page.tsx (RSC)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nexport default async function Page() {\n // Fetch or define content server-side\n const serverContent = [\n { type: 'heading', level: 1, children: [{ text: 'Rendered on the Server! 🎉' }] },\n { type: 'paragraph', children: [{ text: 'This content is static and server-rendered.' }] },\n ];\n\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: serverContent,\n });\n\n return (\n \n );\n}\n```\nThis renders the content to HTML on the server without needing a client-side JavaScript bundle for `EditorStatic` itself.\n\n## Pairing with `renderStaticHtml`\n\nFor server-rendering the static Plate tree to an HTML string, use\n`renderStaticHtml`. It renders `` through React DOM Server; it is\nnot a semantic HTML interchange serializer.\n\n`EditorStatic` renders configured node components and render attributes. It\nomits editing-surface slots such as `slots.wrapContent`; wrap `result.data` in\napplication presentation when the exported document needs an outer shell.\n\n```ts title=\"lib/html-serializer.ts\"\nimport { createEditor } from 'platejs';\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nasync function getDocumentAsHtml(value: any[]) {\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: value,\n });\n\n const result = await renderStaticHtml(editor, {\n // component: EditorStatic, // Optional: Defaults to EditorStatic\n props: { className: 'prose max-w-none' }, // Example: Pass props to the root div\n });\n\n return result.data;\n}\n\n// Example Usage:\n// const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ];\n// getDocumentAsHtml(value).then(console.log);\n```\nFor more details, see the [HTML Serialization guide](/docs/plate/html).\n\n## API Reference\n\n### `` props\n\n```ts\nimport type React from 'react';\nimport type { Editor, EditorDocumentValue } from 'platejs';\n\ninterface EditorStaticProps\n extends React.HTMLAttributes {\n /**\n * A document to render instead of the editor's own. The editor supplies\n * the plugins and is not edited.\n */\n document?: EditorDocumentValue;\n\n /**\n * The Plate editor instance, created via `createEditor`.\n * Must include component-bound plugins relevant to the rendered content.\n */\n editor: E;\n\n /** Inline CSS styles for the root `div` element. */\n style?: React.CSSProperties;\n\n // Other HTMLDivElement attributes like `className`, `id`, etc., are also supported.\n}\n```\n\n- **`document`**: A document rendered through the editor's plugins instead of the editor's own value; see [Render a document](#render-a-document).\n- **`editor`**: An editor created with `createEditor` or `createStaticEditor`, including component-bound plugins required by the value.\n\n## Next steps\n\n- Explore [HTML Serialization](/docs/plate/html) for exporting content.\n- Learn about using Plate in [React Server Components](/docs/plate/installation/rsc).\n- Refer to individual plugin documentation for their base (non-React) imports.\n", + "content": "---\ntitle: Static Rendering\ndescription: A minimal, memoized, read-only version of Plate with RSC/SSR support.\n---\n\n`` is a **fast, read-only** React component for rendering Plate content, optimized for **server-side** or **React Server Component** (RSC) environments. It avoids client-side editing logic and memoizes node renders for better performance compared to using [``](/docs/plate/api/core/plate-components) in read-only mode.\n\nIt is the rendering path behind `renderStaticHtml` and fits server or RSC\nsurfaces that need a non-interactive Plate view.\n\n## Key advantages\n\n- **Server-Safe:** No browser API dependencies; works in SSR/RSC.\n- **No Plate Editor Overhead:** Excludes interactive features like selections or event handlers.\n- **Memoized Rendering:** Uses structural identity to re-render only changed nodes.\n- **Partial Re-Renders:** Changes in one part of the document don't force a full re-render.\n- **Lightweight:** Smaller bundle size as it omits interactive editor code.\n\n## When to use ``\n\n- Generating HTML with [HTML Serialization](/docs/plate/html).\n- Displaying server-rendered previews in Next.js (especially with RSC).\n- Building static sites with read-only Plate content.\n- Optimizing performance-critical read-only views.\n- Rendering AI-streaming content.\n\n\n For interactive read-only features (like comment popovers or selections), use the standard `` component in the browser. For purely server-rendered, non-interactive content, `` is the recommended choice.\n\n\n## Kit usage\n\n\n\n### Installation\n\n`BaseEditorKit` includes base plugins configured for static rendering.\n\n\n\n### Add kit\n\n```tsx\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst editor = createEditor({\n plugins: BaseEditorKit,\n initialValue: [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'This content is rendered statically.' }] },\n ],\n});\n\n// Render statically\nexport default function MyStaticPage() {\n return ;\n}\n```\n\n### Example\n\nSee a complete server-side static rendering example:\n\n\n\n\n\n## Manual usage\n\n\n\n### Create a Plate editor\n\nInitialize a Plate editor instance using `createEditor` with component-bound plugins. This is analogous to using `useCreateEditor` for the interactive `` component.\n\n```tsx title=\"lib/plate-static-editor.ts\"\nimport { createEditor } from 'platejs';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n// Import any other desired base plugins, such as MarkdownPlugin.\n// Ensure you are NOT importing from /react subpaths for server environments.\n\nconst editor = createEditor({\n plugins: [\n ...BaseBasicBlocksKit,\n // Add other base plugins here.\n ],\n initialValue: [\n {\n type: 'paragraph',\n children: [{ text: 'Hello from a static Plate editor!' }],\n },\n ],\n});\n```\n\n### Define static node components\n\nIf your interactive editor uses client-side components (e.g., with `use client` or event handlers), you **must** create static, server-safe equivalents. These components should render pure HTML without browser-specific logic.\n\n```tsx title=\"components/editor/paragraph-static.tsx\"\nimport React from 'react';\nimport type { BaseParagraphPlugin } from 'platejs';\nimport { EditorElement, type EditorElementProps } from 'platejs/static';\n\nexport function ParagraphElementStatic(\n props: EditorElementProps\n) {\n return (\n \n {props.children}\n \n );\n}\n```\nCreate similar static components for headings, images, links, etc.\n\n### Bind static components\n\nConfigure each server-safe component on its owning base plugin.\n\n```ts title=\"components/static-editor-kit.ts\"\nimport { BaseHeadingPlugin, BaseParagraphPlugin } from 'platejs';\nimport { ParagraphElementStatic } from './ui/paragraph-static';\nimport { HeadingElementStatic } from './ui/heading-static';\n\nexport const StaticEditorKit = [\n BaseParagraphPlugin.configure({ component: ParagraphElementStatic }),\n BaseHeadingPlugin.configure({ component: HeadingElementStatic }),\n];\n```\n\n### Render ``\n\nUse `` with an editor whose plugin descriptors own their static components.\n\n```tsx title=\"app/my-static-page/page.tsx (RSC Example)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { StaticEditorKit } from '@/components/static-editor-kit';\n\nexport default async function MyStaticPage() {\n // Example: Fetch or define editor value\n const initialValue = [\n { type: 'heading', level: 1, children: [{ text: 'Server-Rendered Title' }] },\n { type: 'paragraph', children: [{ text: 'Content rendered statically.' }] },\n ];\n\n const editor = createEditor({\n plugins: StaticEditorKit,\n initialValue,\n });\n\n return (\n \n );\n}\n```\n\n### Memoization details\n\n`` enhances performance through memoization:\n- Static element and leaf rendering uses `React.memo`.\n- **Reference Equality:** A block renders again only when it, a block\n before it, or a decoration inside it changed, so a renderer may read its\n own block and earlier ones, such as list numbering.\n- **Later content:** An element whose rendering reads content after it, such\n as a table of contents, declares `render: { readsDocument: true }` on its\n plugin and renders again whenever the document changes.\n\n\n\n## Render a document\n\nPass `document` to render a document through the editor's plugins without\nediting the editor: no transaction, history or selection change. One configured\neditor can render a streamed preview or a stored revision this way:\n\n```tsx\n\n```\n\nThe document must be valid for the editor's schema. The editor validates a\ndocument from `editor.read.value()` once and any other document object on every\nrender, because that object can change between renders.\n`EditorPreview` takes the same `document`, and copying from it copies the\nrendered document.\n\nEach render reads the decorations of every top-level block, so rendering again\nis also how decorations that depend on other state refresh. A component that\nre-renders for other reasons, such as each streamed chunk, should memoize the\nelement on its document:\n\n```tsx\nconst preview = useMemo(\n () => ,\n [editor, document]\n);\n```\n\nEverything read through the rendered editor sees the document: `editor.read`,\nplugin reads such as `editor.plugin(TocPlugin).read.headings()`, and plugin\nAPIs. Plugins get that editor from their render, decorate, read or API\ncontext. An editor captured when a plugin was created, as in\n`.extend(({ editor }) => …)`, is the editor you passed and still holds its own\nvalue. Reading it from plugin code that reads, decorates or renders the\ndocument throws an error that names the fix. React runs component bodies after\nthat code returns, so a component reads through its `editor` prop.\n\n## Client-side alternative: `EditorPreview`\n\nFor cases where you need **minimal interactivity** with static content, use ``. This component wraps `` and adds client-side event handlers for user interactions while maintaining the performance benefits of static rendering.\n\n### Example: server component with both static views\n\n```tsx title=\"app/document/page.tsx\"\nimport { createStaticEditor, EditorStatic } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\nimport { InteractiveViewer } from './interactive-viewer';\n\nexport default async function DocumentPage() {\n const content = await fetchDocument(); // Your document data\n // Server-side static editor\n const editor = createStaticEditor({\n plugins: BaseEditorKit,\n initialValue: content,\n });\n\n return (\n
\n {/* Pure static rendering - no interactivity */}\n
\n

Static View (Server Rendered)

\n \n
\n\n {/* Interactive view - rendered on client */}\n
\n

Interactive View

\n \n
\n
\n );\n}\n```\n\n### Example: client component with EditorPreview\n\n```tsx title=\"app/document/interactive-viewer.tsx\"\n'use client';\n\nimport { EditorPreview, useStaticEditor } from 'platejs/react';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nexport function InteractiveViewer({ value }) {\n const editor = useStaticEditor({\n plugins: BaseEditorKit,\n initialValue: value,\n });\n\n return ;\n}\n```\n\n### Key features of `EditorPreview`\n\n- **Client-side only**: Requires `'use client'` directive\n- **Adds interactivity**: Enables user interactions with the content (e.g., text selection, copying, future interactions like tooltips, highlights, etc.)\n- **Minimal overhead**: Still uses `EditorStatic` internally for rendering\n- **Use with `useStaticEditor`**: Creates a static editor optimized for view-only React components\n- **Exact copy payloads**: `EditorPreview` resolves selection against its own\n static host and writes the same model slice, HTML, and plain-text payloads as\n a mounted editor\n\n\n `EditorPreview` cannot be used in Server Components. If you're passing an editor from a server component to a client component, you'll encounter serialization errors. Use `EditorStatic` on the server side, or create the editor client-side with `useStaticEditor`.\n\n\n## `EditorStatic` vs. `EditorPreview` vs. `EditorRoot` + `readOnly`\n\n| Aspect | `` | `` | `` + `readOnly` |\n| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |\n| **Environment** | Server/Client (SSR/RSC safe) | Client-only | Client-only |\n| **Interactivity** | None | Minimal (selection, copy, toolbar, etc.) | Full interactive features (browser-only) |\n| **Browser APIs** | Not used | Minimal (event handlers) | Full usage |\n| **Performance** | Best - static HTML only | Good - static rendering + event delegation | Heavier - full editor internals |\n| **Bundle Size** | Smallest | Small | Largest |\n| **Use Cases** | Server rendering, HTML export | Client-side content with basic interactions | Full read-only editor with all features |\n| **Recommendation** | SSR/RSC without any interactions | Client-side content needing light interactivity | Client-side with complex interactive needs |\n\n## RSC/SSR example\n\nIn a Next.js App Router (or similar RSC environment), `` can be used directly in Server Components:\n\n```tsx title=\"app/preview/page.tsx (RSC)\"\nimport { createEditor } from 'platejs';\nimport { EditorStatic } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nexport default async function Page() {\n // Fetch or define content server-side\n const serverContent = [\n { type: 'heading', level: 1, children: [{ text: 'Rendered on the Server! 🎉' }] },\n { type: 'paragraph', children: [{ text: 'This content is static and server-rendered.' }] },\n ];\n\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: serverContent,\n });\n\n return (\n \n );\n}\n```\nThis renders the content to HTML on the server without needing a client-side JavaScript bundle for `EditorStatic` itself.\n\n## Pairing with `renderStaticHtml`\n\nFor server-rendering the static Plate tree to an HTML string, use\n`renderStaticHtml`. It renders `` through React DOM Server; it is\nnot a semantic HTML interchange serializer.\n\n`EditorStatic` renders configured node components and render attributes. It\nomits editing-surface slots such as `slots.wrapContent`; wrap `result.data` in\napplication presentation when the exported document needs an outer shell.\n\n```ts title=\"lib/html-serializer.ts\"\nimport { createEditor } from 'platejs';\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';\n\nasync function getDocumentAsHtml(value: any[]) {\n const editor = createEditor({\n plugins: [...BaseBasicBlocksKit],\n initialValue: value,\n });\n\n const result = await renderStaticHtml(editor, {\n // component: EditorStatic, // Optional: Defaults to EditorStatic\n props: { className: 'prose max-w-none' }, // Example: Pass props to the root div\n });\n\n return result.data;\n}\n\n// Example Usage:\n// const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ];\n// getDocumentAsHtml(value).then(console.log);\n```\n\n`props` takes appearance props for the root element, such as `className` or\n`style`. It throws a `TypeError` for `editor` or `document`. Pass the editor as\nthe first argument and another document as the `document` option.\n\n### Export a live editor\n\nA live editor's components are editing UI that needs a mounted Plate editor.\nPass the app's static kit as `presentation` to draw the export with its static\ncomponents while the live editor keeps the document and plugin settings:\n\n```ts\nimport { renderStaticHtml } from 'platejs/static';\nimport { BaseEditorKit } from '@/components/editor/plugins-static';\n\nconst result = await renderStaticHtml(editor, {\n presentation: BaseEditorKit,\n});\n```\n\nFor each element component, mark component, and wrapper slot function an\ninstalled plugin declares, the plugin of the same name in `presentation`\nsupplies the drawing, also on an element that no installed plugin renders.\nDrawings the installed plugin marks `editOnly` are left out. A drawing\n`presentation` lacks is left out too, so the content renders without it, and\n`result.diagnostics` names the plugin in a `missing-static-presentation`\nwarning. An installed `afterNodeChildren` written as a component, such as\n`React.memo`, counts as a drawing, so `presentation` needs a function for it.\nAn installed tag name, such as `component: 'p'`, stays.\n\nDeclare drawings as values and read plugin state through the `editor` prop.\n`presentation` compiles without activating its plugins. Drawings created in a\n`configure((ctx) => ...)` callback are not supported. Such a drawing can throw\nwhen it draws, which rejects the export, or draw the presentation's own\nconfiguration. Pass a stable array, because each new array is compiled again.\n\nFor more details, see the [HTML Serialization guide](/docs/plate/html).\n\n## API Reference\n\n### `` props\n\n```ts\nimport type React from 'react';\nimport type { Editor, EditorDocumentValue } from 'platejs';\n\ninterface EditorStaticProps\n extends React.HTMLAttributes {\n /**\n * A document to render instead of the editor's own. The editor supplies\n * the plugins and is not edited.\n */\n document?: EditorDocumentValue;\n\n /**\n * The Plate editor instance, created via `createEditor`.\n * Must include component-bound plugins relevant to the rendered content.\n */\n editor: E;\n\n /** Inline CSS styles for the root `div` element. */\n style?: React.CSSProperties;\n\n // Other HTMLDivElement attributes like `className`, `id`, etc., are also supported.\n}\n```\n\n- **`document`**: A document rendered through the editor's plugins instead of the editor's own value; see [Render a document](#render-a-document).\n- **`editor`**: An editor created with `createEditor` or `createStaticEditor`, including component-bound plugins required by the value.\n\n## Next steps\n\n- Explore [HTML Serialization](/docs/plate/html) for exporting content.\n- Learn about using Plate in [React Server Components](/docs/plate/installation/rsc).\n- Refer to individual plugin documentation for their base (non-React) imports.\n", "type": "registry:file", "target": "content/docs/plate/(guides)/static.mdx" } diff --git a/apps/www/src/__registry__/generation.json b/apps/www/src/__registry__/generation.json index b1b3199ea2..feb5cf07c4 100644 --- a/apps/www/src/__registry__/generation.json +++ b/apps/www/src/__registry__/generation.json @@ -1,4 +1,4 @@ { - "generation": "dfb0db75965503600052bdfa77c94d6547e524bbc8fcef3e1a4a8aaa30d5b7a6", - "manifestSha256": "40fce9157f35538145046c5e7e270078e6bb57e75591dc09c5b84984f8a2b648" + "generation": "1ed6a6b2983bdff996da6d36752b9a5be8b0f0583bf68eeeddf83f687eccc158", + "manifestSha256": "3d539aa36c71444066f0a43238cd3613d5d662644c67328382cedacf2785d2c6" } diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index 49d1734af8..f89dcfbadc 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -3,7 +3,7 @@ // Do not edit this file directly. import * as React from "react" -export const registryGeneration = "dfb0db75965503600052bdfa77c94d6547e524bbc8fcef3e1a4a8aaa30d5b7a6" +export const registryGeneration = "1ed6a6b2983bdff996da6d36752b9a5be8b0f0583bf68eeeddf83f687eccc158" export const Index: Record = { "plate": { @@ -129,7 +129,7 @@ export const Index: Record = { description: "A toolbar button for exporting editor content as HTML, Markdown, or Word.", type: "registry:component", dependencies: ["platejs","lucide-react","sonner","color-name@^2.0.0","diff-match-patch-ts@^0.6.0","html-to-vdom@^0.7.0","jszip@^3.10.1","juice@^11.0.1","katex@0.16.22","mime-types@^2.1.35","remark-parse@^11.0.0","remark-stringify@^11.0.0","unified@^11.0.5","virtual-dom@^2.1.1","xmlbuilder2@^3.1.1"], - registryDependencies: ["@plate/editor","@plate/editor-plugins-static","@plate/docx-export","@plate/docx-source","@plate/editor-dropdown-menu","@plate/editor-static","@plate/toolbar"], + registryDependencies: ["@plate/editor","@plate/docx-export","@plate/docx-source","@plate/editor-dropdown-menu","@plate/editor-plugins-static","@plate/editor-static","@plate/toolbar"], files: [{ path: "src/registry/components/editor/export-toolbar-button.tsx", type: "registry:component", diff --git a/apps/www/src/__registry__/overlays/manifest.json b/apps/www/src/__registry__/overlays/manifest.json index 977d5b0aad..1ca317bdfe 100644 --- a/apps/www/src/__registry__/overlays/manifest.json +++ b/apps/www/src/__registry__/overlays/manifest.json @@ -159,7 +159,7 @@ "style": "radix-rhea" } ], - "generation": "dfb0db75965503600052bdfa77c94d6547e524bbc8fcef3e1a4a8aaa30d5b7a6", + "generation": "1ed6a6b2983bdff996da6d36752b9a5be8b0f0583bf68eeeddf83f687eccc158", "itemCount": 319, "payloads": { "overlays/base-luma/floating-popover.json": "970d8e66abcc67a92801140819b2a1d80d66d6e03d75e9270d5a53b6219e3bcc", @@ -275,7 +275,7 @@ "r/api-selection-docs.json": "553a074dac52069e3711fb5807a73e9198529e23b6822b2c489a5dcd357f8e2f", "r/api-text-docs.json": "8e48e9b440b5c26655512f6592d186a67d111510b9818b768fbca1606923ad02", "r/api-utils-docs.json": "a4fb8af5a12fd8b00c8a7dd1bba6e2982c1266b1604442ec495655580f8b3cd8", - "r/authored-changes-docs.json": "a88cc15efd74a96173e3035168c4ba992e6e541f41e491dc4b926d179ed0e90d", + "r/authored-changes-docs.json": "515a7199e84541e4a585d85762974f338f62a153d48d6ead0d75f6f5adbb6fb3", "r/autoformat-docs.json": "fce886cd7e4f2242d9c4c84392017c7e2eca3c1bbdd0674d4909d645d8eab1b7", "r/autoformat.json": "51f06c39278143076022e2fbcf4a11e6d1e2d56fade18c540f0860276ffd5d9a", "r/basic-blocks-docs.json": "3a828c16afe32059a12de4438339468d7105eea2d367bce5215706e98f8278a4", @@ -285,7 +285,7 @@ "r/basic-marks-static.json": "13a4af597034067899fcb99b5b4f0f3bc3122023de0146c6b273facf1b4d5eff", "r/basic-marks.json": "22c8be48a8b71635fcacd440391e74cc14ec3031728858d113a9bc3e17c17e42", "r/basic-nodes.json": "53e5cd589de0f4ee7f3e9f3078a3868d5110410a3e860299a92b473e8cbbdcde", - "r/block-list-static.json": "6ae08d9b0d797e531f851c095cf2a0f0abec2d08c127e5a32a279c5a8435c11c", + "r/block-list-static.json": "516c3c8cb60c6d4274fbd70309efb3726c5d91450518aa4d0e5a9a359e6a796c", "r/block-list.json": "a82adf62c95d9d3c7d00f2a37367018f6f5814a39397d9af4a58a3d1d3f5b061", "r/block-menu-docs.json": "0e26fa82d35d5920bcc896ab650f87ebe6b2550cae1420f666cdff29349badc2", "r/block-menu.json": "30e4ac6e7ddd9d0d2a421b071218016ad8a8269d1563678b1775874e43da0fb2", @@ -350,8 +350,8 @@ "r/docs.json": "f15ece9b5fbb3fc116f371ab3216b9ffb5011686fbab8b703db87ca60cbefc87", "r/document-meta-docs.json": "fa5f9f6e85ad1e4a5316fe26d04c7e3dc76838dcfa463e579dee19ee57556fd0", "r/document-model-docs.json": "be2eb5d96e65ef6e40df4dcfb94b1aa3a419660f5983e57dc441816e0c70206d", - "r/docx-docs.json": "840d9853de9d2c81f0c83e5ed72890172f1a12f40d3acabccb319f8c38badf80", - "r/docx-export.json": "82e23bb6b58e6b5bb0bfd32b1831e3171f3540d8733375875ea502ef1611eae1", + "r/docx-docs.json": "20fac1a85d80ec65f72bc17344fbe5e835b2dd8a5e1200497cd96a615cd9b103", + "r/docx-export.json": "e3a1e88b172915094e7648713898c43967b3e4d719e01742b22237ed42a27f04", "r/docx-source.json": "7fe6f17c255bedc625d1d976cb41d6e9a18c7b67a27d68e6fe7ef73e17f00844", "r/docx.json": "579c16a466ea609fb320cc25b775e453472ebf4b35a0c503c1573ce10e8c9a00", "r/dom-coverage-docs.json": "2d9015564fc448dd75c7a92e633d534734f47a45e5b51a63e04ddb026fc9bb7f", @@ -376,7 +376,7 @@ "r/equation-toolbar-button.json": "a4388f55b8a002901481101bf54e2ab7770c7fe8c850802a9f9e28947458da9b", "r/examples-collaboration-example-docs.json": "cf553d2e7be6c7a85d385326d57429a3ce9ee5151ded49693fb9bad20687c845", "r/examples-editable-voids-docs.json": "72d8bcaebc19aeb07344f0dd6ae5d83628cd1d2ac249b1d0fd0684e3fe4bac96", - "r/examples-export-docs.json": "96daf7f0d473dc22f437f0affda497fe205c33134c888fdae0bab99ee0d401e5", + "r/examples-export-docs.json": "b707fdb36dd35aead460e9220ef00da50b187e24dd0deb9a84e379de7d3d17c9", "r/examples-huge-document-docs.json": "292f384315024e37776fea9eb44b501d6c228c0c73656abf764f2e6888969e25", "r/examples-hundreds-editors-docs.json": "8f487120426bcfd3e6e41e19bb9598b1d48e5dbcb13fc6222425577a50d76e9b", "r/examples-preview-markdown-docs.json": "b13896cacf1c716a69e31897eb1c8ebe3ce3200ae2ae30beec8f3a49424ecb78", @@ -385,7 +385,7 @@ "r/excalidraw.json": "a6fe0ea9e93612455c2d27f15087a375a67d1cb76f022143ef43d7e2bca4f69f", "r/exit-break-docs.json": "30c1431a174060569f6c57b56e5b5c74b495e7b293fe252110689537a3aa4934", "r/exit-break.json": "e3cb311a297814a2f9a3ea3f0ac0a90cf39491036135f1be5ac88652105e46c0", - "r/export-toolbar-button.json": "32a4c7a9286901563f780fa0e068eb944347c4cfd9ebfb6ac43e3354bc9d687d", + "r/export-toolbar-button.json": "314e9bbfb18df642a092db4cb5e2122b0f12a3a32702e5cd91aef3013292f7f3", "r/external-text-docs.json": "64bbde404fea77bd914120f3503fdeed5d1fc3c9fc274fc3df87fa0f43090fd2", "r/feature-kits-docs.json": "8ea3aa153832e3fae5e28676d178a6327035952e7bc534227645cd8bd831740b", "r/files-api.json": "fcae8c935cc2b3942a94310b84368f683a033818f63eaf064bc962635d0cf11d", @@ -422,7 +422,7 @@ "r/hundreds-editors-demo.json": "9e1f0bbfcb06cb03b39db35c7709f34a2d60c675fc534b3a057968f8fbb4c81d", "r/import-toolbar-button.json": "e42e7bc40d31fb6f81698d157ad92892e89a660b5b61b5d51e3bf072f217cba2", "r/indent-docs.json": "46511cfb3f7f5e3ab7519833fa0608a1f52fab3e96b348f3d1872e12ba51d0d2", - "r/indent-static.json": "c60686416ac282c8d9fd87ee02584efca714ba39bcc17d7a959230dd7b485dc4", + "r/indent-static.json": "fa0fbd42e6d303a88b5865ce1a8ff54ee52cb847f145a370293aea07f86fabdf", "r/indent-toolbar-button.json": "f058d3a4f347e1d395ab9126e9a98574cc472beed18badecc0d036dd82747023", "r/indent.json": "a0c5209a630fde2e3025bf425d4d34d2662495ac9e8f08faeb81b6b427c3d49c", "r/index-docs.json": "3aa3d7010fe6a8e83f3e6928582a9e6d450b632b1d298203b2054e050515e882", @@ -450,7 +450,7 @@ "r/link-toolbar-button.json": "ebf8f33e21992f1778e5dbd2e402b35e642f60fc35ecec64745272c7e1a41e06", "r/link.json": "a3e13d647701381d359a4de487fa48a5af4296a1365d95ea80e930c8f8e979fe", "r/list-docs.json": "e35d480e4fd2e05b7450b54389fe121e464ae4746bc32a786d7b95ea36ff357c", - "r/list-static.json": "6f005fe4346c9e3960edcefcaac0bbac3be74a8e6867b27748678496234a4d44", + "r/list-static.json": "cacc943dfc68e26f69fdde3294ddad96b48544d60b44a623cce03128424e8317", "r/list-toolbar-button.json": "61e09b91d90531f18d73fc65f9064d9ac332c62042a11243b7920e2d735191a8", "r/list.json": "b064d16e4196abf66deb0ac6b7985ca08a1c8726eedd522a582069d98c008389", "r/locations-docs.json": "fc9c4175c2c3f4518383d1b0ed690b0c87d6ef0b90a82466b466319d0c3f6933", @@ -500,8 +500,8 @@ "r/plugin-rules-docs.json": "5313a58bace8f0962869bc76d1b7878dc762313526808ee41aaeaabe3439df5a", "r/plugin-shortcuts-docs.json": "69036d155c2a4b974bc5950142ee64382a2e43f73fe1404e927643e9fad34044", "r/preview-markdown-demo.json": "4b9fa5d02ca9340671417838e3d44b2c01dd91835d4441ead848f3950d05b5ba", - "r/registry-docs.json": "47f20c74ed3383fe05bedfc1018bbc53657ec814c575c52ee98145330afc12be", - "r/registry.json": "93c14ecc9e54e546e22bcc58cecdcd7de7634692a24c4bc9e80331a509588d7f", + "r/registry-docs.json": "8531238929cd8a9e3610106db3743023c92af720e425c12e9cda053f7f0c936f", + "r/registry.json": "ae0a5086035d51d118624001f0564dee5c678ab34a2268352dd73e4338d2814a", "r/releases-index-docs.json": "a9c8ca4634b8a11f7c507d15d490ebc881ad09e3d5b03736a3fa317f3d8cd36b", "r/remote-cursor-overlay.json": "c3cc5c20b3384415a0164ac68a75aa97bf724c663922bbd984d8f2583b44047f", "r/resize-handle.json": "210cba33558c732e30826e900eb5c32c5cdc9e6ea4c0792610cec70d16534ad5", @@ -510,12 +510,12 @@ "r/select-editor-demo.json": "60d8d8a7cde82a23adcc6248b6809db76abc921e8e74b8794fdb61f7ee3c0a51", "r/select-editor.json": "233e73376c0979d29e7ba20bed048495cd94cb0f7e3030453eaf1ac535b69e53", "r/selection-docs.json": "56e8310f54b602930f0b6484c01b75576cf346f77b8de4eaac6aa37a624e21dc", - "r/serializing-docs.json": "595839735faef5eeb2236c1aa0f0576ac396cd07988bc628f3c30799782390ba", + "r/serializing-docs.json": "f52da4e68553096b8033825735e08ef805ccd9c4241a71f31da15f77626cd9a4", "r/settings-dialog.json": "b4912e789401f46d7232e6dbf4a9781b1ff0b543ab387551280d691618e658c5", "r/single-block-docs.json": "a0a292b619ddb5a7b8d41500c96dac3339d69c4946989f8d985b77ce408980a6", "r/slash-command-docs.json": "3064e7a1548cf60144255ad1ce65d4b3d212bd45e211905067f43d186a210801", "r/slash.json": "e87aea26f5494da58676a256a9eaf7c187eb52a31b019a26576d6413460c45bb", - "r/static-docs.json": "bf047dd76b37a1219d6a32683996cf7db240bcd7f7d58a6cdf386434d8afccfa", + "r/static-docs.json": "a4fd42e2c7782ce400b90c76e11299c8fb3ce9fd9f881a865999038a7dae76d2", "r/strikethrough-docs.json": "ea6f97a75842b22165ef90cdf30f54651484f996cf260567b854ee8071d12d03", "r/subscript-docs.json": "7fb3caed733c79bd83aa82efeed427e6b5eb9f42102318ff3a9ecf86a9edcae5", "r/suggestion-demo.json": "1b11cf69d8d7c1ddbb2eee4d0d53a24086607cb6a9dc7cb4b45f243ef5603325", @@ -595,7 +595,7 @@ "rd/api-selection-docs.json": "553a074dac52069e3711fb5807a73e9198529e23b6822b2c489a5dcd357f8e2f", "rd/api-text-docs.json": "8e48e9b440b5c26655512f6592d186a67d111510b9818b768fbca1606923ad02", "rd/api-utils-docs.json": "a4fb8af5a12fd8b00c8a7dd1bba6e2982c1266b1604442ec495655580f8b3cd8", - "rd/authored-changes-docs.json": "a88cc15efd74a96173e3035168c4ba992e6e541f41e491dc4b926d179ed0e90d", + "rd/authored-changes-docs.json": "515a7199e84541e4a585d85762974f338f62a153d48d6ead0d75f6f5adbb6fb3", "rd/autoformat-docs.json": "fce886cd7e4f2242d9c4c84392017c7e2eca3c1bbdd0674d4909d645d8eab1b7", "rd/autoformat.json": "51f06c39278143076022e2fbcf4a11e6d1e2d56fade18c540f0860276ffd5d9a", "rd/basic-blocks-docs.json": "3a828c16afe32059a12de4438339468d7105eea2d367bce5215706e98f8278a4", @@ -605,7 +605,7 @@ "rd/basic-marks-static.json": "f2a6f8ee5a61a2919734e52b354cd6804000d18260219eb2198f59af6f687b72", "rd/basic-marks.json": "4e49e0d8dbaa12a61da0f5dd0d85a4350e4635a4c6e16dca82cf7e225dcb6067", "rd/basic-nodes.json": "3198cae7b7676d9289198d47dedd1b94e4c50572d6ae368732906f3626056bc3", - "rd/block-list-static.json": "6ae08d9b0d797e531f851c095cf2a0f0abec2d08c127e5a32a279c5a8435c11c", + "rd/block-list-static.json": "516c3c8cb60c6d4274fbd70309efb3726c5d91450518aa4d0e5a9a359e6a796c", "rd/block-list.json": "a82adf62c95d9d3c7d00f2a37367018f6f5814a39397d9af4a58a3d1d3f5b061", "rd/block-menu-docs.json": "0e26fa82d35d5920bcc896ab650f87ebe6b2550cae1420f666cdff29349badc2", "rd/block-menu.json": "e162e6633e131b31f40c1d97a041f5dfe6ec7846963a8f1e973394eadd2601fa", @@ -670,8 +670,8 @@ "rd/docs.json": "08a1cf2a0500cb3a4703c562585940a1906a21747e91d12bc51a049ad2aeb2af", "rd/document-meta-docs.json": "fa5f9f6e85ad1e4a5316fe26d04c7e3dc76838dcfa463e579dee19ee57556fd0", "rd/document-model-docs.json": "be2eb5d96e65ef6e40df4dcfb94b1aa3a419660f5983e57dc441816e0c70206d", - "rd/docx-docs.json": "840d9853de9d2c81f0c83e5ed72890172f1a12f40d3acabccb319f8c38badf80", - "rd/docx-export.json": "82e23bb6b58e6b5bb0bfd32b1831e3171f3540d8733375875ea502ef1611eae1", + "rd/docx-docs.json": "20fac1a85d80ec65f72bc17344fbe5e835b2dd8a5e1200497cd96a615cd9b103", + "rd/docx-export.json": "e3a1e88b172915094e7648713898c43967b3e4d719e01742b22237ed42a27f04", "rd/docx-source.json": "7fe6f17c255bedc625d1d976cb41d6e9a18c7b67a27d68e6fe7ef73e17f00844", "rd/docx.json": "579c16a466ea609fb320cc25b775e453472ebf4b35a0c503c1573ce10e8c9a00", "rd/dom-coverage-docs.json": "2d9015564fc448dd75c7a92e633d534734f47a45e5b51a63e04ddb026fc9bb7f", @@ -696,7 +696,7 @@ "rd/equation-toolbar-button.json": "31d6f2f304f73da63960f3a0fcb844e64571c5b13f798c421989ff66bf8c22fe", "rd/examples-collaboration-example-docs.json": "cf553d2e7be6c7a85d385326d57429a3ce9ee5151ded49693fb9bad20687c845", "rd/examples-editable-voids-docs.json": "72d8bcaebc19aeb07344f0dd6ae5d83628cd1d2ac249b1d0fd0684e3fe4bac96", - "rd/examples-export-docs.json": "96daf7f0d473dc22f437f0affda497fe205c33134c888fdae0bab99ee0d401e5", + "rd/examples-export-docs.json": "b707fdb36dd35aead460e9220ef00da50b187e24dd0deb9a84e379de7d3d17c9", "rd/examples-huge-document-docs.json": "292f384315024e37776fea9eb44b501d6c228c0c73656abf764f2e6888969e25", "rd/examples-hundreds-editors-docs.json": "8f487120426bcfd3e6e41e19bb9598b1d48e5dbcb13fc6222425577a50d76e9b", "rd/examples-preview-markdown-docs.json": "b13896cacf1c716a69e31897eb1c8ebe3ce3200ae2ae30beec8f3a49424ecb78", @@ -705,7 +705,7 @@ "rd/excalidraw.json": "a6fe0ea9e93612455c2d27f15087a375a67d1cb76f022143ef43d7e2bca4f69f", "rd/exit-break-docs.json": "30c1431a174060569f6c57b56e5b5c74b495e7b293fe252110689537a3aa4934", "rd/exit-break.json": "e3cb311a297814a2f9a3ea3f0ac0a90cf39491036135f1be5ac88652105e46c0", - "rd/export-toolbar-button.json": "ef1a333af662933339540c26f716f13ff5d7113b13beda52c9aeb270dea4b046", + "rd/export-toolbar-button.json": "6506e1b8d26b9e63886a2ebd1837cafc02e9f5da4c93778f5c52302a7180ca90", "rd/external-text-docs.json": "64bbde404fea77bd914120f3503fdeed5d1fc3c9fc274fc3df87fa0f43090fd2", "rd/feature-kits-docs.json": "8ea3aa153832e3fae5e28676d178a6327035952e7bc534227645cd8bd831740b", "rd/files-api.json": "fcae8c935cc2b3942a94310b84368f683a033818f63eaf064bc962635d0cf11d", @@ -742,7 +742,7 @@ "rd/hundreds-editors-demo.json": "3ce75b3a400e28a681a8c751f5e7caf37dedbe804f1fab7e994ef573515b89e5", "rd/import-toolbar-button.json": "67e0715df2c038b2409d1405b6abf54da3d6f42297a316db8442e74412eb83f0", "rd/indent-docs.json": "46511cfb3f7f5e3ab7519833fa0608a1f52fab3e96b348f3d1872e12ba51d0d2", - "rd/indent-static.json": "c60686416ac282c8d9fd87ee02584efca714ba39bcc17d7a959230dd7b485dc4", + "rd/indent-static.json": "fa0fbd42e6d303a88b5865ce1a8ff54ee52cb847f145a370293aea07f86fabdf", "rd/indent-toolbar-button.json": "579cb74afb508515974d541716e11cade140ac5b002cae25301f1de749770b61", "rd/indent.json": "2dad570871814a67232c01db1f449c8d6dff9099a37407628f284d40ff4e072b", "rd/index-docs.json": "3aa3d7010fe6a8e83f3e6928582a9e6d450b632b1d298203b2054e050515e882", @@ -770,7 +770,7 @@ "rd/link-toolbar-button.json": "6b87300634262808ba343b513f1b77b7a25b913625f221d778aa1d0bc2ad6492", "rd/link.json": "283526512cdf15a4643e5ecb8e0fb8abe6ecfe585d7bf2ef20f7edc15cf4b9fa", "rd/list-docs.json": "e35d480e4fd2e05b7450b54389fe121e464ae4746bc32a786d7b95ea36ff357c", - "rd/list-static.json": "0ab16ca43923f176e6d86edf998cf78f75ea8625904fdd91549ecdee31835db9", + "rd/list-static.json": "d16b8e5858c70a46462f7b33f44831f0ee556e3afee8916df164af09e22e23a7", "rd/list-toolbar-button.json": "b5da7e04dbb67a57d523fc30a2904ee794ee4ad5403331f02e7c68755f641951", "rd/list.json": "03344df5ae6fcfe3f03e683ec773daba7a801218762fd46e39aabd1e6852d1a3", "rd/locations-docs.json": "fc9c4175c2c3f4518383d1b0ed690b0c87d6ef0b90a82466b466319d0c3f6933", @@ -820,8 +820,8 @@ "rd/plugin-rules-docs.json": "5313a58bace8f0962869bc76d1b7878dc762313526808ee41aaeaabe3439df5a", "rd/plugin-shortcuts-docs.json": "69036d155c2a4b974bc5950142ee64382a2e43f73fe1404e927643e9fad34044", "rd/preview-markdown-demo.json": "313f905095d85bc61535af23dc36ac106f26b4b046a8d005d40146838adb487c", - "rd/registry-docs.json": "3bbdae6011c6935e49f646ff31b490316e017489fb416cebb775961c27f43313", - "rd/registry.json": "f60b7e34c96e995801fe76b17ad2179a0d97e98ed5ebb23507f7e84319b51cc6", + "rd/registry-docs.json": "899f30a90a63425bb884ed67adb468338d3ae7038268678cbeb43c5d1e91c650", + "rd/registry.json": "8c35a25229397b7a88058d65e554f7cce7ec66424a91df506330b984dd0f10ec", "rd/releases-index-docs.json": "a9c8ca4634b8a11f7c507d15d490ebc881ad09e3d5b03736a3fa317f3d8cd36b", "rd/remote-cursor-overlay.json": "c3cc5c20b3384415a0164ac68a75aa97bf724c663922bbd984d8f2583b44047f", "rd/resize-handle.json": "210cba33558c732e30826e900eb5c32c5cdc9e6ea4c0792610cec70d16534ad5", @@ -830,12 +830,12 @@ "rd/select-editor-demo.json": "0c2dbc79bf0a8616ee24c76e178fb32656b299c03d25dc50bf5026617e76f14c", "rd/select-editor.json": "de171238a84c963ca19bb31da34f328e03c926a0c0f06839dd2fd2264a5ef5b1", "rd/selection-docs.json": "56e8310f54b602930f0b6484c01b75576cf346f77b8de4eaac6aa37a624e21dc", - "rd/serializing-docs.json": "595839735faef5eeb2236c1aa0f0576ac396cd07988bc628f3c30799782390ba", + "rd/serializing-docs.json": "f52da4e68553096b8033825735e08ef805ccd9c4241a71f31da15f77626cd9a4", "rd/settings-dialog.json": "07c5892d0b1ab16a994248d4ac36eb30f7d638ee5352e212b9c4d22fd6b7fe86", "rd/single-block-docs.json": "a0a292b619ddb5a7b8d41500c96dac3339d69c4946989f8d985b77ce408980a6", "rd/slash-command-docs.json": "3064e7a1548cf60144255ad1ce65d4b3d212bd45e211905067f43d186a210801", "rd/slash.json": "baecba6a2bc6d2d8da5f19bb74e8792889930d730a6eb4f18e5f474c1e8d7ffb", - "rd/static-docs.json": "bf047dd76b37a1219d6a32683996cf7db240bcd7f7d58a6cdf386434d8afccfa", + "rd/static-docs.json": "a4fd42e2c7782ce400b90c76e11299c8fb3ce9fd9f881a865999038a7dae76d2", "rd/strikethrough-docs.json": "ea6f97a75842b22165ef90cdf30f54651484f996cf260567b854ee8071d12d03", "rd/subscript-docs.json": "7fb3caed733c79bd83aa82efeed427e6b5eb9f42102318ff3a9ecf86a9edcae5", "rd/suggestion-demo.json": "82fe74fe4a27aef0d376d03f2bb1735902f8f112058babaf9d6ee41440033c10", diff --git a/apps/www/src/__registry__/registry-metadata.json b/apps/www/src/__registry__/registry-metadata.json index 1936ea144e..68ceb305de 100644 --- a/apps/www/src/__registry__/registry-metadata.json +++ b/apps/www/src/__registry__/registry-metadata.json @@ -1,5 +1,5 @@ { - "generation": "dfb0db75965503600052bdfa77c94d6547e524bbc8fcef3e1a4a8aaa30d5b7a6", + "generation": "1ed6a6b2983bdff996da6d36752b9a5be8b0f0583bf68eeeddf83f687eccc158", "registries": { "base": { "homepage": "https://platejs.org", @@ -324,10 +324,10 @@ "type": "registry:component", "registryDependencies": [ "@plate/editor", - "@plate/editor-plugins-static", "@plate/docx-export", "@plate/docx-source", "@plate/editor-dropdown-menu", + "@plate/editor-plugins-static", "@plate/editor-static", "@plate/toolbar" ] @@ -7075,10 +7075,10 @@ "type": "registry:component", "registryDependencies": [ "@plate/editor", - "@plate/editor-plugins-static", "@plate/docx-export", "@plate/docx-source", "@plate/editor-dropdown-menu", + "@plate/editor-plugins-static", "@plate/editor-static", "@plate/toolbar" ] diff --git a/apps/www/src/registry/changelog/2026-10-09-export-static-presentation.json b/apps/www/src/registry/changelog/2026-10-09-export-static-presentation.json new file mode 100644 index 0000000000..089b654b1a --- /dev/null +++ b/apps/www/src/registry/changelog/2026-10-09-export-static-presentation.json @@ -0,0 +1,177 @@ +{ + "schemaVersion": 1, + "id": "2026-10-09-export-static-presentation", + "status": "draft", + "source": { + "kind": "entry-mdx", + "path": "apps/www/src/registry/changelog/entries/2026-10-09-export-static-presentation.mdx" + }, + "change": { + "type": "source", + "date": "2026-10-09", + "commits": [] + }, + "release": { + "status": "unresolved" + }, + "kind": "fix", + "summary": "Export every block through the static kit", + "targets": [ + { + "name": "export-toolbar-button", + "files": [ + "apps/www/src/registry/components/editor/export-toolbar-button.tsx" + ], + "definitionFiles": ["apps/www/src/registry/registry-editor.ts"], + "diagnostics": [] + }, + { + "name": "block-list-static", + "files": [ + "apps/www/src/registry/components/editor/block-list-static.tsx" + ], + "definitionFiles": ["apps/www/src/registry/registry-editor.ts"], + "diagnostics": [] + }, + { + "name": "docx-export", + "files": ["apps/www/src/registry/components/editor/docx-export.tsx"], + "definitionFiles": ["apps/www/src/registry/registry-features.ts"], + "diagnostics": [] + }, + { + "name": "indent-static", + "files": ["apps/www/src/registry/components/editor/indent-static.tsx"], + "definitionFiles": ["apps/www/src/registry/registry-features.ts"], + "diagnostics": [] + }, + { + "name": "list-static", + "files": ["apps/www/src/registry/components/editor/list-static.tsx"], + "definitionFiles": ["apps/www/src/registry/registry-features.ts"], + "diagnostics": [] + } + ], + "entries": [ + { + "id": "2026-10-09-export-toolbar-every-block-through-kit-420422f3", + "kind": "fix", + "summary": "Fix HTML and Word export throwing on tables, task lists, callouts, columns, code blocks and images by drawing the file with `BaseEditorKit` as `presentation`.", + "details": [], + "migrationNotes": [], + "targets": ["export-toolbar-button"], + "source": { + "line": 14, + "row": 1, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + }, + { + "id": "2026-10-09-block-list-static-single-indent", + "kind": "fix", + "summary": "Drop the per-level `margin-left` on numbered and task lists, so static HTML indents a nested list once and Word takes its level from the block's indent.", + "details": [], + "migrationNotes": [], + "targets": ["block-list-static"], + "source": { + "line": 18, + "row": 2, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + }, + { + "id": "2026-10-10-export-toolbar-failure-toast", + "kind": "fix", + "summary": "Show an error toast when HTML or Word export throws, instead of failing with no message.", + "details": [], + "migrationNotes": [], + "targets": ["export-toolbar-button"], + "source": { + "line": 22, + "row": 3, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + }, + { + "id": "2026-10-10-docx-export-stylesheet-owns-word-look", + "kind": "fix", + "summary": "Move the Word look of callouts, code blocks, and empty equation and table of contents placeholders into `DOCX_EXPORT_STYLES`, because the package's DOCX drawings leave colors, fonts and sizes to the stylesheet.", + "details": [], + "migrationNotes": [], + "targets": ["docx-export"], + "source": { + "line": 26, + "row": 4, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + }, + { + "id": "2026-10-10-block-list-static-task-marker", + "kind": "fix", + "summary": "Draw the task checkbox inside its `li` and mark the `li` with `data-checked`, so Word export gives each task one paragraph with a ☑ or ☐ list marker.", + "details": [], + "migrationNotes": [], + "targets": ["block-list-static"], + "source": { + "line": 30, + "row": 5, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + }, + { + "id": "2026-10-10-indent-static-image-target", + "kind": "fix", + "summary": "Target images like `IndentKit` does, so a static editor loads and indents an image the live editor indented instead of throwing a schema error.", + "details": [], + "migrationNotes": [], + "targets": ["indent-static"], + "source": { + "line": 34, + "row": 6, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + }, + { + "id": "2026-10-10-list-static-image-target", + "kind": "fix", + "summary": "Target images like `ListKit` does, so a static editor loads an image in a list and draws it as a list item.", + "details": [], + "migrationNotes": [], + "targets": ["list-static"], + "source": { + "line": 38, + "row": 7, + "legacyRelease": { + "date": "2026-10-09", + "entry": "2026-10-09-export-static-presentation", + "section": null + } + } + } + ], + "diagnostics": [] +} diff --git a/apps/www/src/registry/changelog/components.json b/apps/www/src/registry/changelog/components.json index 32330878a9..3fc734009f 100644 --- a/apps/www/src/registry/changelog/components.json +++ b/apps/www/src/registry/changelog/components.json @@ -5,6 +5,7 @@ "block-list-static": [ "2026-10-10-fix-static-list-indent", "2026-10-10-list-restart-continue", + "2026-10-09-export-static-presentation", "2026-09-30-static-list-table-known-paths", "2026-09-07-list-wrappers", "2026-08-31-inline-component-props", @@ -75,6 +76,39 @@ "2026-08-20-demand-driven-dnd", "2026-08-17-flat-editor-registry" ], + "export-toolbar-button": [ + "2026-10-09-export-static-presentation", + "2026-10-08-registry-install-dependencies", + "2026-09-29-word-export-loss-and-source-reuse", + "2026-09-27-document-conversion-contracts", + "2026-09-24-export-menu-projection", + "2026-09-06-registry-lifetime-repairs", + "2026-09-02-comments-ownership", + "2026-08-17-flat-editor-registry", + "2026-08-16-direct-react-component-families", + "2026-08-15-compose-docx-export-kit", + "2026-08-13-fix-registry-install-dependencies", + "2026-01-20-add-docx-file-import-word-export-support", + "2025-10-17-fix-react-decouple" + ], + "docx-export": [ + "2026-10-09-export-static-presentation", + "2026-09-24-export-menu-projection", + "2026-09-07-docx-bookmarks", + "2026-09-02-comments-ownership", + "2026-09-01-plugin-render-contract", + "2026-08-17-flat-editor-registry" + ], + "indent-static": [ + "2026-10-09-export-static-presentation", + "2026-08-17-flat-editor-registry" + ], + "list-static": [ + "2026-10-09-export-static-presentation", + "2026-09-03-huge-code-block-demo", + "2026-08-17-finalize-v54-ast-profile", + "2026-08-17-flat-editor-registry" + ], "emoji-data": ["2026-10-08-emoji-frimousse"], "emoji": [ "2026-10-08-emoji-frimousse", @@ -122,20 +156,6 @@ "2025-12-14-fix-combobox-opening-popover-all-users-collaboration", "2025-11-20-biome-ultracite" ], - "export-toolbar-button": [ - "2026-10-08-registry-install-dependencies", - "2026-09-29-word-export-loss-and-source-reuse", - "2026-09-27-document-conversion-contracts", - "2026-09-24-export-menu-projection", - "2026-09-06-registry-lifetime-repairs", - "2026-09-02-comments-ownership", - "2026-08-17-flat-editor-registry", - "2026-08-16-direct-react-component-families", - "2026-08-15-compose-docx-export-kit", - "2026-08-13-fix-registry-install-dependencies", - "2026-01-20-add-docx-file-import-word-export-support", - "2025-10-17-fix-react-decouple" - ], "docx": [ "2026-10-08-registry-install-dependencies", "2026-09-07-docx-bookmarks", @@ -513,13 +533,6 @@ "2026-09-02-comments-ownership", "2026-08-18-isolate-docx-file-io" ], - "docx-export": [ - "2026-09-24-export-menu-projection", - "2026-09-07-docx-bookmarks", - "2026-09-02-comments-ownership", - "2026-09-01-plugin-render-contract", - "2026-08-17-flat-editor-registry" - ], "history-toolbar-button": [ "2026-09-23-history-replay-lifecycle", "2026-09-15-history-actions", @@ -863,11 +876,6 @@ "2026-08-17-finalize-v54-ast-profile", "2026-08-17-flat-editor-registry" ], - "list-static": [ - "2026-09-03-huge-code-block-demo", - "2026-08-17-finalize-v54-ast-profile", - "2026-08-17-flat-editor-registry" - ], "editor-static": [ "2026-09-02-comments-ownership", "2026-08-17-flat-editor-registry", @@ -1056,7 +1064,6 @@ "2026-08-17-flat-editor-registry", "2026-08-12-fix-font-size-selection-highlight" ], - "indent-static": ["2026-08-17-flat-editor-registry"], "line-height-static": ["2026-08-17-flat-editor-registry"], "media-static": ["2026-08-17-flat-editor-registry"], "toc-static": ["2026-08-17-flat-editor-registry"], diff --git a/apps/www/src/registry/changelog/entries/2026-10-09-export-static-presentation.mdx b/apps/www/src/registry/changelog/entries/2026-10-09-export-static-presentation.mdx new file mode 100644 index 0000000000..d935a04d13 --- /dev/null +++ b/apps/www/src/registry/changelog/entries/2026-10-09-export-static-presentation.mdx @@ -0,0 +1,38 @@ +--- +id: 2026-10-09-export-static-presentation +date: 2026-10-09 +status: draft +kind: fix +summary: 'Export every block through the static kit' +change: { 'type': 'source', 'date': '2026-10-09', 'commits': [] } +release: { 'status': 'unresolved' } +diagnostics: [] +--- + + + +- **`export-toolbar-button`**: Fix HTML and Word export throwing on tables, task lists, callouts, columns, code blocks and images by drawing the file with `BaseEditorKit` as `presentation`. + + + +- **`block-list-static`**: Drop the per-level `margin-left` on numbered and task lists, so static HTML indents a nested list once and Word takes its level from the block's indent. + + + +- **`export-toolbar-button`**: Show an error toast when HTML or Word export throws, instead of failing with no message. + + + +- **`docx-export`**: Move the Word look of callouts, code blocks, and empty equation and table of contents placeholders into `DOCX_EXPORT_STYLES`, because the package's DOCX drawings leave colors, fonts and sizes to the stylesheet. + + + +- **`block-list-static`**: Draw the task checkbox inside its `li` and mark the `li` with `data-checked`, so Word export gives each task one paragraph with a ☑ or ☐ list marker. + + + +- **`indent-static`**: Target images like `IndentKit` does, so a static editor loads and indents an image the live editor indented instead of throwing a schema error. + + + +- **`list-static`**: Target images like `ListKit` does, so a static editor loads an image in a list and draws it as a list item. diff --git a/apps/www/src/registry/changelog/index.json b/apps/www/src/registry/changelog/index.json index ea65700274..ff5c59544e 100644 --- a/apps/www/src/registry/changelog/index.json +++ b/apps/www/src/registry/changelog/index.json @@ -91,6 +91,30 @@ "entries": 1, "diagnostics": [] }, + { + "id": "2026-10-09-export-static-presentation", + "href": "/registry/changelog/2026-10-09-export-static-presentation.json", + "status": "draft", + "kind": "fix", + "summary": "Export every block through the static kit", + "change": { + "type": "source", + "date": "2026-10-09", + "commits": [] + }, + "release": { + "status": "unresolved" + }, + "targets": [ + "export-toolbar-button", + "block-list-static", + "docx-export", + "indent-static", + "list-static" + ], + "entries": 7, + "diagnostics": [] + }, { "id": "2026-10-08-emoji-frimousse", "href": "/registry/changelog/2026-10-08-emoji-frimousse.json", diff --git a/apps/www/src/registry/components/editor/block-list-static.tsx b/apps/www/src/registry/components/editor/block-list-static.tsx index 08877d8c16..0084b14144 100644 --- a/apps/www/src/registry/components/editor/block-list-static.tsx +++ b/apps/www/src/registry/components/editor/block-list-static.tsx @@ -55,7 +55,6 @@ function List( style={{ listStyleType: markerStyle }} start={listType === ListType.Numbered ? listStart : undefined} > - {isTask && }
  • + {isTask && } {props.children}
  • @@ -96,19 +96,17 @@ function TodoMarkerStatic( const checked = props.element.checked === true; return ( -
    - -
    + ); } diff --git a/apps/www/src/registry/components/editor/docx-export.spec.tsx b/apps/www/src/registry/components/editor/docx-export.spec.tsx new file mode 100644 index 0000000000..86b4c15a24 --- /dev/null +++ b/apps/www/src/registry/components/editor/docx-export.spec.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; + +import JSZip from 'jszip'; +import { createEditor, type Value } from 'platejs'; +import { exportDocx } from 'platejs/docx/export'; + +import { DOCX_EXPORT_STYLES } from './docx-export'; +import { EditorStatic } from './editor-static'; +import { BaseEditorKit } from './plugins-static'; + +const exportXml = async (initialValue: Value, stylesheet: string) => { + const editor = createEditor({ initialValue, plugins: BaseEditorKit }); + const result = await exportDocx(editor, { + component: EditorStatic, + presentation: BaseEditorKit, + projection: 'proposed', + stylesheet, + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + + return zip.file('word/document.xml')!.async('string'); +}; + +describe('DOCX_EXPORT_STYLES', () => { + it('lets the app stylesheet restyle a code block', async () => { + const xml = await exportXml( + [{ children: [{ text: 'CODE' }], type: 'codeBlock' }] as Value, + `${DOCX_EXPORT_STYLES} +.editor-codeBlock > [data-docx-preserve-whitespace] { + background-color: #123456; + font-family: Arial; + font-size: 14pt; +}` + ); + + expect(xml).toMatch(/w:fill="123456"/i); + expect(xml).toContain('w:ascii="Arial"'); + expect(xml).toContain(''); + }); + + it('gives callout cells the default background', async () => { + const xml = await exportXml( + [{ children: [{ text: 'Note' }], icon: '💡', type: 'callout' }] as Value, + DOCX_EXPORT_STYLES + ); + + expect(xml.match(/w:fill="f4f4f5"/gi)).toHaveLength(2); + }); +}); diff --git a/apps/www/src/registry/components/editor/docx-export.tsx b/apps/www/src/registry/components/editor/docx-export.tsx index 22cc3281f7..8178b7c5bc 100644 --- a/apps/www/src/registry/components/editor/docx-export.tsx +++ b/apps/www/src/registry/components/editor/docx-export.tsx @@ -83,4 +83,17 @@ hr { sup { vertical-align: super; font-size: 8pt; } sub { vertical-align: sub; font-size: 8pt; } mark { background-color: #ffff00; } +.editor-callout > table > tbody > tr > td { background-color: #f4f4f5; } +.editor-callout > table > tbody > tr > td:first-child { + font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols; + font-size: 18px; +} +.editor-codeBlock > [data-docx-preserve-whitespace] { + background-color: #f5f5f5; + font-family: 'Courier New', Consolas, monospace; + font-size: 10pt; +} +.editor-equation [data-empty], +.editor-inlineEquation [data-empty] { color: #888; } +.editor-toc [data-empty] { color: #666; font-size: 10pt; } `.trim(); diff --git a/apps/www/src/registry/components/editor/export-toolbar-button.spec.tsx b/apps/www/src/registry/components/editor/export-toolbar-button.spec.tsx index 680ed02f69..c2a73e8359 100644 --- a/apps/www/src/registry/components/editor/export-toolbar-button.spec.tsx +++ b/apps/www/src/registry/components/editor/export-toolbar-button.spec.tsx @@ -1,10 +1,11 @@ -import { afterAll, describe, expect, it, mock } from 'bun:test'; +import { afterAll, describe, expect, it, mock, spyOn } from 'bun:test'; import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { BaseParagraphPlugin, createEditor, createEditorView } from 'platejs'; import { AuthoredPlugin } from 'platejs/authored'; import { CommentsPlugin } from 'platejs/comments/react'; import { EditorRoot } from 'platejs/react'; +import * as actualStatic from 'platejs/static'; import React from 'react'; mock.module('@/registry/components/editor/dropdown-menu', () => ({ @@ -52,9 +53,13 @@ const exportDocx = mock( } ); +const renderStaticHtml = mock(actualStatic.renderStaticHtml); + mock.module('platejs/docx/export', () => ({ exportDocx })); +mock.module('platejs/static', () => ({ ...actualStatic, renderStaticHtml })); +const toastError = mock(); mock.module('sonner', () => ({ - toast: { error: mock(), success: mock(), warning: mock() }, + toast: { error: toastError, success: mock(), warning: mock() }, })); const originalCreateObjectURL = URL.createObjectURL; @@ -111,6 +116,54 @@ describe('ExportToolbarButton', () => { expect(click).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith('blob:test'); }); + + const clickFailedExport = async (name: string) => { + const { ExportToolbarButton } = await import('./export-toolbar-button'); + const editor = createEditor({ + initialValue: [{ children: [{ text: 'ABC' }], type: 'paragraph' }], + plugins: [BaseParagraphPlugin], + }); + const consoleError = spyOn(console, 'error').mockImplementation(() => {}); + toastError.mockClear(); + const view = render( + + + + ); + + try { + await act(async () => { + fireEvent.click(view.getByRole('button', { name })); + }); + } finally { + view.unmount(); + consoleError.mockRestore(); + } + }; + + it('reports an HTML export that throws', async () => { + renderStaticHtml.mockImplementationOnce(async () => { + throw new Error('A component threw while drawing.'); + }); + + await clickFailedExport('Export as HTML'); + + expect(toastError).toHaveBeenCalledWith( + 'The HTML file could not be exported.' + ); + }); + + it('reports a Word export that throws', async () => { + exportDocx.mockImplementationOnce(async () => { + throw new Error('A component threw while drawing.'); + }); + + await clickFailedExport('Export as Word'); + + expect(toastError).toHaveBeenCalledWith( + 'The Word document could not be exported.' + ); + }); }); afterAll(() => { diff --git a/apps/www/src/registry/components/editor/export-toolbar-button.tsx b/apps/www/src/registry/components/editor/export-toolbar-button.tsx index 6d052e0884..e0d88ab5de 100644 --- a/apps/www/src/registry/components/editor/export-toolbar-button.tsx +++ b/apps/www/src/registry/components/editor/export-toolbar-button.tsx @@ -24,6 +24,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/registry/components/editor/dropdown-menu'; +import { BaseEditorKit } from '@/registry/components/editor/plugins-static'; import { ToolbarButton } from '@/registry/components/editor/toolbar'; import { EditorStatic } from './editor-static'; @@ -110,13 +111,15 @@ export function ExportToolbarButton() { (mountedProjection === 'accepted' ? 'accepted' : 'proposed'); const exportToHtml = async () => { - const result = await renderStaticHtml(model, { - component: EditorStatic, - projection, - props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }, - }); + try { + const result = await renderStaticHtml(model, { + component: EditorStatic, + presentation: BaseEditorKit, + projection, + props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }, + }); - const html = ` + const html = ` @@ -128,8 +131,12 @@ export function ExportToolbarButton() { `; - downloadBlob(new Blob([html], { type: 'text/html' }), 'plate.html'); - toastWarnings(result.diagnostics); + downloadBlob(new Blob([html], { type: 'text/html' }), 'plate.html'); + toastWarnings(result.diagnostics); + } catch (error) { + console.error('Could not export HTML.', error); + toast.error('The HTML file could not be exported.'); + } }; const exportToMarkdown = () => { @@ -153,81 +160,90 @@ export function ExportToolbarButton() { const exportToWord = async ( requestedProjection?: CleanProjection | 'review' ) => { - let omittedCommentCount = 0; - const docxComments: DocxComment[] = []; - - if (commentsInstalled) { - const commentEditor = isAuthoredEditor(model) - ? createEditorView(model, { - authored: { intent: 'edit', projection: 'proposed' }, - }) - : model; - const comments = commentEditor.plugin(CommentsPlugin); - const users = comments.store.get('users'); - - comments.api.getThreads().forEach((thread) => { - if (thread.status !== 'published') return; - const attachment = comments.api.attachment(thread.id); - - if (attachment?.type !== 'range' || attachment.status !== 'attached') { - omittedCommentCount += 1; - - return; - } - const parentId = thread.messages[0] - ? `${thread.id}:${thread.messages[0].id}` - : null; - - thread.messages.forEach((message, index) => { - const user = users[message.userId]; - const name = user?.name ?? 'Unknown'; - const initials = name - .split(/\s+/) - .filter(Boolean) - .map((part) => part[0]) - .join('') - .slice(0, 4); - - docxComments.push({ - author: { ...(initials ? { initials } : {}), name }, - body: message.body, - createdAt: message.createdAt, - durableId: null, - id: `${thread.id}:${message.id}`, - parentId: index === 0 ? null : parentId, - resolved: index === 0 ? thread.resolution !== null : null, - target: { range: attachment.range }, + try { + let omittedCommentCount = 0; + const docxComments: DocxComment[] = []; + + if (commentsInstalled) { + const commentEditor = isAuthoredEditor(model) + ? createEditorView(model, { + authored: { intent: 'edit', projection: 'proposed' }, + }) + : model; + const comments = commentEditor.plugin(CommentsPlugin); + const users = comments.store.get('users'); + + comments.api.getThreads().forEach((thread) => { + if (thread.status !== 'published') return; + const attachment = comments.api.attachment(thread.id); + + if ( + attachment?.type !== 'range' || + attachment.status !== 'attached' + ) { + omittedCommentCount += 1; + + return; + } + const parentId = thread.messages[0] + ? `${thread.id}:${thread.messages[0].id}` + : null; + + thread.messages.forEach((message, index) => { + const user = users[message.userId]; + const name = user?.name ?? 'Unknown'; + const initials = name + .split(/\s+/) + .filter(Boolean) + .map((part) => part[0]) + .join('') + .slice(0, 4); + + docxComments.push({ + author: { ...(initials ? { initials } : {}), name }, + body: message.body, + createdAt: message.createdAt, + durableId: null, + id: `${thread.id}:${message.id}`, + parentId: index === 0 ? null : parentId, + resolved: index === 0 ? thread.resolution !== null : null, + target: { range: attachment.range }, + }); }); }); + } + const wordProjection = + requestedProjection ?? (unresolved ? projection : ('review' as const)); + const result = await exportDocx(model, { + comments: docxComments, + component: EditorStatic, + // The download still completes; the toasts below report what was lost. + lossPolicy: 'allow', + presentation: BaseEditorKit, + projection: wordProjection, + source: docxSource?.source, + stylesheet: DOCX_EXPORT_STYLES, }); - } - const wordProjection = - requestedProjection ?? (unresolved ? projection : ('review' as const)); - const result = await exportDocx(model, { - comments: docxComments, - component: EditorStatic, - // The download still completes; the toasts below report what was lost. - lossPolicy: 'allow', - projection: wordProjection, - source: docxSource?.source, - stylesheet: DOCX_EXPORT_STYLES, - }); - - if (!result.ok) { - toast.error( - result.diagnostics.find(({ severity }) => severity === 'error') - ?.message ?? 'The Word document could not be exported.' - ); - - return; - } - downloadBlob(result.blob, 'plate.docx'); - toastWarnings(result.diagnostics); - if (omittedCommentCount > 0) { - toast.warning( - `${omittedCommentCount} comment thread${omittedCommentCount === 1 ? '' : 's'} could not be attached to the Word document.` - ); + if (!result.ok) { + toast.error( + result.diagnostics.find(({ severity }) => severity === 'error') + ?.message ?? 'The Word document could not be exported.' + ); + + return; + } + + downloadBlob(result.blob, 'plate.docx'); + toastWarnings(result.diagnostics); + if (omittedCommentCount > 0) { + toast.warning( + `${omittedCommentCount} comment thread${omittedCommentCount === 1 ? '' : 's'} could not be attached to the Word document.` + ); + } + } catch (error) { + console.error('Could not export Word.', error); + toast.error('The Word document could not be exported.'); } }; diff --git a/apps/www/src/registry/components/editor/indent-static.tsx b/apps/www/src/registry/components/editor/indent-static.tsx index 9881ebd46f..06c91d279a 100644 --- a/apps/www/src/registry/components/editor/indent-static.tsx +++ b/apps/www/src/registry/components/editor/indent-static.tsx @@ -11,6 +11,7 @@ export const BaseIndentKit = [ PLUGINS.blockquote, PLUGINS.codeBlock, PLUGINS.details, + PLUGINS.image, ], }), ]; diff --git a/apps/www/src/registry/components/editor/list-static.tsx b/apps/www/src/registry/components/editor/list-static.tsx index a6ace995de..e9c7726aee 100644 --- a/apps/www/src/registry/components/editor/list-static.tsx +++ b/apps/www/src/registry/components/editor/list-static.tsx @@ -36,6 +36,7 @@ export const BaseListKit = [ PLUGINS.blockquote, PLUGINS.codeBlock, PLUGINS.details, + PLUGINS.image, ], }), ]; diff --git a/apps/www/src/registry/components/editor/plugins-static.spec.ts b/apps/www/src/registry/components/editor/plugins-static.spec.ts index b77a246c85..eb40a20f3d 100644 --- a/apps/www/src/registry/components/editor/plugins-static.spec.ts +++ b/apps/www/src/registry/components/editor/plugins-static.spec.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { createEditor } from 'platejs'; +import { + createEditor as createLiveEditor, + ParagraphPlugin, +} from 'platejs/react'; +import { renderStaticHtml } from 'platejs/static'; +import { DEMO_VALUES } from '@/registry/examples/values/demo-values'; import { playgroundValue } from '@/registry/examples/values/playground-value'; import { AlignKit } from './align'; @@ -42,4 +48,120 @@ describe('BaseEditorKit', () => { expect(result.data).toContain(' { + const { EditorKit } = await import('./plugins'); + const live = createLiveEditor({ plugins: EditorKit }); + const presentation = createEditor({ plugins: BaseEditorKit }); + const drawings = ({ + component, + render, + slots, + }: { + component?: unknown; + render: { mark?: { leafComponent?: unknown } | null }; + slots: Partial< + Record<'afterNodeChildren' | 'wrapNode' | 'wrapNodeChildren', unknown> + >; + }) => ({ + component: component ?? undefined, + leaf: render.mark?.leafComponent ?? undefined, + ...Object.fromEntries( + (['afterNodeChildren', 'wrapNode', 'wrapNodeChildren'] as const).map( + (slot) => [ + slot, + typeof slots[slot] === 'function' ? slots[slot] : undefined, + ] + ) + ), + }); + const missing = EditorKit.flatMap((plugin) => { + const peerPlugin = BaseEditorKit.find(({ name }) => name === plugin.name); + const peer = peerPlugin ? drawings(presentation.plugin(peerPlugin)) : {}; + // `editor.plugin()` resolves the kit's configured paragraph only through + // the core `ParagraphPlugin` descriptor. + const own = drawings( + live.plugin( + plugin.name === ParagraphPlugin.name ? ParagraphPlugin : plugin + ) + ); + + return Object.entries(own).flatMap(([part, drawing]) => + drawing && + typeof drawing !== 'string' && + peer[part as keyof typeof peer] === undefined + ? [`${plugin.name} ${part}`] + : [] + ); + }); + + // No static AI mark exists; export draws AI-marked text plainly with a + // warning. + expect(missing).toEqual(['ai component']); + }); + + it('exports the demo documents with no missing static drawing', async () => { + const { EditorKit } = await import('./plugins'); + const editor = createLiveEditor({ + plugins: EditorKit, + initialValue: ( + [ + 'playground', + 'basic-blocks', + 'basic-marks', + 'callout', + 'code-block', + 'column', + 'date', + 'details', + 'equation', + 'font', + 'footnote', + 'indent', + 'line-height', + 'link', + 'list', + 'media', + 'mention', + 'table', + 'text-align', + 'toc', + 'docx', + ] as const + ) + .flatMap((id) => { + const value = DEMO_VALUES[id]; + + return Array.isArray(value) ? value : value.children; + }) + // EditorKit does not install code drawings. + .filter((block) => block.type !== 'codeDrawing'), + }); + + const { diagnostics } = await renderStaticHtml(editor, { + presentation: BaseEditorKit, + projection: 'proposed', + }); + + expect( + diagnostics.filter(({ code }) => code === 'missing-static-presentation') + ).toEqual([]); + }); + + it('loads an indented list image, which EditorKit can write', () => { + expect(() => + createEditor({ + plugins: BaseEditorKit, + initialValue: [ + { + children: [{ text: '' }], + indent: 1, + listType: 'bulleted', + type: 'image', + url: 'https://example.com/a.png', + }, + ], + }) + ).not.toThrow(); + }); }); diff --git a/apps/www/src/registry/registry-editor.ts b/apps/www/src/registry/registry-editor.ts index 2d45644877..78471cf753 100644 --- a/apps/www/src/registry/registry-editor.ts +++ b/apps/www/src/registry/registry-editor.ts @@ -149,7 +149,7 @@ export const editorComponents: Registry['items'] = [ label: 'New', }, name: 'export-toolbar-button', - registryDependencies: ['@plate/editor', '@plate/editor-plugins-static'], + registryDependencies: ['@plate/editor'], title: 'Export Toolbar Button', type: 'registry:component', }, diff --git a/apps/www/tests/browser/docx.spec.ts b/apps/www/tests/browser/docx.spec.ts index 6e2d65275b..6c38813faa 100644 --- a/apps/www/tests/browser/docx.spec.ts +++ b/apps/www/tests/browser/docx.spec.ts @@ -85,9 +85,11 @@ test('a Word file imports and exports through the toolbar', async ({ expect(artifact.suggestedFilename()).toBe('plate.docx'); expect(path).not.toBeNull(); - const bytes = await readFile(path); + const word = await JSZip.loadAsync(await readFile(path)); + const wordXml = await word.file('word/document.xml')!.async('string'); - expect(bytes).toEqual(input); + expect(wordXml).toContain('A Test of Headers'); + expect(wordXml).toContain(''); errors.assertNone(); } finally { errors.stop(); @@ -247,3 +249,108 @@ test('the export menu applies one suggestion projection to every format', async errors.stop(); } }); + +test('the export menu exports interactive blocks to HTML and Word', async ({ + page, +}, testInfo) => { + test.setTimeout(180_000); + const errors = recordBrowserRuntimeErrors(page); + const exportButton = page.getByRole('button', { name: 'Export' }); + + const downloadExport = async ( + name: 'Export as HTML' | 'Export as Word', + filename: string + ) => { + await expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + await exportButton.click(); + const download = page.waitForEvent('download', { timeout: 30_000 }); + + await page.getByRole('menuitem', { exact: true, name }).click(); + const artifact = await download; + const path = testInfo.outputPath(filename); + + await artifact.saveAs(path); + + return path; + }; + const readDocumentXml = async (path: string) => { + const word = await JSZip.loadAsync(await readFile(path)); + + return word.file('word/document.xml')!.async('string'); + }; + + try { + const editor = await openDocxDemo(page); + const startNewBlock = async () => { + await editor.click(); + await page.keyboard.press( + process.platform === 'darwin' ? 'Meta+ArrowDown' : 'Control+End' + ); + await page.keyboard.press('Enter'); + }; + const slashInsert = async (query: string, text: string) => { + await startNewBlock(); + await page.keyboard.type(`/${query}`); + await page.keyboard.press('Enter'); + await page.keyboard.type(text); + }; + + await slashInsert('to-do', 'TASK-ITEM'); + await slashInsert('callout', 'CALLOUT-TEXT'); + await slashInsert('code block', 'CODE-TEXT'); + await startNewBlock(); + page.once('dialog', (dialog) => + dialog.accept('https://platejs.org/og.png') + ); + await page.locator('button:has(svg.lucide-plus)').first().click(); + await page.getByRole('menuitem', { exact: true, name: 'Image' }).click(); + await slashInsert('3 columns', 'COLUMN-TEXT'); + await slashInsert('table', 'TABLE-CELL'); + + const html = await readFile( + await downloadExport('Export as HTML', 'blocks.html'), + 'utf-8' + ); + + for (const text of [ + 'TASK-ITEM', + 'CALLOUT-TEXT', + 'CODE-TEXT', + 'COLUMN-TEXT', + 'TABLE-CELL', + 'https://platejs.org/og.png', + '', + ]) { + expect(wordXml).toContain(text); + } + + await editor + .locator('table [data-editor-string]', { hasText: 'TABLE-CELL' }) + .selectText(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.type(' EDITED'); + await expect(editor.locator('table')).toContainText('TABLE-CELL EDITED'); + const task = editor.getByRole('checkbox').first(); + + await task.click(); + await expect(task).toBeChecked(); + errors.assertNone(); + } finally { + errors.stop(); + } +}); diff --git a/content/docs/(guides)/authored-changes.cn.mdx b/content/docs/(guides)/authored-changes.cn.mdx index 8a238e2de2..408ccab6e2 100644 --- a/content/docs/(guides)/authored-changes.cn.mdx +++ b/content/docs/(guides)/authored-changes.cn.mdx @@ -260,13 +260,21 @@ Markdown 不包含隐藏 Plate document,也不能恢复完整 review state。 ```tsx import { exportDocx } from "platejs/docx/export"; import { renderStaticHtml } from "platejs/static"; +import { BaseEditorKit } from "@/components/editor/plugins-static"; const semanticHtml = editor.api.html.serialize({ projection: "proposed" }); -const styledHtml = await renderStaticHtml(editor, { projection: "proposed" }); -const docx = await exportDocx(editor, { projection: "review" }); +const styledHtml = await renderStaticHtml(editor, { + presentation: BaseEditorKit, + projection: "proposed", +}); +const docx = await exportDocx(editor, { + presentation: BaseEditorKit, + projection: "review", +}); ``` -`renderStaticHtml` 使用当前 editor 的 static component。需要不含应用样式的 +`renderStaticHtml` 与 `exportDocx` 用作为 `presentation` 传入的 static kit +绘制,因为 live editor 的组件是编辑 UI。需要不含应用样式的 semantic HTML 时,调用 `editor.api.html.serialize({ projection })`。两种 HTML 都只携带可见的 accepted 或 proposed content。成功的 DOCX result 在 `docx.blob` 中返回 `Blob`。每个 serializer 都返回结构化 `diagnostics`。 diff --git a/content/docs/(guides)/authored-changes.mdx b/content/docs/(guides)/authored-changes.mdx index dec71cf439..307fd688a1 100644 --- a/content/docs/(guides)/authored-changes.mdx +++ b/content/docs/(guides)/authored-changes.mdx @@ -311,13 +311,21 @@ hidden Plate document and cannot restore exact review state. ```tsx import { exportDocx } from "platejs/docx/export"; import { renderStaticHtml } from "platejs/static"; +import { BaseEditorKit } from "@/components/editor/plugins-static"; const semanticHtml = editor.api.html.serialize({ projection: "proposed" }); -const styledHtml = await renderStaticHtml(editor, { projection: "proposed" }); -const docx = await exportDocx(editor, { projection: "review" }); +const styledHtml = await renderStaticHtml(editor, { + presentation: BaseEditorKit, + projection: "proposed", +}); +const docx = await exportDocx(editor, { + presentation: BaseEditorKit, + projection: "review", +}); ``` -`renderStaticHtml` uses the current editor's static components. Semantic HTML +`renderStaticHtml` and `exportDocx` draw with the static kit passed as +`presentation`, because a live editor's components are editing UI. Semantic HTML uses feature mappings and no application styling. Both HTML forms carry visible accepted or proposed content only. A successful DOCX result returns a `Blob` in `docx.blob`. Every serializer returns structured `diagnostics`. diff --git a/content/docs/(guides)/serializing.cn.mdx b/content/docs/(guides)/serializing.cn.mdx index 67159d4688..858df653cb 100644 --- a/content/docs/(guides)/serializing.cn.mdx +++ b/content/docs/(guides)/serializing.cn.mdx @@ -113,10 +113,17 @@ source 与 Word revision 都属于 package workflow。 ```ts import { exportDocx } from 'platejs/docx/export'; +import { BaseEditorKit } from '@/components/editor/plugins-static'; -const result = await exportDocx(editor, { projection: 'review' }); +const result = await exportDocx(editor, { + presentation: BaseEditorKit, + projection: 'review', +}); ``` +live editor 的组件是编辑 UI,所以把应用的 static kit 作为 `presentation` 传入来 +绘制文件。 + Word 文件包含可见的 Word 文档、comment 与 tracked revision。另一个 Plate editor 需要恢复命名根、metadata 或精确的 authored change 记录时,把 Plate JSON 与文件 一起保存。 diff --git a/content/docs/(guides)/serializing.mdx b/content/docs/(guides)/serializing.mdx index 955eaaa29f..35d3e081fc 100644 --- a/content/docs/(guides)/serializing.mdx +++ b/content/docs/(guides)/serializing.mdx @@ -116,10 +116,17 @@ retained source, and Word revisions are part of its package workflow. ```ts import { exportDocx } from 'platejs/docx/export'; +import { BaseEditorKit } from '@/components/editor/plugins-static'; -const result = await exportDocx(editor, { projection: 'review' }); +const result = await exportDocx(editor, { + presentation: BaseEditorKit, + projection: 'review', +}); ``` +A live editor's components are editing UI, so pass the app's static kit as +`presentation` to draw the file. + A Word file contains the visible Word document, its comments, and its tracked revisions. Save Plate JSON beside it when another Plate editor must recover named roots, metadata, or exact authored change records. diff --git a/content/docs/(guides)/static.cn.mdx b/content/docs/(guides)/static.cn.mdx index 2e5ce99726..dbfc93f686 100644 --- a/content/docs/(guides)/static.cn.mdx +++ b/content/docs/(guides)/static.cn.mdx @@ -341,6 +341,39 @@ async function getDocumentAsHtml(value: any[]) { // const mySlateValue = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ]; // getDocumentAsHtml(mySlateValue).then(console.log); ``` + +`props` 接收根元素的外观 props,例如 `className` 或 `style`。传入 `editor` +或 `document` 会抛出 `TypeError`。editor 作为第一个参数传入,另一个 document +通过 `document` 选项传入。 + +### 导出 live editor + +live editor 的组件是编辑 UI,需要已挂载的 Plate editor。把应用的 static kit +作为 `presentation` 传入,导出就用它的 static 组件绘制,live editor 仍提供 +document 和插件设置: + +```ts +import { renderStaticHtml } from 'platejs/static'; +import { BaseEditorKit } from '@/components/editor/plugins-static'; + +const result = await renderStaticHtml(editor, { + presentation: BaseEditorKit, +}); +``` + +对已安装插件声明的每个 element 组件、mark 组件和 wrapper slot 函数, +`presentation` 中同名的插件提供对应的绘制方式,没有任何已安装插件渲染的元素也一样。 +已安装插件标为 `editOnly` 的绘制会被省略。`presentation` 缺少的绘制也会被省略,内容 +不带该绘制渲染,`result.diagnostics` 会用一条 `missing-static-presentation` 警告指出 +该插件。已安装插件以组件形式(例如 `React.memo`)写的 `afterNodeChildren` 也算一种 +绘制,`presentation` 需要以函数形式提供它。已安装插件的标签名(例如 +`component: 'p'`)保持不变。 + +请把绘制声明为值,并通过 `editor` prop 读取插件状态。`presentation` 编译时不会激活 +其中的插件。不支持在 `configure((ctx) => ...)` 回调里创建绘制。这样的绘制可能在绘制时 +抛错,导致导出失败,也可能绘制出 presentation 自己的配置。请传入稳定的数组,因为每个 +新数组都会重新编译。 + 更多详情请参阅 [HTML 序列化指南](/docs/html)。 ## API 参考 diff --git a/content/docs/(guides)/static.mdx b/content/docs/(guides)/static.mdx index 1d76880852..43c0674aff 100644 --- a/content/docs/(guides)/static.mdx +++ b/content/docs/(guides)/static.mdx @@ -357,6 +357,42 @@ async function getDocumentAsHtml(value: any[]) { // const value = [ { type: 'heading', level: 1, children: [{ text: 'My Document' }] } ]; // getDocumentAsHtml(value).then(console.log); ``` + +`props` takes appearance props for the root element, such as `className` or +`style`. It throws a `TypeError` for `editor` or `document`. Pass the editor as +the first argument and another document as the `document` option. + +### Export a live editor + +A live editor's components are editing UI that needs a mounted Plate editor. +Pass the app's static kit as `presentation` to draw the export with its static +components while the live editor keeps the document and plugin settings: + +```ts +import { renderStaticHtml } from 'platejs/static'; +import { BaseEditorKit } from '@/components/editor/plugins-static'; + +const result = await renderStaticHtml(editor, { + presentation: BaseEditorKit, +}); +``` + +For each element component, mark component, and wrapper slot function an +installed plugin declares, the plugin of the same name in `presentation` +supplies the drawing, also on an element that no installed plugin renders. +Drawings the installed plugin marks `editOnly` are left out. A drawing +`presentation` lacks is left out too, so the content renders without it, and +`result.diagnostics` names the plugin in a `missing-static-presentation` +warning. An installed `afterNodeChildren` written as a component, such as +`React.memo`, counts as a drawing, so `presentation` needs a function for it. +An installed tag name, such as `component: 'p'`, stays. + +Declare drawings as values and read plugin state through the `editor` prop. +`presentation` compiles without activating its plugins. Drawings created in a +`configure((ctx) => ...)` callback are not supported. Such a drawing can throw +when it draws, which rejects the export, or draw the presentation's own +configuration. Pass a stable array, because each new array is compiled again. + For more details, see the [HTML Serialization guide](/docs/html). ## API Reference diff --git a/content/docs/(plugins)/(serializing)/docx.cn.mdx b/content/docs/(plugins)/(serializing)/docx.cn.mdx index d5e3968a3c..d02364f7e3 100644 --- a/content/docs/(plugins)/(serializing)/docx.cn.mdx +++ b/content/docs/(plugins)/(serializing)/docx.cn.mdx @@ -125,7 +125,6 @@ editor.update.value.replace(imported.document); const exported = await exportDocx(editor, { projection: "review", source: imported.source, - stylesheet, }); imported.source?.dispose(); @@ -209,7 +208,7 @@ const document = parseAuthoredDocument(json); ### 内容损失 -`exportDocx` 默认拒绝内容损失。图片无法嵌入,或传入的批注没有可表示的范围时,结果为 `ok: false`,并以错误 diagnostic 报告该损失。设置 `lossPolicy: "allow"` 可以下载文件,并以警告形式接收相同的损失: +`exportDocx` 默认拒绝内容损失。图片无法嵌入、传入的批注没有可表示的范围,或节点在 `presentation` 中没有绘制方式时,结果为 `ok: false`,并以错误 diagnostic 报告该损失。设置 `lossPolicy: "allow"` 可以下载文件,并以警告形式接收相同的损失: ```tsx const result = await exportDocx(editor, { @@ -240,7 +239,7 @@ const result = await exportDocx(editor, { ### 使用源文件导出 -传入有效 `source` 时,未改动的 `review` 导出会逐字节返回导入的文件。精确复用要求使用同一个编译 schema、review 文档未改动、批注未传入或完全相同,并且没有传入 `title`、`margins`、`orientation` 或 `pageSize`。该路径不会重新生成内容,因此也不会调用静态渲染器。 +传入有效 `source` 时,未改动的 `review` 导出会逐字节返回导入的文件。精确复用要求使用同一个编译 schema、review 文档未改动、批注未传入或完全相同,并且没有传入 `title`、`margins`、`orientation`、`pageSize`、`stylesheet` 或 `fontFamily`。该路径不会重新生成内容,因此 `component` 和 `presentation` 不起作用。 精确检查不通过时,`exportDocx` 会重新生成完整正文。内部关系闭合、可达且无名称冲突时,会保留下列 source 单元: @@ -267,8 +266,25 @@ const result = await exportDocx(editor, { ### Static rendering 与样式 DOCX 包拥有 code block、column、equation、callout、heading 和目录的语义 -static mapping。应用提供展示 CSS;需要不同 wrapper 时可以传入自定义 root -`component`。`DOCX_EXPORT_STYLES` 是 registry 可复制的展示预设。 +static mapping。除了元素自身的值(例如 callout 的颜色),它们不设置颜色、 +字体或字号。这些由应用的 `stylesheet` 设置,它可以选中: + +- `editor-<插件名>`,每个绘制元素上的 class,例如 `.editor-codeBlock`; +- callout 里的 `table > tbody > tr > td`,第一个单元格放图标; +- `[data-docx-preserve-whitespace]`,code block 的文本; +- `[data-empty]`,空公式或空目录的占位文字。 + +需要不同 wrapper 时可以传入自定义 root `component`。`DOCX_EXPORT_STYLES` 是 +registry 可复制的展示预设。 + +公式导出为由 KaTeX MathML 生成的 Word 公式。转换器尚不支持的写法(如矩阵),会把 +TeX 作为文本放进一个 Word 公式里导出。任务列表项,即带 `data-checked` 的 +`li`,导出时带 ☑ 或 ☐ 列表符号。 + +live editor 的组件是编辑 UI。把应用的 static kit 作为 `presentation` 传入, +正文和每条批注正文都会用同名插件的 static 组件绘制。没有 static 版本的 +插件,其内容会不带组件或 wrapper 导出,并报告一条 +`missing-static-presentation` diagnostic;在 `lossPolicy` 下它算作内容损失。 @@ -286,7 +302,9 @@ static mapping。应用提供展示 CSS;需要不同 wrapper 时可以传入 | 多节页眉和页脚、脚注和尾注 | 检查并限制对应包部件;编辑 source 后省略并返回 diagnostic | | 字段、文本框、形状、图表、SmartArt 和嵌入对象 | 转换可访问时保留纯文本,否则报告不支持或省略的内容 | | Plate 命名根和元数据 | Word 输出会省略并返回 `lossy-content` 警告;保存在 authored JSON 中 | -| 自定义 Plate、数学、emoji 和媒体节点 | 使用已配置的静态序列化器;不支持的输出返回 diagnostic | +| 公式 | 导出为 Word 公式;转换器不支持该写法时,把 TeX 放进 Word 公式。导入时把 Word 公式读成 `equation` 和 `inlineEquation` 节点,TeX 由公式结构重建;未安装这两个插件时读成 TeX 文本。与批注、修订或文字同处一段,或位于列表项、缩进段落中的行间公式读成行内公式 | +| 任务列表 | 以带 `data-checked` 的 `li` 绘制的项导出时带 ☑ 或 ☐ 列表符号 | +| 自定义 Plate、emoji 和媒体节点 | 使用已配置的静态序列化器;不支持的输出返回 diagnostic | ## API 参考 @@ -353,14 +371,15 @@ exportDocx( | `comments` | `readonly DocxComment[]` | 使用 proposed 投影坐标的批注记录 | | `allowRemoteImages` | `boolean` | 获取远程 HTTP(S) 图片并嵌入;默认 `false` | | `component` | `React.ComponentType` | 可选 static editor wrapper | -| `fontFamily` | `string` | 文档正文字体 | +| `fontFamily` | `string` | 文档正文字体;会重新生成未改动的 `source` | | `lossPolicy` | `'allow' \| 'reject'` | 内容被省略时失败,或以警告返回;默认 `'reject'` | | `margins` | `Margins` | 以二十分之一磅为单位的页边距 | | `orientation` | `'landscape' \| 'portrait'` | 页面方向 | | `pageSize` | `PageSize` | 以二十分之一磅为单位的页面尺寸 | +| `presentation` | `readonly BasePluginInput[]` | 绘制正文和批注正文的 static 插件,按插件名匹配 | | `signal` | `AbortSignal` | 协作式取消信号 | | `source` | `DocxSource \| null` | 用于精确或安全导出的 source 对应关系 | -| `stylesheet` | `string` | DOCX 转换前应用的 CSS | +| `stylesheet` | `string` | 设置 Word 外观的 CSS;会重新生成未改动的 `source` | | `title` | `string` | 文档元数据标题 | 成功结果包含 `blob` 和结构化 `diagnostics`。失败结果只包含 `diagnostics`。 diff --git a/content/docs/(plugins)/(serializing)/docx.mdx b/content/docs/(plugins)/(serializing)/docx.mdx index 1675194325..0c30f65b1d 100644 --- a/content/docs/(plugins)/(serializing)/docx.mdx +++ b/content/docs/(plugins)/(serializing)/docx.mdx @@ -127,7 +127,6 @@ editor.update.value.replace(imported.document); const exported = await exportDocx(editor, { projection: "review", source: imported.source, - stylesheet, }); imported.source?.dispose(); @@ -211,7 +210,7 @@ const document = parseAuthoredDocument(json); ### Content loss -`exportDocx` rejects content loss by default. When an image cannot be embedded, or a supplied comment has no representable range, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: "allow"` to download the file and receive the same loss as warnings: +`exportDocx` rejects content loss by default. When an image cannot be embedded, a supplied comment has no representable range, or a node has no drawing in `presentation`, the result is `ok: false` with the loss as an error diagnostic. Set `lossPolicy: "allow"` to download the file and receive the same loss as warnings: ```tsx const result = await exportDocx(editor, { @@ -242,7 +241,7 @@ Before returning a generated file, export checks the written package against the ### Source-aware export -With a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, or `pageSize` override. Static renderer options are bypassed on this path because no content is regenerated. +With a valid `source`, an unchanged `review` export returns the admitted file byte for byte. Exact reuse requires the same compiled schema, an unchanged review document, unchanged or omitted comments, and no `title`, `margins`, `orientation`, `pageSize`, `stylesheet`, or `fontFamily` option. `component` and `presentation` do not apply on this path, because export regenerates nothing. If an exact check fails, `exportDocx` regenerates the complete document body. It keeps these source units when their internal relationships are closed, reachable, and nonconflicting: @@ -269,10 +268,29 @@ The same configured static renderer handles the main document and rich comment b ### Static rendering and styles DOCX owns its semantic static mappings for code blocks, columns, equations, -callouts, headings, and tables of contents. The app supplies presentation CSS -and may pass a custom root `component` when it needs a different wrapper. +callouts, headings, and tables of contents. They set no color, font, or font +size beyond an element's own values, such as a callout's color. The app's +`stylesheet` sets them and can select: + +- `editor-`, the class on each drawn element, such as `.editor-codeBlock`; +- `table > tbody > tr > td` inside a callout, whose first cell holds the icon; +- `[data-docx-preserve-whitespace]`, the text of a code block; +- `[data-empty]`, the placeholder of an empty equation or table of contents. + +The app may pass a custom root `component` when it needs a different wrapper. `DOCX_EXPORT_STYLES` is the registry's copied presentation preset. +Equations export as Word math built from KaTeX's MathML. An equation that uses +notation the converter does not cover, such as a matrix, exports its TeX as +text inside a Word equation. A task list item, an `li` with `data-checked`, exports with a ☑ or ☐ +list marker. + +A live editor's components are editing UI. Pass the app's static kit as +`presentation` so the body and every comment body draw with static components +of the same plugin name. A plugin with no static version exports its content +without its component or wrapper and reports a `missing-static-presentation` +diagnostic, which counts as lost content under `lossPolicy`. + ## Format support @@ -289,7 +307,9 @@ and may pass a custom root `component` when it needs a different wrapper. | Multiple-section headers and footers, footnotes, and endnotes | Inspect and bound their package parts; omit them from edited source-aware output with diagnostics | | Fields, text boxes, shapes, charts, SmartArt, and embedded objects | Keep reachable plain text when conversion exposes it; report unsupported or omitted content | | Plate named roots and metadata | Omit from Word output with a `lossy-content` warning; keep them in authored JSON | -| Custom Plate, math, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics | +| Equations | Export as Word math, or as TeX inside a Word equation when the converter does not cover the notation. Import reads Word math into `equation` and `inlineEquation` nodes, with TeX rebuilt from the equation's structure, or into TeX text when those plugins are not installed. A display equation that shares its paragraph with a comment, tracked change or text, or sits in a list item or an indented paragraph, imports inline | +| Task lists | Give an item drawn as an `li` with `data-checked` a ☑ or ☐ list marker | +| Custom Plate, emoji, and media nodes | Use their configured static serializers; unsupported output produces diagnostics | ## API reference @@ -358,14 +378,15 @@ exportDocx( | `comments` | `readonly DocxComment[]` | Comment records in proposed-projection coordinates | | `allowRemoteImages` | `boolean` | Fetch remote HTTP(S) images to embed; defaults to `false` | | `component` | `React.ComponentType` | Optional static editor wrapper | -| `fontFamily` | `string` | Document body font | +| `fontFamily` | `string` | Document body font; rebuilds an unchanged `source` | | `lossPolicy` | `'allow' \| 'reject'` | Fail on dropped content, or return it as warnings; defaults to `'reject'` | | `margins` | `Margins` | Page margins in twentieths of a point | | `orientation` | `'landscape' \| 'portrait'` | Page orientation | | `pageSize` | `PageSize` | Page dimensions in twentieths of a point | +| `presentation` | `readonly BasePluginInput[]` | Static plugins that draw the body and comment bodies, matched by plugin name | | `signal` | `AbortSignal` | Cooperative cancellation signal | | `source` | `DocxSource \| null` | Retained source correspondence for exact or safe export | -| `stylesheet` | `string` | CSS applied before DOCX conversion | +| `stylesheet` | `string` | CSS that sets the Word look; rebuilds an unchanged `source` | | `title` | `string` | Document metadata title | A successful result contains `blob` and structured `diagnostics`. A failed result contains only `diagnostics`. diff --git a/content/docs/examples/export.cn.mdx b/content/docs/examples/export.cn.mdx index 9344315ad2..561ba353af 100644 --- a/content/docs/examples/export.cn.mdx +++ b/content/docs/examples/export.cn.mdx @@ -22,14 +22,18 @@ registry 中的 `export-toolbar-button` 在浏览器中将当前文档下载为 | 格式 | 实现 | 输出 | | ---- | ---- | ---- | -| HTML | `platejs/static` 的 `renderStaticHtml(model, { component: EditorStatic, projection })` | `plate.html` | +| HTML | `platejs/static` 的 `renderStaticHtml(model, { component: EditorStatic, presentation: BaseEditorKit, projection })` | `plate.html` | | Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` | -| Word | `platejs/docx/export` 的 `exportDocx(model, { component: EditorStatic, projection, stylesheet: DOCX_EXPORT_STYLES })` | `plate.docx` | +| Word | `platejs/docx/export` 的 `exportDocx(model, { component: EditorStatic, presentation: BaseEditorKit, projection, stylesheet: DOCX_EXPORT_STYLES })` | `plate.docx` | 所有格式都从 `useModelEditor()` 捕获同一份完整 document 和明确的 authored projection。导出读取 model,而不是 DOM,因此 selection、placeholder 和 review -decoration 不会进入文件。当前 editor 提供已编译的 schema、format mapping 和 static -component 绑定;导出不会创建第二个 conversion editor。 +decoration 不会进入文件。当前 editor 提供已编译的 schema、format mapping 和插件 +设置;static kit 只提供绘制方式。 + +editor 自身的组件是编辑 UI,所以 HTML 和 Word 把应用的 static kit 作为 +`presentation` 传入。导出用同名插件的 static 组件绘制每个节点。插件没有 +static 组件时,导出不带组件绘制其内容,并添加 `missing-static-presentation` 警告。 HTML 文件内联应用自身的 stylesheet,并复制页面的 `body` class。跨域 stylesheet 保留为 link。 diff --git a/content/docs/examples/export.mdx b/content/docs/examples/export.mdx index 66c7f12034..64da6bbd9b 100644 --- a/content/docs/examples/export.mdx +++ b/content/docs/examples/export.mdx @@ -24,15 +24,20 @@ component to add the menu to an editor toolbar. | Format | Implementation | Output | | -------- | -------------- | ------ | -| HTML | `renderStaticHtml(model, { component: EditorStatic, projection })` from `platejs/static` | `plate.html` | +| HTML | `renderStaticHtml(model, { component: EditorStatic, presentation: BaseEditorKit, projection })` from `platejs/static` | `plate.html` | | Markdown | `model.api.markdown.serialize({ projection })` | `plate.md` | -| Word | `exportDocx(model, { component: EditorStatic, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` | +| Word | `exportDocx(model, { component: EditorStatic, presentation: BaseEditorKit, projection, stylesheet: DOCX_EXPORT_STYLES })` from `platejs/docx/export` | `plate.docx` | Every format captures the same complete document and explicit authored projection from `useModelEditor()`. Export reads model content rather than the DOM, so selection, placeholders, and review decorations do not enter the file. -The current editor supplies its compiled schema, format mappings, and static component -bindings; export does not create a second conversion editor. +The current editor supplies its compiled schema, format mappings, and plugin +settings; the static kit supplies only the drawings. + +The editor's own components are editing UI, so HTML and Word pass the app's +static kit as `presentation`. Export draws each node with the static component +of the same plugin name. When a plugin has no static component, export draws its +content without one and adds a `missing-static-presentation` warning. The HTML file inlines the app's own stylesheets and copies the page's `body` classes. Cross-origin stylesheets stay as links. diff --git a/docs/plans/2026-10-09-html-static-repair-redo.decisions.tsv b/docs/plans/2026-10-09-html-static-repair-redo.decisions.tsv new file mode 100644 index 0000000000..b3398f75d9 --- /dev/null +++ b/docs/plans/2026-10-09-html-static-repair-redo.decisions.tsv @@ -0,0 +1,168 @@ +ts phase decision why evidence result +2026-10-09T13:33:57Z lead claude-opus-5-5 @xhigh read from the session transcript, since an agent cannot see its own effort transcript 5e83f7e5-30c4-4960-a568-e9ff2a177311.jsonl recorded claude session +2026-10-09T13:33:57Z plan Redo docs/plans/2026-10-09-html-static-repair.md at claude-opus-5-5 @xhigh The owner ran /redo on the draft. Its lead ran gpt-6-astra @medium (Codex rollout 2026-10-09T15-21-48 turn_context), its later revisions claude-opus-5-5 @medium (Claude session bf973712), and its seats name no effort, so every judgment unit counts below the current level. docs/plans/artifacts/html-static-repair-redo/draft-plan-before-redo.md; docs/plans/artifacts/html-static-repair-redo/draft-log-before-redo.tsv; node .agents/pstack/lead.mjs printed claude-opus-5-5 @xhigh; intake base 666f02406e recorded every judgment unit marked redo; deterministic proofs on unchanged export source reused; the Redo table in the new plan lists each unit +2026-10-09T13:33:57Z plan Keep the owner hold: no code until the direction is agreed with Ziad At 09:30Z in session bf973712 the owner asked to talk this over with Ziad first. The redo answers the questions evidence can settle and leaves the direction call to that talk. Claude session bf973712-908a-48cc-99b6-0ad714a412e4 user message 2026-10-09T09:30:55Z recorded this run ends at the plan; no product source edited +2026-10-09T13:33:57Z design Why lane: was drawing the live editing components a decision? The draft asked Ziad whether rendering live components was on purpose; the history can answer it. docs/plans/artifacts/html-static-repair-redo/why/answer.txt (why investigator opus @high); docs/plans/2026-09-24-document-first-export-contracts.md:127,325,367-368,441-442 recorded removing caller-built export editors was deliberate; drawing the editing components was never decided or tested; no source forbids a static render composition that shares policy +2026-10-09T13:33:57Z design Remeasure bug 1 across every element, slot and mark the docx demo can hold The draft row was partial: blockquote inconclusive, media and task lists unmeasured. scope: 170 top-level blocks from 21 registry demo values, each rendered alone through the live EditorKit as /blocks/docx-demo mounts it and through BaseEditorKit, plain and with DOCX_STATIC_COMPONENTS, at 666f02406e with apps/www/node_modules restored per docs/plans/artifacts/html-static-repair-redo/env-repair.txt and React set global for classic JSX; read 2026-10-09; docs/plans/artifacts/html-static-repair-redo/element-probe/probe-a4.log verified live HTML throws for 17 element types plus the task-list marker slot, media included; Word throws for 11 element types plus the marker; blockquote works; the static kit renders all 170 blocks in both modes; source-check 0 dist files +2026-10-09T13:33:57Z design Compare live and static output where both render Switching export to static presentation changes bytes the owner sees. scope: inner HTML of the 125 blocks both kits render, root element excluded; docs/plans/artifacts/html-static-repair-redo/element-probe/output-diff-summary.txt recorded 100 identical; live leaks contentEditable spacers, a button inside a div for dates and span for sub/sup; the static list wrapper double-indents 15 blocks (margin-left 48px against 24px live) +2026-10-09T13:33:57Z design Census configuration drift between the live and static kits The owner forbade a second plugin list to keep in sync; the size of the existing drift decides how much sharing the target needs. scope: compiled plugins of EditorKit and BaseEditorKit, non-presentation fields compared by stable JSON, at 666f02406e; docs/plans/artifacts/html-static-repair-redo/drift-probe/probe-a1.log recorded 45 shared plugins; render-relevant drift in table defaultTableWidth (600 vs null), list and indent targetPlugins and table inject; codeDrawing only static +2026-10-09T13:33:57Z design Test whether a static editor can render a captured authored document The draft claimed the static kit would need the authored-change plugins; the capture contract must hold for HTML and Word review. scope: live EditorKit document with one pending insertion and one pending deletion; HTML accepted and proposed, Word accepted, proposed and review compared by visible text, w:t and w:delText runs, w:ins and w:del counts and diagnostic codes against the live editor; happy-dom registered with Node web streams restored; docs/plans/artifacts/html-static-repair-redo/capture-probe/probe-a5.log verified the shipped static kit refuses the raw capture; BaseEditorKit plus the headless AuthoredPlugin matches live in all five cases, also through one shared editor and a createEditorView per export; projecting first keeps HTML but drops Word projection diagnostics and cannot do review +2026-10-09T13:33:57Z design Test swapping element presentation by plugin name over the live runtime The WIP review pointed at keeping the live runtime and repairing presentation selection. scope: the 170 blocks through the live EditorKit with every BaseEditorKit element component applied through the private override map; docs/plans/artifacts/html-static-repair-redo/overlay-probe/probe-a1.log verified every element renders; the task-list marker still throws because it is a wrapper slot, not an element component +2026-10-09T13:33:57Z design How lane: export call sites, dispatch, kits and runtime-set state Extraction rule: a Codex how explorer maps the files the design touches. docs/plans/artifacts/html-static-repair-redo/how-explorer/brief.md; docs/plans/artifacts/html-static-repair-redo/how-explorer/answer.txt (gpt-6.1-sol @xhigh, exit 0) recorded live slots run in static dispatch; installed decoration sources such as find and active comments run on export; a component throw escapes exportDocx as a throw, not a diagnostic; Word equation overrides emit LaTeX while the static kit draws KaTeX +2026-10-09T13:33:57Z architect Run architect with three runners and the draft design as a fourth candidate api-plan: the plan changes public export inputs. Redo step 4 takes the draft design as one more candidate. docs/plans/artifacts/html-static-repair-redo/architect/grounding.md; docs/plans/artifacts/html-static-repair-redo/architect/runner-prompt.md; runners docs/plans/artifacts/html-static-repair-redo/architect/runner-opus.txt (claude-opus-5-5 @high), docs/plans/artifacts/html-static-repair-redo/architect/runner-astra.txt (gpt-6-astra @xhigh), docs/plans/artifacts/html-static-repair-redo/architect/runner-sol.txt (gpt-6.1-sol @xhigh); draft docs/plans/artifacts/html-static-repair-redo/architect/candidate-draft.txt; pings docs/plans/artifacts/html-static-repair-redo/architect/ping-*.txt recorded all three runners converged across both model families on live semantics with static presentation looked up by plugin key, loud failure for missing presentation and no second semantic editor; all three rejected the draft shape +2026-10-09T13:33:57Z design Prove the converged premise: live semantics with static presentation renders every block Plan playbook: prove each premise that existing code already does something by running it. scope: the 170 blocks through the live EditorKit with BaseEditorKit element components applied by plugin name through the private override map, the list slot rebuilt with BlockListStatic, and the dnd and aiChat wrapNode slots removed as the stand-in for skipping a slot the static kit lacks; leaves left live; HTML and Word overrides; docs/plans/artifacts/html-static-repair-redo/presentation-probe/probe-a3.log; docs/plans/artifacts/html-static-repair-redo/presentation-probe/output-vs-static.txt verified 169 of 170 blocks render in both modes; the one failure is codeDrawing, which the live editor cannot hold; 160 of 169 match the static kit byte for byte inside the root; the rest differ through live leaves (spacer spans, sub as span) and the live table options +2026-10-09T13:33:57Z architect Pick cand-4 (live semantics, optional presentation by plugin key) as the base The blind judge scored it 40 of 48 against 38, 35 and 20 and the lead agrees: smallest phase 1 for both defects, no second semantic list, the only build-failing kit check. docs/plans/artifacts/html-static-repair-redo/architect/judge/verdict.txt (claude-opus-5-5 @high, blind); docs/plans/artifacts/html-static-repair-redo/architect/candidate-map.json; docs/plans/artifacts/html-static-repair-redo/architect/synthesis.md recorded base cand-4; corrections drop the platejs/react live-editor refusal (AI preview builds a react editor) and the table-width obligation; grafts loud coverage at export start, runtime rejection of content props, one binding inherited by derived views, frozen capture in phase 2, shared policy and Word lowering in phase 3 +2026-10-09T13:33:57Z plan Supersede the draft and open subject html on the draft's page link Redo step 3 opens a new iteration of the draft's subject; the draft's first review scope is html, whose hub exists but had no subject file. docs/plans/topics/html.md; docs/plans/2026-10-09-html-static-repair.md Status line; pre-edit copy docs/plans/artifacts/html-static-repair-redo/draft-plan-before-redo.md recorded the draft reads superseded; its Page line moved to the subject file; the redo plan leads the page +2026-10-09T13:33:57Z plan Write the redo plan with review_basis empty The draft named 2026-10-04-html-audit-2, whose Pursue hard-cuts createStaticEditor; this plan does not execute it, and naming it would mark that Pursue adopted when this plan lands. docs/plans/2026-10-09-html-static-repair-redo.md; docs/research/schema.md:134-151; node tooling/scripts/review-ledger.mjs check reports nothing for the new plan recorded the plan adds no detached operation and no createStaticEditor caller, so audit-2 stays free +2026-10-09T13:33:57Z writing unslop pass on the plan and the subject file Plan and subject prose are new. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md and docs/plans/topics/html.md recorded nine mid-sentence colons rewritten, one stale tracking claim cut +2026-10-09T13:33:57Z writing bro pass on Brief, Teach and Defaults The page shows these to the owner first. pstack:bro on docs/plans/2026-10-09-html-static-repair-redo.md; node .agents/pstack/plan-page.mjs --check exit 0 recorded Brief at 36 and 38 words, Teach at 135 words, Defaults in plain words +2026-10-09T14:04:35Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh The api-plan review row runs a panel on the finished plan; each seat answered its ping right before the round. frozen 900e00d20ab123a9b5ef825699875fd6e0c2e0a9; docs/plans/artifacts/html-static-repair-redo/panel-r1/prompt.md; docs/plans/artifacts/html-static-repair-redo/panel-r1/seat-opus.txt; docs/plans/artifacts/html-static-repair-redo/panel-r1/seat-astra.txt; docs/plans/artifacts/html-static-repair-redo/panel-r1/seat-sol.txt; pings docs/plans/artifacts/html-static-repair-redo/panel-r1/ping-*.txt recorded all three seats answered; opus 1 critical, 6 warnings, 2 nits; astra 4 critical, 2 warnings; sol 4 critical, 1 warning +2026-10-09T14:04:35Z panel critical (opus; astra and sol as warning): the plan has no repro steps and does not answer whether the fix contradicts Ziad's refactor The owner asked at 08:50Z for concrete repro steps and at 09:22Z whether the direction contradicts Ziad's refactor. frozen 900e00d20ab123a9b5ef825699875fd6e0c2e0a9; docs/plans/2026-10-09-html-static-repair-redo.md Reproduce today and For the talk with Ziad; docs/plans/artifacts/html-static-repair-redo/repro/repro-props-a1.log applied Reproduce today holds the table browser repro and the bug 2 script, whose run printed control true false, editorProp false true, documentProp false true; the talk section answers no with citations +2026-10-09T14:04:35Z panel critical (astra, sol; opus as warning): presentation omits the text-placement mark path, and the coverage census misclassified bindings pluginRenderTextStatic.internal.tsx:33 picks plugin.component for text-placement marks apart from leaf dispatch; BaseAIPlugin.ts:157 installs one. scope: compiled bindings of every live EditorKit plugin that draws, by kind and markPlacement, against BaseEditorKit peers, at 666f02406e; docs/plans/artifacts/html-static-repair-redo/presentation-probe/coverage-v2-a3.log applied presentation covers element, leaf-placement and text-placement components and wrapper slots; the census shows ai is a text-placement mark with no peer, and phase 1 adds its static peer +2026-10-09T14:04:35Z panel critical (astra, sol; opus as warning): a wrapper whose plugin the static kit omits was skipped silently, dropping list numbering if the list peer went missing Absence from the static kit cannot tell an editing wrapper from a forgotten content wrapper. docs/plans/artifacts/html-static-repair-redo/presentation-probe/coverage-v2-a3.log (dnd and aiChat wrappers carry a match; dnd.tsx:442-445 excludes read-only views); plan Layer and owner and Main changes applied a missing static peer never authorizes a skip; export draws the structural default and adds a missing-static-presentation warning; listing a peer is the explicit choice; the static render honors editOnly and slot match; the registry spec fails on any warning over the demo corpus +2026-10-09T14:04:35Z panel critical (astra, sol; opus as warning): removing the static list margin flattens Word list levels, a numbered code block included Plan playbook: a not-verified flag about a removed path runs as a probe before the pick. render-document-file.ts:569 and :640-656 derive the list level from the list's own margin. scope: three-level numbered paragraphs and a numbered code block at indent 2 through renderStaticHtml and exportDocx, live EditorKit, BaseEditorKit, BaseEditorKit without the list margin, then a worktree converter that carries the host block margin with the margin removed, w:ilvl read from word/document.xml; docs/plans/artifacts/html-static-repair-redo/list-probe/probe-a3.log; docs/plans/artifacts/html-static-repair-redo/list-probe/converter-prototype-a2.log; docs/plans/artifacts/html-static-repair-redo/list-probe/converter-prototype.patch applied today's live export flattens nested lists to level 0; the static margin gives levels 0 1 2 but 48 96 144 px in HTML; the converter that carries the host margin plus the margin removal gives 24 48 72 px and levels 0 1 2, and level 1 for the code block; the worktree was removed +2026-10-09T14:04:35Z panel critical (astra, sol; opus as nit): settings read after the first await break the one-capture law docs/vision/plate.md:1159-1162 requires output and diagnostics from one revision captured before the first await; createEditorViewPluginApis and getPluginStore read the source editor after renderStaticHtmlWithOverrides.tsx:51. scope: a two-column static table without column sizes at defaultTableWidth 600, width set to 900 synchronously after renderStaticHtml starts, at 666f02406e; plugin state objects frozen and replaced on write; docs/plans/artifacts/html-static-repair-redo/capture-law-probe/probe-a1.log; docs/plans/artifacts/html-static-repair-redo/presentation-probe/coverage-v2-a3.log applied the HEAD case renders 450 px cells against the captured 300 px; phase 1 now takes configuration and each plugin state object into one export frame before the first await, with tests f and h +2026-10-09T14:04:35Z panel warning (astra): the census Word rows never build a Word file element-probe/probe.tsx:42 and presentation-probe/probe.tsx call only renderStaticHtmlWithOverrides. docs/plans/artifacts/html-static-repair-redo/element-probe/probe.tsx; plan Steps phase 1 applied the plan calls those rows the render step Word runs; phase 1 adds real exportDocx files for rich fixtures read for text runs, w:ilvl and diagnostics +2026-10-09T14:04:35Z panel warning (opus): phase 1 carried four missing-peer rules and a runtime scan beyond the two fixes One rule is simpler, and the export loss law already turns a loss into a diagnostic. plan Defaults and Decision ledger; docs/plans/artifacts/html-static-repair-redo/element-probe/output-diff-summary.txt applied one rule, a structural default with a warning, and no runtime scan; leaf and text swaps stay because live leaves leak contentEditable spacers and span for sub +2026-10-09T14:04:35Z panel warning (opus): the "no second list" claim hid that static kit membership now matters, close to the rejected component map Honest cost for the decision Ziad makes. plan For the talk with Ziad applied the talk section states the cost, the registry guard, and why an array of static plugins read only for drawing is not the rejected caller-maintained map +2026-10-09T14:04:35Z panel warning (opus): no end state, phase 3 mixed two jobs, and audit-2 went unnamed Plan playbook: the target is fixed and phases order it. plan Target and Steps; For the talk with Ziad applied a Target section; phase 2 is the shared policy kit and phase 3 is Word lowering, each with its value against the previous phase; audit-2 is named and left free +2026-10-09T14:04:35Z panel warning (opus): the cost gate missed bundle size and had a cohort with no passing baseline The toolbar is a client file that would import the static kit; today's export crashes on the demo corpus. plan Steps phase 1 first step; export-toolbar-button.tsx:1 applied a toolbar chunk line under 20 KB gzip or a lazy kit load; the demo corpus is a correctness check only; the first-export line has its createEditor baseline +2026-10-09T14:04:35Z panel nit (opus): children and dangerouslySetInnerHTML cannot replace the document PlateStatic.tsx:703-731 passes explicit children; React throws when both are set. plan Public API option table applied only editor and document are reserved +2026-10-09T14:04:35Z panel nit (opus): the presentation binding mechanism was unnamed staticComponentOverrides.ts:9-18 keys on the exact editor object, which derived views do not share. plan Main changes applied the export frame is that mechanism +2026-10-09T14:04:35Z design corrected: the table-width obligation was dropped on the judge's V9, which was wrong table-static.tsx:48 calls read.cell, which reads defaultTableWidth from the store at BaseTablePlugin.ts:1200. docs/plans/artifacts/html-static-repair-redo/architect/synthesis.md Correction after panel round 1; docs/plans/artifacts/html-static-repair-redo/capture-law-probe/probe-a1.log corrected live export reads the live width; the editor-less renders drift until phase 2; a mid-export change is fixed by the phase 1 frame +2026-10-09T14:04:35Z writing unslop pass on the round 1 revision The revision rewrote most plan sections. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md recorded fourteen mid-sentence colons and two fragments rewritten +2026-10-09T14:04:35Z writing bro pass on the revised Brief, Teach and Defaults The page shows these first. pstack:bro on docs/plans/2026-10-09-html-static-repair-redo.md; node .agents/pstack/plan-page.mjs --check exit 0 recorded Brief at 38 and 37 words; four Defaults rows reworded +2026-10-09T14:24:48Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh Round 1 applied critical findings, so the revision earns a round that reviews only what changed. frozen 6fa05e28b8a0d56931d2fbf55027bb5f66c1e795, delta from 900e00d20a; docs/plans/artifacts/html-static-repair-redo/panel-r2/prompt.md; docs/plans/artifacts/html-static-repair-redo/panel-r2/round2.diff; docs/plans/artifacts/html-static-repair-redo/panel-r2/seat-opus.txt; docs/plans/artifacts/html-static-repair-redo/panel-r2/seat-astra.txt; docs/plans/artifacts/html-static-repair-redo/panel-r2/seat-sol.txt; pings docs/plans/artifacts/html-static-repair-redo/panel-r2/ping-*.txt recorded all three seats answered; opus 1 critical, 3 warnings, 2 nits; astra 2 critical, 2 warnings; sol 3 critical +2026-10-09T14:24:48Z panel critical (astra, sol; opus as warning): the converter prototype keeps the host indent in a module variable across awaits, so concurrent exports can swap list levels The patch sets hostMarginLeft at module scope and restores it in finally around an awaited child conversion; render-document-file.ts:624 awaits each child. docs/plans/artifacts/html-static-repair-redo/list-probe/converter-prototype.patch lines 41-60 read; render-document-file.ts:636-656 parent fallback; plan Main changes and Steps test i applied the build passes the host block down as a traversal argument through getIndentLevel's parent fallback, replacing the prototype's approach; test i runs two exports at once +2026-10-09T14:24:48Z panel critical (astra, sol; opus as warning): frozen state references miss stores a compiled callback closed over, and non-plain values inside state BaseCodeBlockPlugin.ts:795 captures store at extend time and :859 reads it during decoration; pluginStore.ts:153-163 keeps non-plain values by reference. BaseCodeBlockPlugin.ts:788-866 read; createPluginContext.internal.ts:219 read; BaseDetailsPlugin.ts:127-137, :243, :342 copy their Set; plan Layer and owner, Main changes, test h, Open work applied the frame answers every plugin store read and configuration read while the synchronous render runs, so closures read it; the claim narrows to plain state, and a shared non-plain value is open work with owner and stop +2026-10-09T14:24:48Z panel critical (sol; astra as warning): Word drops render diagnostics and its loss check lacks the new code, so lossPolicy reject would not refuse a missing image exportDocx.tsx:251, :261, :417 and :425 keep only the HTML; :376 recognizes three loss codes; :548 rejects only those. exportDocx.tsx:236-261, :370-380, :405-425, :540-552 read; plan Public API diagnostics row, Decision ledger, Steps implement step and test j applied exportDocx collects the render diagnostic from the body and every comment body, and a missing block drawing counts as lost content; test j covers allow and reject in the body and a comment body +2026-10-09T14:24:48Z panel critical (opus): static AI peers in the shared kit crash the AI preview with a duplicate ai plugin, and AI chat has no base plugin The seat traced resolvePlugins.ts:2296-2316 and ai-menu.tsx:54 and :106. scope: BaseEditorKit plus a static BaseAIPlugin.configure peer plus BaseAIPlugin.extend as the preview, then with an unrelated definePlugin('ai') planted, at 666f02406e; docs/plans/artifacts/html-static-repair-redo/ai-peer-probe/probe-a2.log applied the plan adds no AI peers, because AI chat has no base plugin and the peers would change three other kits; the duplicate crash is dismissed, since the same-lineage peer composes and only the planted unrelated ai plugin throws the duplicate error +2026-10-09T14:24:48Z panel warning (astra; opus as nit): the corpus guard misses drawings no demo block uses element-probe/probe.tsx:25 is a fixed id list, and ai-value holds no AI-marked text. plan Steps registry step applied the spec also compares compiled drawing bindings against a named list of three allowed absences, and its mutation removes the unused upload peer +2026-10-09T14:24:48Z panel warning (opus): the answer about Ziad's refactor left out that editorPlugins was removed docs/plans/2026-09-24-document-first-export-contracts.md:325 and :371 removed a plugin-array option from exportDocx. plan For the talk with Ziad applied the section says this plan adds back a plugin-array option of that kind, read only for drawing, and leaves that judgment to Ziad +2026-10-09T14:24:48Z panel warning (opus): the frame's owners and per-read cost were understated pluginStore.ts:220-231 and plugin.ts:1786-1800 resolve reads through the source editor. plan Layer and owner, Decision ledger and Steps cost probe applied the plugin store read and the view configuration read are named owners, and the cost probe adds a typing burst with the frame check +2026-10-09T14:24:48Z panel nit (opus): static dispatch drops object-form slots today, and afterNodeChildren was outside the contract pluginRenderElementStatic.internal.tsx:47-120 applies only function slots; PlateStatic.spec.tsx:62-64 shows static already honors editOnly. plan Main changes and Public API option table applied phase 1 evaluates match for object-form slots against the read-only view, and all three slots are in the contract +2026-10-09T14:24:48Z writing unslop pass on the round 2 revision The round 2 changes rewrote several sections. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md recorded three mid-sentence colons rewritten; one claim narrowed to the plugin actually read +2026-10-09T14:24:48Z writing bro pass on the changed Defaults row The page shows Defaults to the owner. pstack:bro on docs/plans/2026-10-09-html-static-repair-redo.md; node .agents/pstack/plan-page.mjs --check exit 0 recorded the AI row reworded in plain words +2026-10-09T14:40:48Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh Round 2 applied critical findings, so the revision earns a third round; it is the last before a build. frozen e28da64627c10bc401e542030b03a1bfafebe516, delta from 6fa05e28b8; docs/plans/artifacts/html-static-repair-redo/panel-r3/prompt.md; docs/plans/artifacts/html-static-repair-redo/panel-r3/round3.diff; docs/plans/artifacts/html-static-repair-redo/panel-r3/seat-opus.txt; docs/plans/artifacts/html-static-repair-redo/panel-r3/seat-astra.txt; docs/plans/artifacts/html-static-repair-redo/panel-r3/seat-sol.txt; pings docs/plans/artifacts/html-static-repair-redo/panel-r3/ping-*.txt recorded all three seats answered; opus 1 critical, 2 warnings, 2 nits; astra 2 critical; sol 2 critical, 2 warnings +2026-10-09T14:40:48Z panel critical (astra, sol): answering store reads from captured state misses shared mutable objects, such as the code highlighter engine behind a plain object lowlight/lib/index.js:37-44 returns a plain object over one HighlightJs instance; pluginStore.ts:108 and :163 keep functions and non-plain values by reference; the frame already changed approach in round 2. lowlight/lib/index.js:36-46 read; docs/plans/artifacts/html-static-repair-redo/capture-law-probe/probe-a1.log shows the HEAD gap the narrowed claim names; the seats' highlighter probes were not run; proof: none applied the frame leaves phase 1 instead of changing approach again, reverting the round 1 and 2 mechanism; settings are read at drawing time as today; Target, Main changes and Open work narrow the claim, and a separate plan owns capture because the typed asks name only the two defects +2026-10-09T14:40:48Z panel critical (astra, sol): a missing list wrapper drops numbering, yet the round 2 rule counted it only as a warning block-list-static.tsx:43-56 draws the list markup that render-document-file.ts:566 turns into numbering; exportDocx.tsx:548 rejects only classified content loss. plan Public API diagnostics row, Decision ledger, Steps implement step and test j applied Word counts every missing static drawing as lost content, replacing round 2's split by kind; test j adds a missing list peer under allow and reject +2026-10-09T14:40:48Z panel critical (opus): evaluating match on object-form slots pulls the discussion wrapper into every export discussion.tsx:1137-1141 matches every top-level block; pluginRenderElementStatic.internal.tsx:103-117 draws only function slots today; BaseEditorKit has no comments peer. discussion.tsx:1135-1143 and pluginRenderElementStatic.internal.tsx:100-118 read; plan Main changes and Open work applied round 2's object-form match evaluation is reverted; static dispatch keeps dropping object-form slots as today, and Open work names the narrowed claim for third-party wrappers +2026-10-09T14:40:48Z panel warning (opus): counting a missing drawing as lost content flips the result for exportDocx callers that rely on the strict default exportDocx.tsx:548 defaults lossPolicy to reject; the Brief and Defaults said plain with a warning. plan Brief, Defaults and Public API diagnostics row applied each says Word's default strict mode refuses a file with a missing drawing +2026-10-09T14:40:48Z panel warning (opus, sol): the converter's existing list-numbering state is module-wide, so test i can pass while numbering IDs cross exports render-document-file.ts:631 and :720 keep and reset a module map; :579 reuses IDs from it. plan Open work; test i scoped to levels; owner: natamox; stop: phase 3, or 2026-11-30 deferred it predates this plan and lies outside the two defects; test i checks levels only; owner: natamox; stop: phase 3, or 2026-11-30 +2026-10-09T14:40:48Z panel warning (sol) and nits (opus): the typing gate had no frozen number, and usePluginStore is a second store read path Both belong to the store-read change. plan Steps cost probe applied both are moot because the store-read change left phase 1 with its typing line +2026-10-09T14:40:48Z plan deviation: export settings capture leaves phase 1 Panel round 3 showed the store-read frame cannot capture shared mutable render dependencies, and the frame had already changed approach once. plan Defaults row "Settings changed while an export runs" (word: freeze now), Target, Main changes, Decision ledger "Settings read during export" (defer), Open work; pre-revision copy docs/plans/artifacts/html-static-repair-redo/plan-round3-reviewed.md recorded the Defaults pick reverses from taken with the document to read at drawing time; the approved draft never chose a capture, so no owner approval reverses +2026-10-09T14:40:48Z writing unslop pass on the round 3 settlement Settlement text rewrote several sections. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md recorded no remaining mid-sentence colons outside lists +2026-10-09T14:40:48Z writing bro pass on the settled Brief, Teach and Defaults The page shows these first. pstack:bro on docs/plans/2026-10-09-html-static-repair-redo.md; node .agents/pstack/plan-page.mjs --check exit 0 recorded the settings row reworded +2026-10-09T14:41:54Z writing unslop pass on the Close The Close is new prose the owner reads. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md Close recorded five label-and-colon bullets rewritten as sentences +2026-10-09T14:51:29Z plan Remove apps/www/node_modules, which this run created The run restores the intake state of files it created; the probes are done. docs/plans/artifacts/html-static-repair-redo/env-repair.txt; find over the directory showed only links and copied .bin scripts; ls apps/www/node_modules reports no such file recorded removed; no installed package touched +2026-10-09T14:51:29Z trail Decision-trail review by gpt-6.1-sol @xhigh A panel dismissed a critical claim and narrowed the capture claim, so the trail gets a review from another model family. docs/plans/artifacts/html-static-repair-redo/trail-review/brief.md; docs/plans/artifacts/html-static-repair-redo/trail-review/answer.txt; frozen b0148ea1448b4a7f55528778c48c3af6eb47fa27; transcript 5e83f7e5-30c4-4960-a568-e9ff2a177311.jsonl lines 1-1716 recorded eight warnings and no critical; two flags came from a transcript range that ended before the cleanup and the Close pass, and six led to plan fixes listed in the Close's Attention section +2026-10-09T14:51:29Z plan Add the two missing draft units to the Redo table The trail review found the bug 2 regression run and the table and named-root probes without a disposition. plan Redo table and Close counts recorded both are reuse rows; the ledger reads 13 units, 12 done and 1 open +2026-10-09T14:51:29Z design corrected: drawing the editing components was "never decided or tested" The why lane found no source that records the choice and labeled its conclusion inferred; an older cited probe directory is gone. docs/plans/artifacts/html-static-repair-redo/why/answer.txt items 1 and 3; plan For the talk with Ziad table corrected the plan says no source the lane read records the choice, and labels it inferred +2026-10-09T14:51:29Z plan Align phase 1 text with the round 3 settlement The trail review found the phase 1 value line still promising capture and the Layer and owner row still applying object-form match rules. plan Steps phase 1 value line, Layer and owner row, Public API diagnostics row, test j recorded phase 1 no longer promises capture; object-form slots stay undrawn; the list fix states why it belongs in phase 1; test j adds AI-highlighted text +2026-10-09T14:51:29Z writing unslop pass on the trail-review fixes The fixes changed plan prose. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md recorded one dense sentence split and the reviewer line set on its own line +2026-10-09T14:52:07Z plan Publish the html topic on a new page The draft's page belongs to another account, so this session could not read or update it; the plan-page skill then publishes a new page and records both links. https://claude.ai/artifact/JpnCNp8LPBuPcXoHzLT7bz (version 2); docs/plans/topics/html.md Page line; plan Defaults row "Page for this work" (word: old page) recorded the new page carries the redo plan; the old link stays named in Defaults +2026-10-09T15:38:40Z plan Line the plan up with the authored redesign The owner noted that comments and review are being refactored in a plan Ziad drafted that changes the data protocol, and asked to read both together and settle the best implementation. docs/plans/2026-10-08-authored-review.md Document shape, Main changes, Hard cuts and Phase 2 read; exportDocx.tsx:9-10, :35-36, :107, :157, :182, :260 and reviewProjection.tsx:68, :312 call the nouns that plan deletes; export-toolbar-button.tsx:5, :160-161; pre-edit copy docs/plans/artifacts/html-static-repair-redo/plan-before-authored-fit.md recorded the presentation target stands; phase 1 does not depend on how suggestions are stored and can land first; Word revision drawing, comment range mapping and the toolbar's projected view go to the authored Phase 2 step as three asks; the authored Phase 1 code is not in this checkout, so the fit rests on that plan's text +2026-10-09T15:38:40Z plan Keep the after-cap change for the build's diff panel The plan reached the three-round cap; a design change at the cap is reviewed in the diff panel after its build unless the owner asks for another round. node .agents/pstack/decisions-check.mjs rounds exits 1 at 3 rounds; plan Close, After the redo recorded the plan names the word another round for a plan panel first +2026-10-09T15:38:40Z writing unslop pass on the authored fit New plan prose. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md recorded one mid-sentence colon rewritten; two Defaults rows reworded in plain words +2026-10-09T15:43:43Z plan The owner settles the direction without the talk with Ziad The owner asked to settle the best implementation now, without waiting to discuss it with others. plan Status, Settled decisions, Close After the redo and Open work; pre-edit copy docs/plans/artifacts/html-static-repair-redo/plan-before-settle.md recorded the hold on a talk with Ziad is lifted; the picks stand as the settled direction; the build waits on the owner's go; the three switchover expectations become open work owned by natamox +2026-10-09T15:43:43Z writing unslop pass on the settled-direction edits Plan prose changed. pstack:unslop on docs/plans/2026-10-09-html-static-repair-redo.md recorded one sentence reworded +2026-10-09T15:57:29Z build Start phase 1 on the owner's go The owner wrote "go" after the settled plan. plan Build section and Completion Gates; docs/plans/artifacts/html-static-repair-redo/build-todo.md; base 666f02406e recorded execution authority; no commit and no push +2026-10-09T15:57:29Z build Install workspace dependencies offline apps/www/node_modules was absent, and registry tests and the browser proof need it. docs/plans/artifacts/html-static-repair-redo/env-install-a1.log (pnpm install --frozen-lockfile --offline --ignore-scripts, exit 0) recorded apps/www dependencies present; the only warning is the unbuilt CLI bin +2026-10-09T15:57:29Z build Blast radius of the public API change before code moves Build playbook: a public API change runs pstack:blast-radius first. docs/plans/artifacts/html-static-repair-redo/blast-radius.md recorded no caller passes editor or document in props; only renderStaticHtml and exportDocx reach the override helper; the converter's only entry is exportDocx; HTML round trip and generated registry output are the checks to run +2026-10-09T15:57:29Z verify Pre-acceptance probe for the presentation compile, warm dispatch and bundle The design adds a cached compile of the static kit; Benchmark's pre-acceptance probe decides it before any production edit. scope: renderStaticHtmlWithOverrides at 666f02406e against a disposable prototype (static kit compiled once per kit identity, components through the private override map), 100/1,000/5,000/10,000 counted paragraphs, 5 alternating packets of 5 warm samples, 3 fresh processes per side for the first export, Bun.build gzip of the live kit plus EditorStatic with and without BaseEditorKit, load 3.8 on 8 cores, frozen contract docs/plans/artifacts/html-static-repair-redo/perf-gate/contract.json read 2026-10-09; docs/plans/artifacts/html-static-repair-redo/perf-gate/evaluate-a1.log verified every line passes: warm medians within 1.5x plus allowance at all sizes with renders and bytes equal, 1 compile over 3 exports, first export +68 ms, bundle +0.01 KB gzip because ai-menu.tsx already imports BaseEditorKit; each known-bad arm fails; benchmark-checklist run; limiter not profiled, inferred as per-paragraph React rendering; prototype only, the final path reruns +2026-10-09T16:16:40Z verify Pre-edit baseline of the static and docx-export partitions verify's Evidence rule: cases on owners this edit touches get a pre-edit result. scope: pnpm --filter platejs test:partition:static and test:partition:docx-export on the checkout before any product edit (packages equal 666f02406e), read 2026-10-09; docs/plans/artifacts/html-static-repair-redo/pre-edit/static-partition-a1.log, docs/plans/artifacts/html-static-repair-redo/pre-edit/docx-export-partition-a1.log verified static 67 pass and docx-export 129 pass, 0 fail +2026-10-09T16:16:40Z build Write phase 1 tests a to j first and run each alone at HEAD Plan step 2 and the Tests rule: each test fails for its named defect before its fix lands; verify's testing reference asks for vertical slices. scope: each case run through tooling/scripts/proof-worktree.mjs --expect-fail at 666f02406e with only its spec copied in, failing on its own (fail) line for its named defect; docs/plans/artifacts/html-static-repair-redo/red/test-a-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-b-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-c-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-d-a2.log, docs/plans/artifacts/html-static-repair-redo/red/test-e-runtime-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-e-types-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-f-named-root-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-f-comment-body-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-g-a1.log, docs/plans/artifacts/html-static-repair-redo/red/test-i-a2.log, docs/plans/artifacts/html-static-repair-redo/red/test-j-a1.log verified every case fails at HEAD: a, b, c, d, f, g and j because the editing component ran, e because props were spread silently and both type cases compiled, i because Word levels came out 0, 0, 0 and 0 +2026-10-09T16:16:40Z build Discard two red attempts as fixture mistakes A red run counts only when it fails for its named defect. docs/plans/artifacts/html-static-repair-redo/red/test-d-a1.log failed with 'Point path [0,0] is not a text node' (a blockquote holds paragraphs); docs/plans/artifacts/html-static-repair-redo/red/test-i-a1.log also differed on code text, which Word writes with non-breaking spaces superseded by test-d-a2.log and test-i-a2.log +2026-10-09T16:16:40Z build Test j: a stand-in text mark named ai, and no list case inside a comment A package test cannot import the app's AI plugin. A wrapper slot is reported whenever a render consults it, because live dispatch calls a wrapper's outer function inside a component render (packages/platejs/src/react/utils/pluginRenderElement.internal.tsx:68-80), so it may use hooks and static export never calls it; the body paragraph would then report the list for the comment case too. packages/platejs/src/docx/export/lib/exportDocx.presentation.spec.tsx; plan step 2 (j) lists six cases recorded five cases; the image and text-mark comment cases prove comment-body collection, and the registry guard covers the real AI mark +2026-10-09T16:16:40Z build A missing wrapper peer is reported on every render that consults it Whether a live wrapper would draw for an element is known only by calling it, and static export never runs editing code. Elements and marks are reported only when a node needs them. packages/platejs/src/static/internal/staticPresentation.ts (resolveDrawing); plan Defaults row added recorded look default; a strict Word export of a document without lists still fails when the presentation lacks a live list wrapper +2026-10-09T16:49:39Z writing deslop on the phase 1 product diff Writing passes rule: product code gets deslop before review. pstack:deslop over the package, registry and spec diff applied rewrote the stale public JSDoc of renderStaticHtml's presentation option and the function summary; no defensive checks, any casts or nesting to remove +2026-10-09T16:49:39Z writing no-comments through comment-sicko, read-only, lead applies Writing passes rule and the Lead writes the code override. docs/plans/artifacts/html-static-repair-redo/comment-sicko/answer.md applied 5 deletions, 8 rewrites and 4 keeps; renamed drawing to renderDiagnostics in exportDocx and host to indentedAncestor in the Word converter; split resolveDrawing into early returns; reworded the diagnostic message; docs say plugin name, not key +2026-10-09T16:49:39Z writing Dismiss comment-sicko side finding 4: derive the Word diagnostic type from StaticHtmlDiagnostic tooling/entrypoints/entrypoint-dag.mjs:290-293 gives docx-internal only authored and core as dependencies, so docx/internal/types.ts cannot import from static. tooling/entrypoints/entrypoint-dag.mjs:290-293; packages/platejs/src/docx/internal/types.ts dismissed the shape stays defined twice, with matching JSDoc +2026-10-09T16:49:39Z writing Verify comment-sicko open findings 15 and 16 before rewording A kept comment whose claim is unverified stays open until checked. scope: whether editor.plugin() reads back the live kit's configured paragraph, and whether any non-test code writes the ai mark, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/dupes-probe.spec.ts (reading the kit's configured paragraph through editor.plugin() throws 'descriptor is not installed'); git grep finds no non-test caller of BaseAIPlugin.update.insertNodes, the only writer of the ai mark fixed 15 reworded to name the core ParagraphPlugin descriptor; 16 drops the streaming claim and keeps 'no static AI mark exists' +2026-10-09T16:49:39Z writing Defer comment-sicko side finding 3: the registry's AILeaf may draw a mark no shipped flow writes Outside phase 1; the guard keeps the ai gap named. apps/www/src/registry/components/editor/ai.tsx:59; plan Open work deferred owner: natamox; stop: the next AI menu review or 2026-11-30; proof: none +2026-10-09T16:49:39Z writing unslop on the six docs pages Writing passes rule: docs get one unslop pass after drafting. content/docs/examples/export.mdx, content/docs/(guides)/static.mdx, content/docs/(plugins)/(serializing)/docx.mdx and their .cn.mdx twins applied named what is left out instead of 'plainly', named the actor, split a colon-joined sentence +2026-10-09T16:49:39Z build Lint the task's 21 files Delivery and the Proof and tooling lint rule; other sessions may share the tree. scope: ultracite fix, oxfmt, ultracite check and oxlint --type-aware on the 21 task files, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/lint-check-a2.log, docs/plans/artifacts/html-static-repair-redo/build/lint-typeaware-a2.log verified clean on 21 of 21 files after one consistent-return fix in resolveDrawing +2026-10-09T16:49:39Z build Fix the guard spec's portal typing The www typecheck rejected reading component from a union of plugin portals. scope: tsc --noEmit -p apps/www/tsconfig.json on the checkout, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/www-tsc-a1.txt (2 errors, both in plugins-static.spec.ts); docs/plans/artifacts/html-static-repair-redo/build/www-tsc-a3.txt fixed the helper takes a structural parameter of the three fields it reads; www tsc exit 0 +2026-10-09T16:49:39Z build Drop @plate/editor-plugins-static from export-toolbar-button's registryDependencies The registry check refuses a dependency the item's own import already installs. scope: the registry dependency check in apps/www/src/registry/components/editor/markdown.spec.ts through test-suite.mjs, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/www-editor-and-integration-suite-a1.log (markdown.spec.ts failure); apps/www/src/registry/registry-editor.ts fixed markdown.spec.ts passes; the suite otherwise had 348 passes +2026-10-09T16:49:39Z verify Rerun the affected checks on the linted bytes Lint can rewrite bytes; verify's Evidence rule reruns cases after the last edit. scope: platejs typecheck (90 tasks, 51 cached by input hash), static and docx-export partitions, the registry guard spec and pnpm test:types on the checkout after lint, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/platejs-typecheck-a1.log, docs/plans/artifacts/html-static-repair-redo/build/static-partition-a3.log, docs/plans/artifacts/html-static-repair-redo/build/docx-export-partition-a3.log, docs/plans/artifacts/html-static-repair-redo/build/plugins-static-spec-a1.log, docs/plans/artifacts/html-static-repair-redo/build/type-tests-a1.log verified typecheck exit 0, static 74 pass, docx-export 137 pass, guard 4 pass, type contracts 0 errors +2026-10-09T16:51:19Z verify Docs checks for the six edited pages plate-docs verify table: build:source, check:docs and build:registry --check after the final prose pass. scope: www build:source, docs source parity and build:registry --check on the checkout, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/build-source-a1.log, docs/plans/artifacts/html-static-repair-redo/build/docs-source-parity-a1.log, docs/plans/artifacts/html-static-repair-redo/build/build-registry-check-a1.log; check:docs stopped at its first link, docs/plans/artifacts/html-static-repair-redo/build/check-docs-a1.log; proof: docs/plans/artifacts/html-static-repair-redo/build/check-docs-a1.log partial build:source, parity and the registry freshness check pass; check:docs's api-reference link needs packages/*/dist, which this checkout has not built, and apps/www/api-reference.config.json covers neither platejs/static nor platejs/docx/export, so CI's run owns it +2026-10-09T16:51:19Z build built: phase 1 package, registry, docs and release notes A plan's build restarts the panel count before its api-build diff panel. packages/platejs/src/static/internal/staticPresentation.ts (new, replaces staticComponentOverrides.ts), renderStaticHtmlWithOverrides.tsx, staticDocumentView.ts, pipeRenderElementStatic.internal.tsx, pluginRenderElementStatic.internal.tsx, pluginRenderLeafStatic.internal.tsx, pluginRenderTextStatic.internal.tsx, renderStaticHtml.tsx; packages/platejs/src/docx/export/lib/exportDocx.tsx, internal/render-document-file.ts, docxStaticComponents.tsx; packages/platejs/src/docx/internal/types.ts; specs renderStaticHtml.presentation.spec.tsx, renderStaticHtml.props.spec.ts, exportDocx.presentation.spec.tsx, exportDocx.lists.spec.tsx; packages/platejs/type-tests/static-html-contracts.ts; apps/www/src/registry/components/editor/export-toolbar-button.tsx, block-list-static.tsx, plugins-static.spec.ts; apps/www/src/registry/registry-editor.ts; six docs pages; .changeset/platejs-static-presentation.md; registry changelog entry 2026-10-09-export-static-presentation; generated registry output recorded phase 1 build complete before its diff panel +2026-10-09T17:27:23Z verify Mutation proof of every phase 1 fix on the frozen build Review rule: a fix's test must fail when that fix alone is reverted; the plan names the list and upload peers for the guard. scope: node .agents/pstack/mutate.mjs on commit 51201cf4773f, 13 mutations each reverting one fix (element, slot and text dispatch, root-view inheritance, missing record, props rejection, body and comment passthrough, Word loss count and collection, converter ancestor, static kit without list, static kit without upload), each with a passing control, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/mutate-run-a1.txt, docs/plans/artifacts/html-static-repair-redo/mutate-spec.json verified 13 of 13 caught, each failing on its own test line; the list and upload logs name 'list wrapNodeChildren' and 'upload component' +2026-10-09T17:27:23Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh api-build panel on the phase 1 diff (reviews: api-build), round 1 after the build's restart. reviewed tree commit 51201cf4773f; docs/plans/artifacts/html-static-repair-redo/panel-build-r1/prompt.md; docs/plans/artifacts/html-static-repair-redo/panel-build-r1/seat-opus.txt, docs/plans/artifacts/html-static-repair-redo/panel-build-r1/seat-astra.txt, docs/plans/artifacts/html-static-repair-redo/panel-build-r1/seat-sol.txt; pings docs/plans/artifacts/html-static-repair-redo/panel-build-r1/ping-claude-opus.txt recorded three seats answered; two model families +2026-10-09T17:27:23Z panel critical (gpt-6-astra 2, gpt-6.1-sol 4): an accepted Word export renders and counts the bodies of comments the projection omits captureExport drops those comments, but renderCommentBodies received the unfiltered list, so a missing drawing in an omitted body failed the strict export. scope: exportDocx with projection 'accepted', a comment only on proposed text and an ai mark without a static peer in its body, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r1/astra2-probe-a4.log (ok false with the comment, ok true without); docs/plans/artifacts/html-static-repair-redo/panel-build-r2/mutate-run-a1.txt (comments-not-filtered caught) applied renderCommentBodies takes capture.comments; test 'ignores comment bodies the accepted projection omits' +2026-10-09T17:27:23Z panel critical (gpt-6-astra 4, gpt-6.1-sol 5): without presentation the fallback element path drops memoized afterNodeChildren components The function-only slot reader replaced the fallback's component rendering. scope: pipeRenderElementStatic on an element with no plugin binding and a React.memo afterNodeChildren, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r1/astra4-memo-at-head-a1.log (passes at 666f02406e); docs/plans/artifacts/html-static-repair-redo/panel-build-r2/mutate-run-a1.txt (fallback-drops-memo caught) applied the reader keeps afterNodeChildren as written, the plugin path keeps its base function check; test 'renders a memoized afterNodeChildren component around the default output' +2026-10-09T17:27:23Z panel critical (gpt-6-astra 3): edit-only wrappers count as missing drawings Static dispatch never applied editOnly (resolvePlugins.ts:887-910), so a plan claim that it already did was wrong. scope: renderStaticHtml with presentation [] and a live editOnly wrapNodeChildren, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/mutate-run-a1.txt (edit-only-reported caught) applied with a presentation, render and slot drawings that isEditOnly marks are left out without a diagnostic; without one, rendering is unchanged; test 'leaves out an edit-only wrapper without reporting it' +2026-10-09T17:27:23Z panel critical (gpt-6.1-sol 3; gpt-6-astra 5 as warning): the compiled presentation is cached by array identity alone An array changed in place kept serving its old drawings. scope: two renders with one array whose entry changes between them, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/mutate-run-a1.txt (cache-by-identity-only caught) applied the cache keeps a copy of the entries and recompiles when any entry differs; test 'draws with the current entries of a reused presentation array' +2026-10-09T17:27:23Z panel critical (gpt-6.1-sol 2): a slot only the presentation peer declares never draws Slot dispatch walked the live editor's slot cache only. scope: a live plugin without wrapNodeChildren and a peer with one, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r1/static-only-slots.spec.ts found 0 such pairs in the registry kits; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/mutate-run-a1.txt (peer-only-slots-skipped caught) applied with a presentation, static dispatch visits the live plugins whose own or peer slot is set, in plugin order; test 'draws a wrapper slot that only the static peer declares' +2026-10-09T17:27:23Z panel critical (gpt-6-astra 1; claude-opus-5-5 5 as nit): a presentation drawing created in a configure((ctx) => ...) callback reads the compile editor's state Rare: no shipped static kit builds a drawing in a context callback. After the compile moved to withPlateFormatCompilation, the seat's case throws 'Plate runtime is not installed.' instead of silently drawing presentation defaults. scope: the seat's exact case run on the fixed code, and a count over the repository's registry static kits and non-React package plugins at the checkout, read 2026-10-10: 0 real hits in 404 files (1 false positive, list-static.tsx:8, a plain object config); docs/plans/artifacts/html-static-repair-redo/panel-build-r1/astra1-probe-a2.log, docs/plans/artifacts/html-static-repair-redo/panel-build-r1/context-drawings-count-a2.log dismissed rare; the presentation JSDoc states that drawings read plugin state through their editor prop and that a context-callback drawing throws +2026-10-09T17:27:23Z panel warning (gpt-6.1-sol 1): compiling the presentation builds and activates an editor that is never disposed Activation hooks of presentation plugins ran on every compile. scope: a presentation plugin with an activate counter, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/mutate-run-a1.txt (compile-activates caught: 1 activation instead of 0) applied compile through withPlateFormatCompilation, which compiles without activation; test 'compiles the presentation without activating its plugins' +2026-10-09T17:27:23Z panel warning (claude-opus-5-5 1): a live wrapper that never draws during export but is not edit-only, such as CopilotPlugin's, fails strict Word export Outside the ask; the registry toolbar uses lossPolicy 'allow' and no registry editor installs copilot with the export toolbar. packages/platejs/src/ai/react/CopilotPlugin.tsx:547-560; plan Open work; proof: none deferred owner: natamox; stop: an editor that installs copilot and the export toolbar, or 2026-11-30 +2026-10-09T17:27:23Z panel warning (claude-opus-5-5 2): the registry guard reads kit entries, not the compiled plugins export consults No public API lists an editor's compiled plugins; the corpus render covers dependencies for demo content. apps/www/src/registry/components/editor/plugins-static.spec.ts; plan Open work; proof: none deferred owner: natamox; stop: phase 2's policy kits, or 2026-11-30 +2026-10-09T17:27:23Z panel nit (claude-opus-5-5 3): resolveDrawing's draws parameter is redundant The slot reader filters to what draws. packages/platejs/src/static/internal/staticPresentation.ts applied the parameter is gone; isComponent decides for every reader +2026-10-09T17:27:23Z panel nit (claude-opus-5-5 4): the leaf reader applies the peer's own placement It differs only when an app gives one plugin different placements in its live and static kits; no registry pair does. packages/platejs/src/static/internal/staticPresentation.ts (getStaticMarkComponent) dismissed no live and static pair in the registry configures different placements +2026-10-09T17:27:23Z panel nit (claude-opus-5-5 6): the export docs say export creates no second editor The presentation is compiled. content/docs/examples/export.mdx and export.cn.mdx applied the sentence says export compiles the static kit only to read its drawings +2026-10-09T17:27:23Z panel nit (claude-opus-5-5 7): the type contract repeats the props spec's @ts-expect-error cases The type contract imports platejs/static, the public entrypoint, which the spec's source import cannot cover (verify testing reference: public type contracts import public entrypoints). packages/platejs/type-tests/static-html-contracts.ts dismissed it guards the public entrypoint's type +2026-10-09T17:27:23Z writing Writing passes on the round 1 fixes Each panel round's fixes get their writing passes before the next freeze. pstack:deslop over the fix diff (no change); docs/plans/artifacts/html-static-repair-redo/comment-sicko-r2/answer.md (one deletion, one public JSDoc rewrite applied) applied deslop found nothing; no-comments removed one comment and rewrote the presentation limit sentence +2026-10-09T17:27:23Z build Add a browser case for exporting interactive blocks through the toolbar No package test or kit guard sees whether the toolbar passes presentation; the owner asked for browser verification. scope: apps/www/tests/browser/docx.spec.ts 'the export menu exports interactive blocks to HTML and Word' against next dev on port 3297 (devSource true, cwd this checkout) and against 666f02406e on port 3298, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a1.log (4 of 4 pass), docs/plans/artifacts/html-static-repair-redo/browser/browser-case-at-head-a1.log and docs/plans/artifacts/html-static-repair-redo/browser/browser-case-at-head-errors-a1.log (fails at HEAD: useEditorContext must be used inside the Plite context), docs/plans/artifacts/html-static-repair-redo/browser/pass-a9/ verified HTML and Word carry the task, code, column, table and image; typing in the table and toggling the task still work afterwards +2026-10-09T17:27:23Z build Narrow the claim that every block exports in both formats Word export of a callout fails in the CSS inliner, the same way at 666f02406e with the static kit; before phase 1 no callout reached Word. docs/plans/artifacts/html-static-repair-redo/word-hang/callout-cause-a2.log, docs/plans/artifacts/html-static-repair-redo/word-hang/callout-cause-at-head-a1.log; plan Open work; proof: docs/plans/artifacts/html-static-repair-redo/word-hang/callout-cause-at-head-a1.log partial callouts export to HTML; Word reports 'The Word document could not be exported.'; tracked as Open work with owner natamox +2026-10-09T17:50:11Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh api-build panel round 2: the delta between frozen commits 51201cf4773f and 1bbe4a3044330764dacf2ef24f535478fbba5d19. reviewed tree commit 1bbe4a3044330764dacf2ef24f535478fbba5d19; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/prompt.md; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/round2.diff; docs/plans/artifacts/html-static-repair-redo/panel-build-r2/seat-opus.txt, docs/plans/artifacts/html-static-repair-redo/panel-build-r2/seat-astra.txt, docs/plans/artifacts/html-static-repair-redo/panel-build-r2/seat-sol.txt recorded three seats answered; two model families +2026-10-09T17:50:11Z panel critical (gpt-6-astra 1, gpt-6.1-sol 1; claude-opus-5-5 3 as warning): the detached compile clears the runtime that context-callback drawings read, so ctx.schema and ctx.store reads throw at render Round 1's switch to withPlateFormatCompilation was the compile mechanism's one replacement, and its own goal (no activation) is not something the owner's ask needs, so it is reverted rather than patched again. scope: a presentation component created in configure((context) => ...) that reads context.schema.type, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r3/mutate-run-a2.txt (detached-compile caught: the test fails when the detached compile returns) applied reverted to buildEditor; test 'draws a presentation component that reads its schema from the configure context'; the JSDoc now says a context-callback drawing reads the separate editor the presentation is compiled in +2026-10-09T17:50:11Z panel critical (gpt-6.1-sol 2): the peer-only slot union reaches the fallback element path, which gives the slot no plugin context and never runs wrappers The union was round 1's fix for a slot only the peer declares (0 such pairs in the registry kits); it is reverted, so the live editor alone decides which drawings exist, and the fallback path is restored byte for byte. scope: git diff 666f02406e -- packages/platejs/src/static/pipeRenderElementStatic.internal.tsx is empty, and static dispatch reads pluginCache.slots again, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r1/static-only-slots.spec.ts applied reverted the union and the fallback changes; the JSDoc states that a drawing the installed plugin does not declare does not draw and that an element no plugin renders still draws its installed afterNodeChildren slots +2026-10-09T17:50:11Z panel critical (gpt-6.1-sol 3; claude-opus-5-5 1 as warning): a memoized afterNodeChildren peer counts as drawn, but the plugin path only calls functions, so it vanished with no diagnostic Coverage must agree with the renderer that consumes it. scope: a live function afterNodeChildren with a React.memo peer, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r3/mutate-run-a2.txt (reader-accepts-memo-peer caught) applied the slot reader accepts functions only, so the memo peer counts as missing; test 'reports an afterNodeChildren peer the static render cannot call' +2026-10-09T17:50:11Z panel critical (gpt-6-astra 2): Word export of a callout still fails, though the approved plan covers every block that crashes The producer is CalloutDocx: juice throws on its quoted emoji font list in a td that a stylesheet rule matches. scope: exportDocx of a callout under a 'th, td' stylesheet rule, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/word-hang/juice-react-a1.log (unquoted family names inline cleanly), docs/plans/artifacts/html-static-repair-redo/panel-build-r2/callout-test-at-head-a1.log (fails at 666f02406e), docs/plans/artifacts/html-static-repair-redo/panel-build-r3/mutate-run-a2.txt (callout-quoted-fonts caught) applied the callout's Word drawing lists its emoji fonts unquoted; test 'exports a callout under a stylesheet that styles table cells'; the browser case now exports a callout to Word +2026-10-09T17:50:11Z panel warning (gpt-6-astra 3, gpt-6.1-sol 4; claude-opus-5-5 6 as nit): the browser row claimed Word carries the image, which Word omits The toolbar leaves allowRemoteImages off, so a remote image is omitted with a warning. docs/plans/artifacts/html-static-repair-redo/browser/pass-a9/document.xml (no drawing element); apps/www/tests/browser/docx.spec.ts applied the claim is corrected below; the case proves the crash fix and the text of each block, not the image in Word +2026-10-09T17:50:11Z panel warning (claude-opus-5-5 2): peer-only leaf drawings are skipped when live and static placements differ A mark's placement is plugin meaning the live editor owns; after the union's revert the rule is one: the live plugin decides which drawings exist. packages/platejs/src/internal/plugin/resolvePlugins.ts:788; no registry pair configures different placements dismissed placement belongs to the editor's plugin +2026-10-09T17:50:11Z panel nit (claude-opus-5-5 4, 5, 7): slot reader typing, a runtime read before the peers guard, and the docs sentence on compiling The reverts removed the union and the runtime read; the docs sentence no longer describes compiling. packages/platejs/src/static/internal/staticPresentation.ts; content/docs/examples/export.mdx applied moot after the reverts, or reworded +2026-10-09T17:50:11Z corrected Round 1's rare dismissal of the context-callback finding (gpt-6-astra round 1 finding 1) Its count covered this repository's kits only, which supports a rare call only as inferred. docs/plans/artifacts/html-static-repair-redo/panel-build-r1/context-drawings-count-a2.log; plan Open work; proof: none recorded the outcome is a narrowed claim: the presentation JSDoc and the static guide say a context-callback drawing reads the presentation's own compiled editor +2026-10-09T17:50:11Z corrected The browser case row claimed HTML and Word carry the image Word omits remote images under the toolbar's settings. docs/plans/artifacts/html-static-repair-redo/browser/pass-a9/document.xml; proof: none recorded HTML carries the image; Word omits it with a warning, as the toolbar's remote-image setting decides +2026-10-09T17:50:11Z writing Writing passes on the round 2 fixes Each panel round's fixes get their writing passes before the next freeze. pstack:deslop (one JSDoc rewrite); docs/plans/artifacts/html-static-repair-redo/comment-sicko-r3/answer.md (two rewrites applied); pstack:unslop on the static guide and export docs (two colon-joined sentences split) applied +2026-10-09T17:50:11Z verify Final-path benchmark rerun of the frozen contract Benchmark: rerun the pre-acceptance contract on the shipped path. scope: perf-gate/final-probe.tsx through renderStaticHtml with presentation on the checkout after round 2's fixes, load 3.1 to 4.0 on 8 cores, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/perf-gate/final/evaluate-a1.log, docs/plans/artifacts/html-static-repair-redo/perf-gate/final/evaluate-a2.log; proof: docs/plans/artifacts/html-static-repair-redo/perf-gate/final/evaluate-a2.log partial L1 at 1,000, 5,000 and 10,000 paragraphs, L2, L3 (first export +75.8 ms) and L4 (+0.02 KB) pass with each known-bad arm rejected; L1 at 100 paragraphs is inconclusive in both runs, because one baseline outlier (48.5 ms in run 1) widened the allowance past the planted slowdown, while candidate and baseline medians stayed within 0.6 ms +2026-10-09T18:06:12Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh api-build panel round 3, the last allowed: the delta between frozen commits 1bbe4a3044330764dacf2ef24f535478fbba5d19 and 8c5670d17b64ec2de45ecf58d7d57022d0d1a846. reviewed tree commit 8c5670d17b64ec2de45ecf58d7d57022d0d1a846; docs/plans/artifacts/html-static-repair-redo/panel-build-r3/prompt.md; docs/plans/artifacts/html-static-repair-redo/panel-build-r3/round3.diff; docs/plans/artifacts/html-static-repair-redo/panel-build-r3/seat-opus.txt, docs/plans/artifacts/html-static-repair-redo/panel-build-r3/seat-astra.txt, docs/plans/artifacts/html-static-repair-redo/panel-build-r3/seat-sol.txt recorded three seats answered; two model families; the cap is reached +2026-10-09T18:06:12Z panel critical (gpt-6-astra 1, gpt-6.1-sol 1; claude-opus-5-5 2 as warning): with a presentation, the fallback path for an element no plugin renders still runs the live afterNodeChildren slots, ignoring peers, editOnly and diagnostics The fallback's slot handling changed in round 1 and was restored to 666f02406e in round 2, so it leaves this iteration's loop at the cap. No live afterNodeChildren declaration exists in the package or registry sources (claude-opus-5-5 round 3, git grep at 8c5670d), so the count supports rarity only as inferred, which does not dismiss a critical. plan Open work; proof: none open patch: with a presentation bound, resolve the fallback's afterNodeChildren through the presentation (peer drawn as a component, edit-only skipped, missing reported) and keep the 666f02406e behavior without one; owner: natamox; stop: the next plan iteration for drawing context; the JSDoc and the static guide state the exception; plan Open work tracks it +2026-10-09T18:06:12Z panel critical (gpt-6-astra 2): after the revert to buildEditor, a drawing created in a configure((ctx) => ...) callback renders the presentation's own state with no diagnostic The compile mechanism had its replacement in round 1 and its revert in round 2, so it leaves the loop and goes back to planning. The goal still needs presentation; a compile that keeps a valid drawing context without activating plugins needs its own design, which also settles docs/vision/plate.md:1169-1171 (detached compiles do not activate plugins). plan Open work; proof: none open patch: compile the presentation without activation while keeping a valid drawing context, or refuse context-configured drawings at bind with an error that names the plugin; owner: natamox; stop: the next plan iteration for drawing context; the claim is narrowed in the presentation JSDoc and the static guide; plan Open work tracks it +2026-10-09T18:06:12Z panel warning (gpt-6-astra 3, gpt-6.1-sol 2): compiling with buildEditor activates the presentation's plugins and never cleans them up Same mechanism as the open compile finding; the registry kit's activate hooks only validate settings or use WeakMaps (claude-opus-5-5 round 3). plan Open work; proof: none deferred owner: natamox; stop: the next plan iteration for drawing context +2026-10-09T18:06:12Z panel warning (claude-opus-5-5 1): the callout fix covered one quoted style, while the producer is the juice call, which throws on any quoted inline style that a stylesheet rule matches Fixing the producer covers the class: code blocks under a div or * rule and presentation drawings with quoted fonts. scope: exportDocx of a code block under 'div {}' and '* {}', a callout under 'th, td {}', and exportHtmlToDocx of a quoted-font p under 'p {}', on the final code, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/panel-build-r3/opus-juice-probe-a1.log (all ok); docs/plans/artifacts/html-static-repair-redo/final-freeze/mutate-run-a1.txt (juice-without-decode caught on commit 80abd7683a18) applied juice runs with decodeStyleAttributes: true; the callout's font list is restored to 666f02406e; the callout test now guards the boundary +2026-10-09T18:06:12Z panel warning (claude-opus-5-5 3): CopilotPlugin's editing-only wrapper makes every export of a copilot editor report lost content Already deferred in round 1; the producer fix (marking the wrapper edit-only) changes a package plugin outside this phase. packages/platejs/src/ai/react/CopilotPlugin.tsx:547-560; plan Open work; proof: none deferred owner: natamox; stop: an editor that installs copilot and the export toolbar, or 2026-11-30 +2026-10-09T18:06:12Z panel nit (claude-opus-5-5 4, 5): the docs did not say to pass a stable array, and the static guide stated a wider contract than the JSDoc Both are prose fixes. packages/platejs/src/static/renderStaticHtml.tsx; content/docs/(guides)/static.mdx and static.cn.mdx applied the JSDoc says to pass a stable array, and the static guide now states the same contract as the JSDoc +2026-10-09T18:06:12Z writing Writing passes on the post-cap fixes Product code and docs changed after round 3. pstack:deslop (nothing to change); docs/plans/artifacts/html-static-repair-redo/comment-sicko-r4/answer.md (keep); pstack:unslop on the static guide, both guides and the Vision line applied unslop replaced 'plainly' with what is left out +2026-10-09T18:06:12Z build best-api repair for the presentation option AGENTS: a reusable public API change runs best-api repair before closeout. docs/vision/plate.md (new presentation law beside the static renderer law); content/docs/(guides)/authored-changes.mdx, serializing.mdx and their .cn.mdx twins; no skill teaches the old shape (git grep over .agents/rules) recorded the Vision owner states the law; the two guides pass presentation; the tension with docs/vision/plate.md:1169-1171 is open work +2026-10-09T18:06:12Z verify Final checks on the linted bytes The close runs the cheap proofs and the acceptance checks once on bytes no pending step changes. scope: lint and type-aware oxlint on 25 of 25 task files, TURBO_FORCE platejs typecheck (90 tasks, 0 cached), pnpm test:types, static and docx-export partitions, www tsc, the round-trip and Word slow suites, and the docx browser spec on next dev port 3297 serving this checkout, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/lint-check-a7.log, docs/plans/artifacts/html-static-repair-redo/build/lint-typeaware-a7.log, docs/plans/artifacts/html-static-repair-redo/build/platejs-typecheck-a5.log, docs/plans/artifacts/html-static-repair-redo/build/type-tests-a2.log, docs/plans/artifacts/html-static-repair-redo/build/static-partition-a7.log, docs/plans/artifacts/html-static-repair-redo/build/docx-export-partition-a8.log, docs/plans/artifacts/html-static-repair-redo/build/candidate-roundtrip-a2.log, docs/plans/artifacts/html-static-repair-redo/build/docx-slow-a1.log, docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a4.log verified every check passes: static 79, docx-export 139, round trips 7, Word slow suites 31, browser 4 of 4 +2026-10-09T18:07:01Z verify Rerun the element probe on the built code in both formats Plan step: rerun the element probe on the built code and export rich fixtures through the real exportDocx. scope: each of the 170 blocks of 21 demo values alone through the docx demo's live kit with presentation BaseEditorKit, renderStaticHtml and exportDocx with the toolbar's options, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/final-element-probe/probe-a1.log verified 169 blocks export to HTML and Word with no throw and no missing-static-presentation; 1 codeDrawing block is skipped because the live kit does not install it; Word reports 16 unsupported-content and 2 resource-omitted warnings, its existing format limits +2026-10-09T18:08:19Z verify Docs and registry checks after the post-cap guide edits plate-docs: run the checks once after the final prose pass. scope: www build:registry then build:registry --check, build:source and docs source parity on the checkout, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/build-registry-check-a3.log, docs/plans/artifacts/html-static-repair-redo/build/build-source-a3.log, docs/plans/artifacts/html-static-repair-redo/build/docs-source-parity-a3.log verified all exit 0 +2026-10-09T18:08:19Z build Deviation: tests d and i stand in for rerunning the capture and list probes Both probes ran on disposable prototypes; test d checks the review export with presentation (one insertion, one deletion), test i the Word list levels, and the 169-block rerun covers the element probe in both formats. approved step 'Rerun the element, capture and list probes on the built code'; docs/plans/artifacts/html-static-repair-redo/final-element-probe/probe-a1.log; packages/platejs/src/docx/export/lib/exportDocx.presentation.spec.tsx; packages/platejs/src/docx/export/lib/exportDocx.lists.spec.tsx recorded the plan's Close lists it as a deviation +2026-10-09T18:08:19Z build Deviation: the browser case reads the downloaded files instead of opening them, and tracked changes come from the existing suggestion case Playwright saves each download; the case asserts their HTML and Word XML. 'Export as Word with tracked changes' appears only while suggestions are unresolved, which the existing suggestion case in the same spec sets up and exports through the same toolbar. approved step 'Prove it in the browser'; docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a4.log; apps/www/tests/browser/docx.spec.ts recorded no screenshot of the passing state was kept, because the claim is about crashes and file contents, not visuals +2026-10-09T18:09:56Z writing unslop on the build close, Status, gates, Open work and the changeset New prose before the trail review and the hand-back. pstack:unslop over docs/plans/2026-10-09-html-static-repair-redo.md and .changeset/platejs-static-presentation.md applied split three colon-joined sentences +2026-10-09T18:23:10Z verify Typecheck the changed package specs against the base verify's command recipes: platejs package typechecks cover source only; a touched spec runs tsconfig.test.json and compares with HEAD. scope: pnpm exec tsc -p packages/platejs/tsconfig.test.json --noEmit on this tree and in a detached worktree at 666f02406e, distinct errors compared by file, code and text, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/build/spec-typecheck-a1.log, docs/plans/artifacts/html-static-repair-redo/build/spec-typecheck-at-head-a1.log verified 108 distinct errors on both trees, none new and none in a task file +2026-10-09T18:23:10Z verify Preview the ten edited docs pages plate-docs: a preview of each page after the final prose pass. scope: curl of /docs and /cn/docs routes for export, static, docx, authored-changes and serializing on next dev port 3297 serving this checkout, each body checked for its page's new sentence, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/browser/docs-preview/ verified 10 of 10 return 200 with their new text in the rendered body; an HTTP body check, not a screenshot +2026-10-09T18:23:10Z corrected The round 3 freeze left out exportDocx.spec.ts, so the callout mutant first survived The control and the mutant both ran without the new test. docs/plans/artifacts/html-static-repair-redo/panel-build-r3/mutate-run-a1.txt (survived); docs/plans/artifacts/html-static-repair-redo/panel-build-r3/mutate-run-a2.txt (caught on the corrected freeze 8c5670d17b64ec2de45ecf58d7d57022d0d1a846); proof: none recorded the round 3 freeze was rebuilt with the spec before the seats launched +2026-10-09T18:23:10Z verify The browser step's approved proof asked for screenshots and opened downloads The case saved and read each download and recorded runtime errors, but kept no screenshot and opened only the HTML of the suggestion case. proof: docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a4.log partial the crash fix and each block's text are proven in Chromium; no screenshot and no opened Word file +2026-10-09T18:23:10Z verify Accept the browser step's narrower proof The owner's ask was verification in a real browser, which the Chromium run on the real toolbar meets; the claim is crashes and file contents, not visuals. proof: docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a4.log; word: browser screenshots accepted plan Defaults row 'Proof of the browser step' (look) +2026-10-09T18:23:10Z verify The capture and list probes were not rerun on the built code Tests d and i and the 169-block rerun stand in; they do not reproduce the capture probe's five authored comparisons. proof: docs/plans/artifacts/html-static-repair-redo/final-element-probe/probe-a1.log partial review revisions, list levels and per-block export are proven by tests; the five authored comparisons are not rerun +2026-10-09T18:23:10Z verify Accept the capture and list rerun's narrower proof The goal, exports that do not crash and keep Word list levels, holds under the tests. proof: docs/plans/artifacts/html-static-repair-redo/final-element-probe/probe-a1.log; word: port probes accepted plan Defaults row 'Proof of the capture and list reruns' (look) +2026-10-09T18:23:10Z trail Decision-trail review of the build Required: the last panel round left critical findings open. brief docs/plans/artifacts/html-static-repair-redo/trail-review-build/brief.md over frozen commit 373c1cd05e7fb061320716bf5561215e2f4142f4 and transcript lines 2243 to 5905; answer docs/plans/artifacts/html-static-repair-redo/trail-review-build/answer.txt recorded gpt-6.1-sol @xhigh raised seven warnings and one note; the plan's Close lists each with the lead's action +2026-10-10T02:06:11Z verify Pre-commit check of the bytes to commit The owner asked to commit; the commit must hold the bytes the final proofs ran on, with current generated registry output. scope: git diff 373c1cd05e7f against the working tree for packages, apps, content, docs/vision and .changeset lists only generated registry output, which the freeze left out; pnpm --filter www build:registry --check on that tree, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/delivery/registry-check-a1.log verified source, tests and docs match the frozen commit the final checks and the trail review read; registry generation is fresh (exit=0) +2026-10-10T02:06:11Z delivery Cut branch export-static-presentation from next and commit phase 1 On 2026-10-10 the owner asked to cut a branch and commit. That ask overrides the rule that the checkout stays on next; push still waits for the owner. scope: git switch -c from 666f02406e, then git commit --only on the task's paths; commit body docs/plans/artifacts/html-static-repair-redo/delivery/commit-message.txt words both open round 3 findings as open recorded the commit SHA is in git log of export-static-presentation; nothing pushed +2026-10-10T02:17:22Z verify Full pnpm check on commit d718b7fa6c in an --install worktree The owner accepted running the full check the build had left to CI. scope: pnpm check, all 25 steps, in a detached worktree at d718b7fa6c installed with pnpm install --offline --frozen-lockfile --ignore-scripts, read 2026-10-10; proof: docs/plans/artifacts/html-static-repair-redo/delivery/pnpm-check-a1.log partial 11 of 25 steps failed: test, test-slow, plite-test, cli-test, toolbar-variants and www stopped on the install (Bun's postinstall script was not run; plate: command not found); lint-baseline, core-audits, entrypoint-graph and review-ledger failed on this commit's files; knowledge and part of review-ledger name other plans +2026-10-10T02:17:22Z build Fix the check failures this commit caused Each failing step named a file from d718b7fa6c. scope: core-audits flagged type: BaseImagePlugin.name and a component from a contextual configure in two new specs; lint-baseline still listed the old StaticHtmlDiagnostic alias; packages/platejs/turbo.json lacked the media, standard-indent and standard-list partitions the new docx specs import; the draft plan's review_inputs cited run-directory files; docs/plans/artifacts/html-static-repair-redo/delivery/fix-specs-a1.log, docs/plans/artifacts/html-static-repair-redo/delivery/fix-schema-audit-a1.log, docs/plans/artifacts/html-static-repair-redo/delivery/fix-typecheck-a1.log applied type: 'image'; the configure-context test draws a slot, which the audit allows; pnpm lint:baseline dropped one entry; generate-entrypoint-turbo regenerated turbo.json; the draft's review_inputs name the three source files it judged +2026-10-10T02:17:22Z verify The rewritten configure-context test still catches the detached compile A rewritten test must fail for its named defect. scope: mutate.mjs on frozen commit f0fd6632443c71935be37e85ede6b9374c9c4fc6, the detached-compile mutation from round 3 against 'draws a presentation slot that reads its schema from the configure context', read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/delivery/mutate-fix/mutate-run-a1.txt verified caught (exit=1); control passed +2026-10-10T02:38:14Z verify Full pnpm check on d295f31731 in the main checkout, with rg and plate on PATH for the steps that spawn them The --install worktree run could not start six steps; the main checkout has the real install, and the tree was clean at d295f31731. scope: pnpm check, 25 steps, main checkout at d295f31731, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/delivery/pnpm-check-main-a1.log (19 ok); core-audits and entrypoint-graph had spawned rg, which this shell only defines as a function, and passed with an rg shim: docs/plans/artifacts/html-static-repair-redo/delivery/check-core-entry-shim-a1.log; www had no plate bin and passed with a plate shim: docs/plans/artifacts/html-static-repair-redo/delivery/check-www-shim-a1.log (3 of 5 turbo tasks cached, www:build ran) verified 22 of 25 steps pass on the committed bytes; test-slow failed only TableGrid's cold-compile budget under load average 18.6 and passed 3 of 3 alone (docs/plans/artifacts/html-static-repair-redo/delivery/tablegrid-bench-a3.log), on code this branch does not touch; review-ledger and knowledge fail the same way at 666f02406e on the emoji and authored plans (docs/plans/artifacts/html-static-repair-redo/delivery/base-ledger-knowledge-a1.log) +2026-10-10T02:40:57Z verify test-slow's TableGrid budget failure is pre-existing on this machine under load A failure counts as pre-existing only when the same check fails the same way at the base in a detached worktree. scope: pnpm check test-slow at d295f31731 in the main checkout and at 666f02406e in a detached worktree, back to back under load average 8 to 12, read 2026-10-10; docs/plans/artifacts/html-static-repair-redo/delivery/check-test-slow-a1.log (7.80 ms against a 5 ms budget), docs/plans/artifacts/html-static-repair-redo/delivery/base-test-slow-a1.log (8.13 ms against 5 ms) verified the same assertion fails at both commits; the gate closes with 22 of 25 steps passing and three base failures +2026-10-10T12:53:37Z build built: phases 2 and 3 through docs/plans/2026-10-10-html-static-kit-drift.md (6e086e37ae) and docs/plans/2026-10-10-html-word-owns-word.md (e087f26713), changing apps/www/src/registry/components/editor/block-list-static.tsx apps/www/src/registry/components/editor/docx-export.spec.tsx apps/www/src/registry/components/editor/docx-export.tsx apps/www/src/registry/components/editor/indent-static.tsx apps/www/src/registry/components/editor/list-static.tsx apps/www/src/registry/components/editor/plugins-static.spec.ts content/docs/(plugins)/(serializing)/docx.cn.mdx content/docs/(plugins)/(serializing)/docx.mdx packages/platejs/src/docx/export/lib/docxStaticComponents.tsx packages/platejs/src/docx/export/lib/exportDocx.lists.spec.tsx packages/platejs/src/docx/export/lib/exportDocx.spec.ts packages/platejs/src/docx/export/lib/exportDocx.tsx packages/platejs/src/docx/export/lib/internal/docx-document.ts packages/platejs/src/docx/export/lib/internal/math.ts packages/platejs/src/docx/export/lib/internal/render-document-file.spec.ts packages/platejs/src/docx/export/lib/internal/render-document-file.ts packages/platejs/src/docx/export/lib/internal/xml-builder.ts packages/platejs/src/docx/export/lib/sourcePreservation.spec.ts packages/platejs/src/docx/import/lib/importDocx.math.slow.tsx packages/platejs/src/docx/import/lib/importDocx.ts packages/platejs/src/docx/import/lib/wordMath.ts packages/platejs/src/docx/internal/wordMathVocabulary.ts packages/platejs/turbo.json Phases 2 and 3 were built in their own plan iterations and logged there; this log, which the pull request panel uses, had no built: row for them. git log 0b6281ac2b..6e086e37ae; proof: none recorded: phases 1 to 3 are committed on branch export-static-presentation +2026-10-10T13:13:23Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh The pr reviews row: the branch diff gets a panel before its pull request opens; every seat answered after a one-word ping. reviewed tree: commit 6e086e37ae082314646f1db34658a13eddb77778, packet `docs/plans/artifacts/html-static-repair-redo/pr/branch.patch`; prompt `docs/plans/artifacts/html-static-repair-redo/pr/prompt.md`; answers `docs/plans/artifacts/html-static-repair-redo/pr/opus.md`, `docs/plans/artifacts/html-static-repair-redo/pr/astra.md`, `docs/plans/artifacts/html-static-repair-redo/pr/sol.md` recorded 3 of 3 seats answered +2026-10-10T13:13:23Z panel critical (astra), warning (opus): a Word list item that holds only a display equation imports as a block equation and drops listType and indent with no diagnostic A numbered item holding one display equation is an ordinary Word layout, and strict import let the list loss through. The fix keeps the round-3 rule, block only when alone in its paragraph, and adds the paragraph's list and indent to that condition, so it is no new approach. red `docs/plans/artifacts/html-static-repair-redo/pr/red/list-equation-red-a2.log` (proof-worktree --expect-fail at 6e086e37ae082314646f1db34658a13eddb77778; the a1 run used a fixture with no Word numbering and is superseded), green `docs/plans/artifacts/html-static-repair-redo/pr/red/fixes-green-a2.log`; scope: importDocx of a two-item numbered list whose first item is an m:oMathPara keeps indent 1 and listType numbered on that item with an inline equation, and the other 17 math import tests, including a plain display equation as a block, pass applied: materializeEquations promotes a display equation to a block only when its paragraph has no listType or indent; test keeps a list item that holds only a display equation in its list +2026-10-10T13:13:23Z panel critical (sol), warning (opus): props with editor or document set to undefined pass the type and throw The public type admits an explicit undefined because exactOptionalPropertyTypes is off, and the guard tested own keys, so the type and the runtime disagreed. red `docs/plans/artifacts/html-static-repair-redo/pr/red/props-undefined-red-a1.log` (proof-worktree --expect-fail at 6e086e37ae082314646f1db34658a13eddb77778), green `docs/plans/artifacts/html-static-repair-redo/pr/red/fixes-green-a2.log`; scope: renderStaticHtml with props document and editor undefined renders Shown, and the two tests that pass a defined editor or document still reject with a TypeError applied: the guard rejects only a defined value; test renders when props set editor or document to undefined +2026-10-10T13:13:23Z panel warning (opus): xml-builder.ts and render-document-file.ts keep two hand-written DocxDocumentInstance types that the branch extends unevenly Outside the ask; both compile today and the docx suites pass. packages/platejs/src/docx/export/lib/internal/xml-builder.ts, render-document-file.ts; proof: none deferred; owner: natamox; stop: the next change to the DOCX renderer document type, or 2026-11-30 +2026-10-10T13:13:23Z panel nit (opus): compilePeers compares a stored copy of the array; nit (opus): an equation with no TeX annotation exports as an empty Word equation; nit (opus): the Copilot changeset line leaves out that a read-only live editor also skips the ghost text Each is outside the ask; the empty-TeX case needs MathML from a drawing other than the two equation overrides, and Copilot stops when the editor is read-only, so no ghost text is hidden. packages/platejs/src/static/internal/staticPresentation.ts, packages/platejs/src/docx/export/lib/internal/math.ts, .changeset/platejs-static-presentation.md; proof: none deferred; owner: natamox; stop: a report of either case, or 2026-11-30 +2026-10-10T13:13:23Z verify pnpm check on the branch at the panel tree The PR rule: a pull request does not open while pnpm check fails unless the owner says so. `docs/plans/artifacts/html-static-repair-redo/pr/pnpm-check-a1.log`, base `docs/plans/artifacts/html-static-repair-redo/pr/base-ledger-knowledge-a1.log`; proof: docs/plans/artifacts/html-static-repair-redo/pr/pnpm-check-a1.log partial: 23 of 25 steps pass with a ripgrep shim on PATH; review-ledger and knowledge fail; at 666f02406e the same two checks fail the same way for emoji-review, authored-review and emoji.md K7; the branch's own two ledger findings, a short review_commit and two Close sections that did not name their review inputs, are fixed in the working tree +2026-10-10T13:13:52Z writing deslop and no-comments on the round-1 fixes Writing passes: each panel round's fixes get their passes before the next round's freeze. `docs/plans/artifacts/html-static-repair-redo/pr/comment-sicko/reply.md`, `docs/plans/artifacts/html-static-repair-redo/pr/fix-lint-a1.log`, `docs/plans/artifacts/html-static-repair-redo/pr/red/fixes-green-a3.log`; proof: none applied: deslop found nothing; comment-sicko's deletion of the new wordMath.ts comment, which was wrong when only block equations are installed, and its named keepsLayout predicate are applied; lint and both test files pass +2026-10-10T13:29:55Z panel seats claude-opus-5-5 @high, gpt-6-astra @xhigh, gpt-6.1-sol @xhigh Round 1 applied two critical findings, so round 2 re-reviews only their fixes; every seat answered after a one-word ping. reviewed tree: frozen commit f6386640dfa9a37ec1c0084b91909e2b9a576801, delta `docs/plans/artifacts/html-static-repair-redo/pr/r2/delta.patch`; prompt `docs/plans/artifacts/html-static-repair-redo/pr/r2/prompt.md`; answers `docs/plans/artifacts/html-static-repair-redo/pr/r2/opus.md`, `docs/plans/artifacts/html-static-repair-redo/pr/r2/astra.md`, `docs/plans/artifacts/html-static-repair-redo/pr/r2/sol.md` recorded 3 of 3 seats answered; no critical finding, so the panel stops +2026-10-10T13:29:55Z panel nit (opus): the indent half of keepsLayout never decides a Word import, because Mammoth gives indent only to list items, which also carry listType; nit (opus): with only the block equation plugin, a list item's display equation becomes TeX text without a diagnostic, as an inline equation already does without its plugin; nit (opus): the check names the list plugin's keys instead of testing for a plain paragraph Each is outside the ask, the second is the rare setup the owner asked not to chase, and changing the check now would earn no review. packages/platejs/src/docx/import/lib/wordMath.ts materializeEquations; proof: none deferred; owner: natamox; stop: a report of a lost list or equation on Word import, or 2026-11-30 +2026-10-10T13:29:55Z verify pnpm check on the fixed tree The PR rule: a pull request does not open while pnpm check fails unless the owner says so. `docs/plans/artifacts/html-static-repair-redo/pr/pnpm-check-a2.log`, base `docs/plans/artifacts/html-static-repair-redo/pr/base-ledger-knowledge-a1.log`; proof: docs/plans/artifacts/html-static-repair-redo/pr/pnpm-check-a2.log partial: 23 of 25 steps pass with a ripgrep shim on PATH; review-ledger fails only for emoji-review and authored-review and knowledge only for emoji.md K7, which fail the same way at 666f02406e; opening the pull request waits on the owner +2026-10-10T13:35:18Z owner The owner chose to commit the two panel fixes and open the pull request into next with the two check steps that also fail on next stated in its description The PR rule lets a pull request open while pnpm check fails only on the owner's word; the owner picked the recommended option after reading both fixes. AskUserQuestion answer in this session; proof: none recorded: commit, push and open the pull request; merging stays with the owner +2026-10-10T13:35:18Z writing plate-docs edit of the docx guide Equations row in English and Chinese, the changeset props bullet and the documents subject line The panel fixes changed two promises: where a display equation imports inline, and when props throw. content/docs/(plugins)/(serializing)/docx.mdx, docx.cn.mdx, .changeset/platejs-static-presentation.md, docs/plans/topics/documents.md; `docs/plans/artifacts/html-static-repair-redo/pr/build-registry-check-a1.log`, `docs/plans/artifacts/html-static-repair-redo/pr/docs-parity-a1.log`; proof: none applied: registry output rebuilt and fresh, docs source parity passes +2026-10-10T14:54:33Z writing The round-1 fixes' writing row said deslop found nothing, but no deslop skill call ran in that round; deslop now ran on e730c687c7 and ca545c739b The reflect reviewer found the row was not backed by an invocation, and a writing pass counts only when its skill runs. `docs/plans/artifacts/html-static-repair-redo/reflect/reviewer.md` Divergent 3; proof: none corrected: deslop found nothing to change in keepsLayout, the props guard or their tests +2026-10-10T14:54:33Z writing No unslop pass ran on the five commit bodies or the pull request body Writing passes: commit bodies and PR text get one unslop pass; the reflect reviewer found none ran. `docs/plans/artifacts/html-static-repair-redo/reflect/reviewer.md` Divergent 3; proof: none gap: the commits are pushed; the PR body waits on the owner before any edit; owner: natamox; stop: the owner asks for a pass on the PR body, or the PR merges +2026-10-10T14:54:33Z writing side finding (comment-sicko): `as unknown as Editor` at packages/platejs/src/static/internal/renderStaticHtmlWithOverrides.tsx:58 predates this branch (a5d07011f3) Outside this branch's diff, and AGENTS.md lists unknown casts as a rule-only row whose sample is open work. `docs/plans/artifacts/html-static-repair-redo/pr/comment-sicko/reply.md`; proof: none deferred: tracked by the unknown-cast sample in docs/plans/topics/correct.md; owner: natamox; stop: that sample runs +2026-10-10T14:54:33Z writing side finding (comment-sicko, static kit iteration): the comment above the code drawing filter in plugins-static.spec.ts predates the change and was not judged Outside that diff, and the comment states a true reason. docs/plans/artifacts/html-static-kit-drift/comment-sicko/reply.md; proof: none deferred: owner: natamox; stop: the next comment pass on that spec +2026-10-10T14:54:33Z delivery Told the owner the remote branch still pointed at 6e086e37ae and that the run never pushed it; the remote already held ce7657bbbe, and this checkout's reflog shows pushes at 10:08, 14:49 and 20:51 +0800 that no command in this run made The ls-remote output in the same call printed ce7657bbbe98; the reply read the stale tracking ref instead. `docs/plans/artifacts/html-static-repair-redo/reflect/reviewer.md` Divergent 7; git reflog show origin/export-static-presentation; proof: none corrected: the owner is told; who pushed is unknown to the run +2026-10-10T14:54:33Z reflect reflect over transcript lines 14965 to the end, with one claude-opus-5-5 @high reviewer applying the judgment, tooling and divergent lenses, and the lead synthesizing AGENTS.md: a session where the owner corrected the workflow runs reflect before its final reply; it was skipped earlier for speed, which the Owner phrases rule does not allow. `docs/plans/artifacts/html-static-repair-redo/reflect/prompt.md`, `docs/plans/artifacts/html-static-repair-redo/reflect/digest.md`, `docs/plans/artifacts/html-static-repair-redo/reflect/reviewer.md`; proof: none recorded: no lesson qualified to apply without the owner; the memory that let speed drop gates is corrected; 13 backlog items and 4 rejections are listed in the plan Close diff --git a/docs/plans/2026-10-09-html-static-repair-redo.md b/docs/plans/2026-10-09-html-static-repair-redo.md new file mode 100644 index 0000000000..3e1e64f8ac --- /dev/null +++ b/docs/plans/2026-10-09-html-static-repair-redo.md @@ -0,0 +1,605 @@ +--- +review_scopes: [html, exports] +review_basis: [] +work_kind: implementation +review_commit: 666f02406e936864cc61e5ccb6824321df4d9a42 +review_inputs: [VISION.md, docs/vision/common.md, docs/vision/plate.md, packages/platejs/src/static/renderStaticHtml.tsx, packages/platejs/src/static/internal/renderStaticHtmlWithOverrides.tsx, packages/platejs/src/static/internal/staticComponentOverrides.ts, packages/platejs/src/static/internal/staticDocumentView.ts, packages/platejs/src/static/pluginRenderElementStatic.internal.tsx, packages/platejs/src/static/pluginRenderLeafStatic.internal.tsx, packages/platejs/src/static/pluginRenderTextStatic.internal.tsx, packages/platejs/src/internal/plugin/pluginStore.ts, packages/platejs/src/docx/export/lib/exportDocx.tsx, packages/platejs/src/docx/export/lib/docxStaticComponents.tsx, packages/platejs/src/docx/export/lib/internal/render-document-file.ts, apps/www/src/registry/components/editor/export-toolbar-button.tsx, apps/www/src/registry/components/editor/plugins.ts, apps/www/src/registry/components/editor/plugins-static.ts, apps/www/src/registry/components/editor/block-list-static.tsx, apps/www/src/registry/components/editor/ai.tsx, apps/www/src/registry/components/editor/dnd.tsx, tooling/entrypoints/entrypoint-dag.mjs] +--- + +# Export draws with the app's static kit + +Status: done: phases 1 to 3 are in pull request #5157 into next, waiting on your merge; folded into the html subject +Playbook: plan + +This iteration redoes `docs/plans/2026-10-09-html-static-repair.md` at claude-opus-5-5 @xhigh. That draft ran on gpt-6-astra @medium and claude-opus-5-5 @medium and is now superseded. The plan fixes the two export defects. Exporting a document with a table, an image, a task list or other interactive content crashes, because export draws each block with its editing component. Eighteen kinds of content crash the HTML render, and twelve crash the render step Word export runs. Style props passed to export can also swap out the exported document. The live editor keeps deciding what each block means and how it is configured, and export asks the app's static kit only how to draw each block, by plugin name. An `architect` arena with three runners from two model families converged on this shape, and a blind judge picked it over the draft's second export editor (`docs/plans/artifacts/html-static-repair-redo/architect/synthesis.md`). A three-model panel then reviewed the plan three times, and this revision settles every finding. + +## Brief + +### What will change? + +Export will draw each block with its read-only version. Tables, images, task lists and everything else that crashes today will export to HTML and Word. Word keeps nested list levels, and style settings can't swap in another document. + +### What could go wrong? + +Exported files change a little. Editing leftovers disappear, and lists indent once. A block with no read-only version comes out plain with a warning, or stops a strict Word export. Ziad's suggestion rework also changes Word export. + +## Teach + +Export turns a document into a file. Plate draws every block as HTML and saves it. Word export converts that same HTML into a Word file. + +Every block has two versions in the app. The editing version has toolbars and menus, and it only works inside a running editor. The read-only version is plain HTML, kept in a separate set for servers and previews. Export uses the editing version today, so blocks with toolbars or menus crash. Plain text, headings, links and dates have none, so simple documents export fine. + +The fix keeps the live editor in charge of what each block is and how it is set up. Export borrows only the read-only look, matched by block type. A block with no read-only version comes out plain, with a warning. + +## Reproduce today + +Both defects reproduce on `next` at 666f02406e, and the export code has not changed since the draft's baseline 795d0bc491. + +### Bug 1: interactive blocks crash export + +Start the app from source. From the repository root, run: + +```sh +cd apps/www +PLATE_WWW_DYNAMIC_DOCS=1 PLATE_WWW_DEV_SOURCE=1 \ +PLATE_WWW_DIST_DIR=.next-html-repro pnpm exec next dev --port 3311 +``` + +1. Open `http://localhost:3311/blocks/docx-demo`. +2. Open **Export**, then choose **Export as HTML**. The starting document downloads. This is the control. +3. Click inside the editor. Open **Table**, then **Table**, then **Insert 2 by 2 table**. +4. Open **Export** and choose **Export as HTML**, then **Export as Word**. +5. Read the browser console. + +| | Result | +| --- | --- | +| Actual | Both exports throw `useEditor() requires an active Plate editor`, and the stack names `TableElement` inside the static renderer. Nothing downloads. | +| Expected | Both downloads contain the table, drawn without editing controls. | + +An image, a task list, a callout, columns, a code block, a mention or a footnote fails the same way; the full list is in `element-probe/probe-a4.log`. + +### Bug 2: style props swap the exported document + +Save this file as `packages/platejs/repro-props.ts`: + +```ts +import { createEditor } from './src/core'; +import { BaseParagraphPlugin } from './src/lib'; +import { renderStaticHtml } from './src/static/renderStaticHtml'; + +const make = (text: string) => + createEditor({ + plugins: [BaseParagraphPlugin], + initialValue: [{ type: 'paragraph', children: [{ text }] }], + }); +const source = make('SOURCE'); +const foreign = make('FOREIGN'); + +for (const [name, props] of Object.entries({ + control: {}, + editorProp: { editor: foreign }, + documentProp: { document: foreign.read.value() }, +})) { + const { data } = await renderStaticHtml(source, { props }); + console.log(name, data.includes('SOURCE'), data.includes('FOREIGN')); +} +``` + +Run it from the repository root, then delete the file: + +```sh +pnpm --dir packages/platejs exec bun \ + --preload ../../config/plite-source-aliases.ts ./repro-props.ts +``` + +| Case | Actual | Expected after the fix | +| --- | --- | --- | +| No props | `control true false` | `control true false` | +| `props.editor` | `editorProp false true` | TypeScript rejects the call; at runtime it throws a TypeError naming `props.editor` | +| `props.document` | `documentProp false true` | TypeScript rejects the call; at runtime it throws a TypeError naming `props.document` | + +The export's warnings still describe SOURCE while the file holds FOREIGN (`docs/plans/artifacts/html-static-repair-2026-10-09/capture-base-a1.log`). + +## Target + +Exporting a live editor reads one snapshot taken when the export starts: the document, the projection, and every plugin setting the drawing reads. It draws that snapshot with the app's static kit, picked per plugin, and never runs an editing component. A server render or an AI preview has no live editor, so it builds an editor from the same policy the live editor uses, plus the static kit. The Word converter owns what Word needs, such as list levels, and the app's copied stylesheet owns the look. The `presentation` option stays in the end state, because the live editor's components remain editing components. Phase 1 builds the drawing part for the export toolbar. Taking every setting with the document needs its own plan, because the panel showed that the code highlighter keeps shared state no simple snapshot can freeze. Phases 2 and 3 finish editor-less renders and Word. + +## Fit with the authored redesign + +Ziad's `docs/plans/2026-10-08-authored-review.md` moves suggestions into the document itself. Its Phase 1 is built and kept, and its Phase 2 switches the code over. After that cutover, `editor.read.value()` holds the review tree, with struck and inserted text in place and an `authored` mark on each suggested leaf or element; `meta.authored` is gone. `projectAuthoredDocument(document, { projection })` stays as the one pure read for accepted and proposed content. `projectAuthoredRange`, `projectAuthoredReview`, `isAuthoredEditor` and the `projection` view input are deleted. Those claims come from that plan's Document shape, Main changes and Hard cuts; this checkout does not hold its Phase 1 code, so nothing here ran against it. + +The two plans meet in three places, all inside Word export and the export toolbar: + +| Today | Why it changes | Who changes it | +| --- | --- | --- | +| Word's review output renders a merged review document, then `applyReviewProjection` finds each rendered block by its path and wraps it in `` or `` | Its input, `projectAuthoredReview`, is deleted; the review tree already carries the marks | The authored plan's Phase 2 step "Adopt serializer projections", which this plan asks to draw `authored` marks as Word revisions through Word's private map during the same render | +| `exportDocx` maps comment ranges into the accepted output with `projectAuthoredRange` | Deleted; ranges become review-tree positions | The same step, which this plan asks to map ranges inside `exportDocx` from the resolver | +| The toolbar collects comment ranges from a projected view built with `isAuthoredEditor` and `createEditorView(model, { authored })` | Both are deleted | The same step; the toolbar then passes review-tree ranges as they are | + +What this plan's phase 1 changes does not depend on how suggestions are stored: which components draw, the props rejection, Word list levels and the static list indent. So phase 1 can land before the cutover, and the cutover keeps the `presentation` option, the missing-drawing diagnostic and the diagnostic collection in `exportDocx`. If the cutover lands first, phase 1 builds on the review tree instead, and the same steps apply. + +## What other editors do + +Every editor read here keeps export drawing apart from the interactive view, and none exports through the interactive view. ProseMirror and BlockNote were read at the commits below during this run. Tiptap and Lexical come from the repository's research pages, which read them at the named commits. Slate was not read this run. + +| Editor | Export drawing | Interactive drawing | Where both live | What export uses | +| --- | --- | --- | --- | --- | +| ProseMirror (`prosemirror-view@ca4c78e`) | Schema `toDOM`, run by `DOMSerializer` | Node views passed to the view | `toDOM` on the schema, node views on the view | Clipboard serializes with `DOMSerializer.fromSchema` and never calls a node view (`src/clipboard.ts:17`, `:60`) | +| Tiptap (`ueberdosis/tiptap@91c51be53c`) | `renderHTML`, which becomes `toDOM` | `addNodeView`, React node views | Both on one extension | `getHTML`, `generateHTML` and `@tiptap/static-renderer` use `renderHTML` or explicit node and mark mappings (`docs/research/sources/tiptap/conversion-and-export.md`) | +| Lexical (`facebook/lexical@dd5c41b1`) | `exportDOM`, defaulting to `createDOM` | `decorate()` React decorators | Both on one node class | `$generateHtmlFromNodes` uses `exportDOM` (`docs/research/sources/lexical/conversion-and-export.md`) | +| BlockNote (`BlockNote@be20d8b`) | `toExternalHTML`, falling back to `render` | `render` | Both on one block spec (`packages/core/src/schema/blocks/types.ts:215-240`) | External HTML uses `toExternalHTML` (`packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts:100`); the Word exporter takes the schema plus per-block mappings (`packages/xl-docx-exporter/src/docx/docxExporter.ts:55-87`) | +| Slate | None; apps write their own serializer | `renderElement` in `slate-react` | App code | Not read this run | +| Plate today | `foo-static.tsx` components in `BaseEditorKit` | `foo.tsx` components in `EditorKit` | Separate install items and kits, by law | The live editor's editing components, which is the bug | + +Plate's law keeps the two drawings in separate install items, while Tiptap, Lexical and BlockNote put both on one node definition. The target keeps Plate's separation and borrows what all four share. Export picks the export drawing for a node by its type, and the node's meaning stays in one place. + +## Public API + +The export toolbar's HTML call passes the app's static kit as the drawing source. + +```tsx before +// apps/www/src/registry/components/editor/export-toolbar-button.tsx +const result = await renderStaticHtml(model, { + component: EditorStatic, + projection, + props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }, +}); +``` + +```tsx after +// apps/www/src/registry/components/editor/export-toolbar-button.tsx +const result = await renderStaticHtml(model, { + component: EditorStatic, + presentation: BaseEditorKit, + projection, + props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } }, +}); +``` + +The Word call passes the same kit. + +```tsx before +// apps/www/src/registry/components/editor/export-toolbar-button.tsx +const result = await exportDocx(model, { + comments: docxComments, + component: EditorStatic, + lossPolicy: 'allow', + projection: wordProjection, + source: docxSource?.source, + stylesheet: DOCX_EXPORT_STYLES, +}); +``` + +```tsx after +// apps/www/src/registry/components/editor/export-toolbar-button.tsx +const result = await exportDocx(model, { + comments: docxComments, + component: EditorStatic, + lossPolicy: 'allow', + presentation: BaseEditorKit, + projection: wordProjection, + source: docxSource?.source, + stylesheet: DOCX_EXPORT_STYLES, +}); +``` + +The option types change as below. The server HTML block and the AI preview call nothing new, because a static editor draws with its own plugins. + +| Option | Today | Target | +| --- | --- | --- | +| `renderStaticHtml` `presentation` | none | optional static plugin array; for each plugin key, its element component, mark component (leaf or text placement) and wrapper slots draw the export, and nothing else of it is read | +| `exportDocx` `presentation` | none | the same option, used for the body and every comment body | +| `renderStaticHtml` `props` | `Partial`, spread over the captured editor | appearance props only; `editor` and `document` are typed `never` and throw a TypeError at runtime | +| Diagnostics of both functions | authored projection and Word losses | also `missing-static-presentation`, naming each plugin whose block, mark or function wrapper had no static drawing; Word counts every one as lost content, so `lossPolicy: 'reject'`, the default, refuses the file and `'allow'` keeps it with the warning | + +## Layer and owner + +| Delta | Job | Owner | +| --- | --- | --- | +| changed | Read the document, project suggestions, render, return HTML and diagnostics | `renderStaticHtml` in `platejs/static`, with one presentation binding per render that every view the render creates inherits | +| changed | Pick the component that draws a node | Static dispatch in `platejs/static`. With `presentation`, the static plugin of the same key supplies the element component, both mark placements and the wrapper slots written as functions; Word's private map wins over it for its eight types. A plugin that draws but has no static peer draws the structural default and adds a `missing-static-presentation` warning. Edit-only parts stay out, and wrapper slots written as objects with a `match` stay undrawn, as today. Without `presentation`, the editor's own components draw. | +| changed | Word body, comments, review markup and exact source reuse | `exportDocx` in `platejs/docx/export`; its HTML converter takes a list's level from the list's host block indent when the list has none | +| changed | Static presentation and its kit | Copied registry `foo-static.tsx` items, composed in `plugins-static.ts` as `BaseEditorKit`, which export also receives as `presentation` | + +Every owner is in an existing entrypoint. `static` depends on `authored`, `core` and `dom`, and `docx/export` already depends on `static` (`tooling/entrypoints/entrypoint-dag.mjs`). No new edge. + +## Hard cuts and app migration + +| What breaks | Callers | Migration | +| --- | --- | --- | +| `renderStaticHtml` `props` stops accepting `editor` and `document` | None in the repository; the toolbar, the server HTML block and Word export pass only `style` | Pass another document through the top-level `document` option | +| A live editor exported without `presentation` | The registry toolbar, changed in the same step | Pass the app's static kit; without it, rendering keeps today's behavior | + +## Main changes + +- One presentation binding per render. Every view the render creates reads it, including named roots and Word comment bodies, and the private element-only override map folds into it. Plugin settings are read when the drawing runs, as today, so a setting changed while an export waits for the renderer can still reach that export (`capture-law-probe/probe-a1.log`); a separate plan owns that gap. +- Static dispatch draws elements, both mark placements and the wrapper slots written as functions (`wrapNode`, `wrapNodeChildren`, `afterNodeChildren`) from the presentation. It already honors `editOnly`, and it keeps dropping slots written as an object with a `match`, as it does today (`pluginRenderElementStatic.internal.tsx:47-120`). In the registry those are drag handles, AI chat and the discussion wrapper, all editing UI. +- Word's HTML converter passes each host block down its recursive conversion as an argument, through the parent fallback `getIndentLevel` already has (`render-document-file.ts:636-656`), so a list with no margin takes its nearest host block's indent. The host indent adds no module state; the converter's existing list-numbering state is still module-wide, which Open work tracks. The static list wrapper drops its own per-level margin. HTML then indents lists once, and Word keeps nested levels, which today's toolbar export flattens to level 0 (`list-probe/probe-a3.log`). The measured prototype used a module variable instead (`list-probe/converter-prototype.patch`); it proves the result, not the shape. + +## Redo + +The draft is `docs/plans/2026-10-09-html-static-repair.md`. Its lead ran on gpt-6-astra @medium in Codex, its later revisions on claude-opus-5-5 @medium in Claude Code, and its seats recorded no effort. This run's lead is claude-opus-5-5 @xhigh, so every judgment in the draft is redone and deterministic proof on unchanged export source is reused. `git diff --stat 795d0bc491 666f02406e` touches no export source, only the emoji picker. + +| Unit | Producer | Level | Verdict | What happened | +| --- | --- | --- | --- | --- | +| Plan, scope and Defaults | Draft lead and review session | gpt-6-astra @medium; claude-opus-5-5 @medium | redo | Rewritten from the owner's typed asks and the measurements below. | +| Bug 1 scope probe (`review-all-elements-a3.log`) | Review session | claude-opus-5-5 @medium | redo | The draft logged it partial, with blockquote inconclusive and media and task lists unmeasured. Rerun over 170 real blocks, blockquote works, while media and the task-list marker fail. | +| Browser repro of the table crash (`browser-base-errors.json`) | Draft lead | gpt-6-astra @medium | reuse | Deterministic, on export source unchanged since 795d0bc491. | +| Bug 2 API probe (`capture-base-a1.log`, `documented-reproduction.log`) | Draft lead | gpt-6-astra @medium | reuse | Deterministic, same source. | +| Static base controls (`static-base-a2.log`, 31 tests) | Draft lead | gpt-6-astra @medium | recheck | Reused for the plan; the build reruns them, because it changes their inputs. | +| Architect arena (opus and astra answered, sol timed out) and its cross-judge | Draft lead's seats | no effort recorded; sol missing | redo | Rerun with all three runners at the models sheet's levels and the draft's design as a fourth candidate. | +| Mode, cost and type prototypes (`mode-prototype-*`, `cost-*`, `table-mode-*`) | Draft lead | gpt-6-astra @medium | reuse | Evidence for the withdrawn per-component mode only. The new target reuses the frozen cost contract `performance-contract.json` and cites nothing else. | +| Plan panel on the mode design (three seats, no critical) | Draft lead's seats | no effort recorded | redo | It reviewed a withdrawn design. This plan gets its own panel. | +| Owner's withdrawal of the 27-file implementation | Owner | n/a | reuse | The ruling stands; the saved patch is not restored. | +| Bug 2 regression run (`capture-red-a1.log`) | Draft lead | gpt-6-astra @medium | reuse | Deterministic evidence that both props cases fail at base; its test lived in the withdrawn patch, and phase 1 writes test e afresh. | +| Table width and named-root probes (`table-owner-a2.log`, `named-root-base-a1.log`) | Draft lead | gpt-6-astra @medium | reuse | Deterministic, on unchanged source. The named-root probe backs the presentation binding that derived views inherit; the table probe shows the static cell reads the configured width. | +| Repair direction and Questions for Ziad | Review session | claude-opus-5-5 @medium | redo | Redone through the arena; the questions evidence settles are answered in Settled decisions. | +| Writing passes | Draft lead and review session | as above | redo | The prose is new. | + +## What changed from the draft + +| Draft pick | Now | Why | +| --- | --- | --- | +| Capture on the live editor, render through a second editor built from the static kit | Keep the live editor for meaning and settings; borrow only drawing from the static kit | A second editor makes the static kit's options the export's meaning. That is the second plugin list the owner ruled out, and the 2026-09-24 plan deleted that export editor on purpose (`docs/plans/2026-09-24-document-first-export-contracts.md:367-368`). | +| A new public capture entry shared by HTML, Word and Markdown | Dropped; the capture stays inside each export function | The live editor already projects suggestions, reports diagnostics and builds Word review correctly (`capture-probe/probe-a5.log`). | +| Three kit layers in phase 1 | Phase 2, for the editor-less server block and AI preview | Live export reads no static option, so drift cannot reach it. | +| Eleven failing element types, media unmeasured | 17 element types plus the task-list marker in HTML; 11 plus the marker in the render step Word runs | Remeasured over 170 real blocks (`element-probe/probe-a4.log`). | +| Bug 2: reorder the spread and clear `document` | Reject `props.editor` and `props.document` at runtime and type them `never` | A silent reorder hides the caller's mistake. | +| Style option fix can land alone first | Both fixes land in phase 1 | Each is small, and the owner asked to fix both. | + +## Panel round 1 + +Three seats reviewed the plan frozen at `900e00d20a`: claude-opus-5-5 @high, gpt-6-astra @xhigh and gpt-6.1-sol @xhigh. The challenge delta is improved. The lead applied all five critical findings and dismissed none. + +| Finding | Seats | Change | +| --- | --- | --- | +| The plan had no repro steps and did not answer whether the fix contradicts Ziad's refactor | opus critical; astra, sol warning | Reproduce today now holds both repros, and the bug 2 script ran with the documented output (`repro/repro-props-a1.log`). Settled decisions answers the question directly. | +| Text-placement marks have their own dispatch path, which the plan left out | astra, sol critical; opus warning | Presentation now covers both mark placements. The census, rerun by compiled binding, shows the AI mark is a text-placement mark (`presentation-probe/coverage-v2-a3.log`). | +| A wrapper whose plugin the static kit omits was skipped silently, which would drop list numbering if the whole list peer went missing | astra, sol critical; opus warning | A missing static peer never authorizes a skip. Export draws the structural default and warns by name. Listing a static peer is the explicit choice of what to draw, and edit-only parts and slot `match` rules apply because the render is read-only. | +| Removing the static list margin would flatten Word list levels, including a numbered code block | astra, sol critical; opus warning | The lead measured it before picking. Today's toolbar already flattens nested lists in Word, and the static margin fixes Word but doubles the HTML indent. A converter that takes a list's level from its host block fixes both, numbered code block included (`list-probe/probe-a3.log`, `list-probe/converter-prototype-a2.log`). | +| Settings read after the first await break the one-capture law | astra, sol critical; opus nit | It reproduces at HEAD. A table width changed right after export starts reaches the file, 450 px instead of 300 px (`capture-law-probe/probe-a1.log`). Phase 1 now takes plugin state into the export frame. | +| The Word census rows never build a Word file | astra warning | Relabeled as the render step Word runs; phase 1 adds real Word files for rich content. | +| Phase 1 had four missing-peer rules and a redundant runtime scan | opus warning | One rule, a structural default with a warning; the runtime scan is gone. Leaf and text swaps stay, because live leaves leak editing markup (`element-probe/output-diff-summary.txt`). | +| "No second list" hid the cost that the static kit's membership now matters | opus warning | Stated in Settled decisions, with why this is not the rejected component map. | +| No end state; phase 3 mixed two jobs; audit-2 not named | opus warning | Target section added; the policy kit and Word lowering are separate phases; audit-2 is named. | +| The cost gate missed bundle size and had a cohort with no passing baseline | opus warning | Bundle size is a gate line; the crashing corpus stays a correctness check only. | +| `children` and `dangerouslySetInnerHTML` cannot replace the document | opus nit | Only `editor` and `document` are reserved. | +| The binding mechanism was unnamed | opus nit | The export frame is that mechanism. | + +## Panel round 2 + +The same three seats reviewed only the revision, frozen at `6fa05e28b8`. The challenge delta is improved again. The lead applied three critical findings, applied the rest of a fourth and dismissed its crash claim. + +| Finding | Seats | Change | +| --- | --- | --- | +| The converter prototype keeps the host indent in a module variable across awaits, so concurrent exports can swap list levels | astra, sol critical; opus warning | The build passes the host block down as an argument through `getIndentLevel`'s existing parent fallback; test i runs two exports at once. This replaces the prototype's approach. | +| Keeping frozen state references misses stores that compiled callbacks closed over, such as code highlighting, and non-plain values inside state | astra, sol critical; opus warning | The frame answers every plugin store read and configuration read while the synchronous render runs, so closures read it too; test h covers code highlighting. The claim narrows. A non-plain value inside state stays shared, listed under Open work. | +| Word drops render diagnostics and its loss check does not know the new code, so `lossPolicy: 'reject'` would not refuse a missing image | sol critical; astra warning | `exportDocx` collects the diagnostic from the body and every comment body, and a missing block drawing counts as lost content; test j covers both policies. | +| Static AI peers in the shared kit would crash the AI preview with a duplicate `ai` plugin, and AI chat has no base plugin | opus critical | The crash did not reproduce. A static `ai` peer and the preview's `ai` plugin share a lineage and compose, while an unrelated `ai` plugin throws the duplicate error the seat described (`ai-peer-probe/probe-a2.log`). The rest holds, so the plan adds no AI peers. | +| The corpus guard misses drawings no demo block uses | astra warning; opus nit | The spec also compares compiled drawing bindings, with a named list of three allowed absences, and its mutation removes the unused upload peer. | +| The answer about Ziad's refactor left out that `editorPlugins` was removed | opus warning | The talk section now says this plan adds back a plugin-array option of that kind and why it differs. | +| The frame's owners and its per-read cost were understated | opus warning | The plugin store read and the view configuration read are named owners, and the cost probe adds a typing burst. | +| Static dispatch drops object-form slots today, and `afterNodeChildren` was not in the contract | opus nit | Phase 1 evaluates `match` for object-form slots, and all three slots are in the contract. | + +## Panel round 3 + +The same three seats reviewed only the round 2 revision, frozen at `e28da64627`. This was the last round the project allows before a build, so the lead settled every finding without another round. The challenge delta is improved, with one reversal. + +| Finding | Seats | Settlement | +| --- | --- | --- | +| Answering store reads from captured state still misses shared mutable objects, such as the code highlighter's engine behind a plain object | astra, sol critical | Reversed. The frame had already changed approach in round 2, so it leaves phase 1 instead of changing again. Settings are read when the drawing runs, as today, and a separate plan owns capture. The claim narrows, and Open work names the gap. | +| A missing list wrapper drops numbering, yet the round 2 rule counted it only as a warning | astra, sol critical | Applied. Word now counts every missing drawing as lost content, replacing round 2's split by kind; test j adds a missing list peer. | +| Evaluating `match` on object-form slots pulls the discussion wrapper into every export | opus critical | Reverted round 2's change. Static dispatch keeps dropping object-form slots, as today, and Open work names the narrowed claim. | +| The loss rule flips the result for `exportDocx` callers that rely on the strict default | opus warning | Applied. The Brief, Defaults and Public API say Word's default strict mode refuses a file with a missing drawing. | +| The converter's existing list-numbering state is module-wide, so test i can pass while numbering IDs cross exports | opus, sol warning | Deferred. It predates this plan; test i checks levels only, and Open work tracks the numbering race. | +| The typing gate had no frozen number, and a second store read path exists | sol warning; opus nits | Moot. Both belonged to the store-read change, which left phase 1. | + +## Defaults + +| Decision | Pick | Alternative | Word | Impact | +| --- | --- | --- | --- | --- | +| Where export gets its look | The app's read-only versions, matched to the live editor by block type (look) | A second editor built from the read-only set, as the draft proposed | second export editor | big | +| How much phase 1 fixes | Every block that crashes, images and task lists included | Tables only | tables only | big | +| Settings changed while an export runs | Read at drawing time, as today; a later plan locks them in when export starts (look) | Lock them in during phase 1 | freeze now | small | +| Anything with no read-only version | Drawn plain with a warning; Word's strict mode, its default, refuses the file (look) | Export stops with an error everywhere | strict missing | small | +| A wrapper, such as the list's, with no read-only version | Warned about on any export, even one with no list, because export never runs the editing wrapper to ask whether it would wrap (look) | Run the editing wrapper's outer function to ask, which can crash when it uses hooks | ask wrapper | small | +| Nested lists | Word takes list depth from each line's indent, and the read-only list stops adding a second indent | Keep the doubled indent in exported HTML | keep list indent | small | +| Style settings that carry a document or an editor | Rejected with an error | Quietly ignored | ignore content props | small | +| Word's own versions of eight block types | Stay internal and win over the app's for now | Move their styling into the app now | move Word styling | small | +| AI highlights and the AI chat box | No read-only versions. AI-highlighted text exports as plain text with a warning, and the chat box never appears in the file | Add read-only versions to the shared read-only set | add AI peers | detail | +| Loading the read-only set in the export button | Bundled with the button, unless the size check finds it adds too much | Loaded when the export menu opens | lazy kit | detail | +| Order with Ziad's suggestion rework | This fix can land first, because it does not touch how suggestions are stored (look) | Wait and build it on the new suggestion format | after authored | small | +| Word's tracked changes after Ziad's rework | Drawn from the suggestion tags while the file renders | Wrapped into the finished file afterwards, as today | post-process review | small | +| Proof of the browser step | The saved HTML and Word XML of each download plus the runtime-error recorder, with no screenshot; tracked changes come from the existing suggestion case (look) | Keep a screenshot of each opened download | browser screenshots | small | +| Proof of the capture and list reruns | Test d for review revisions, test i for Word list levels and the 169-block rerun (look) | Port the capture and list probes to the built code | port probes | small | +| Page for this work | A new page, because another account owns the draft's page; the new link is https://claude.ai/artifact/JpnCNp8LPBuPcXoHzLT7bz and the old one https://claude.ai/artifact/U79w5hU4y7jaEtBVpY7fRh | Keep the draft's page as this topic's page | old page | detail | + +## Decision ledger + +| Surface | Current | Target | Owner | Reason | Adoption | Proof | Risk | Verdict | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Export drawing | The editor's installed components, editing ones on a live editor | A `presentation` option of static plugins, read by key for elements, both mark placements and wrapper slots | `platejs/static`, `platejs/docx/export` | Editing components need a running editor; 18 kinds of content crash | Registry toolbar, `export.mdx`, `static.mdx`, DOCX docs | `presentation-probe/probe-a3.log` premise; phase 1 tests and browser check | A third-party live plugin with no static peer exports plain with a warning until it ships one | rearchitect | +| Missing static drawing | A crash inside a hook | Structural default plus a `missing-static-presentation` diagnostic; Word counts every one as lost content | `platejs/static`; `exportDocx` collects the diagnostic from the body and every comment body | The export loss law makes every loss a structured diagnostic, and Word's `lossPolicy: 'reject'` refuses lost content (`exportDocx.tsx:376`, `:548`); a missing list wrapper drops numbering, so placement does not tell style from content | Registry guard compares compiled drawing bindings and renders the demo corpus | Phase 1 tests g and j | A strict Word export of a document with an AI highlight fails until the app ships a static AI mark | rearchitect | +| Settings read during export | Document captured before the first await; settings read when the drawing runs | Unchanged in phase 1; a separate plan captures render dependencies at their owners | `platejs/static`, plugin owners such as code highlighting | Capture law (`docs/vision/plate.md:1159-1162`); the HEAD case reaches the file (`capture-law-probe/probe-a1.log`), and the highlighter keeps shared mutable state (panel round 3) | None in phase 1 | The separate plan's own tests | A setting or highlighter change during an export reaches it, as today | defer | +| Appearance props | `Partial` spread after the captured editor | Appearance only; `editor` and `document` typed `never` and rejected | `platejs/static` | Bug 2 | No repository caller passes them | Phase 1 test e | None found | rearchitect | +| Word list depth | The converter reads only the list's own margin | It passes the host block down as an argument and falls back to its margin; the static list drops its margin | `platejs/docx/export`; registry `block-list-static.tsx` | Today's Word export flattens nested lists; the static margin doubles HTML indent | Server block, AI preview and export all change | `list-probe/converter-prototype-a2.log`; phase 1 test i | A host block that carries margin for another reason | rearchitect | +| Presentation compile and bundle | None | One compile per kit identity per process, cached; static kit in the toolbar bundle | `platejs/static`; registry toolbar | Plugin lookup needs a compiled kit | Hidden behind `presentation` | `perf-gate/evaluate-a1.log` on the prototype; the final-path rerun closes it | The first export pays 68 ms more (median of 3 fresh processes each side); the static kit is already in the editor bundle through the AI menu, so the toolbar adds 0.01 KB gzip | rearchitect | +| Word element overrides | Private map of eight elements over the editor's components | Private map over the static kit now; Word lowering plus copied stylesheet in phase 3 | `platejs/docx/export` | Plate law gives format-required defaults to the format owner and forbids a hidden theme (`docs/vision/plate.md:1155-1160`) | None in phase 1 | Phase 3 per-entry Word check | Columns, equations and TOC lose Word shapes | defer | +| Semantic options of editor-less renders | Server block and AI preview read the static kit's own options | One shared policy kit per feature | Registry kits | Table width, list and indent targets drift (`drift-probe/probe-a1.log`) | Server block and AI preview | Phase 2 drift census rerun | `.configure` and `toReactPlugin` composition in one array is unproven | defer | + +## Build + +Execution authority: the owner's "go" on 2026-10-09 after the plan was settled. The lead writes the code on `next` from base `666f02406e936864cc61e5ccb6824321df4d9a42`; subagents only research and review. The owner first ruled out commits and pushes. On 2026-10-10 the owner asked to cut a branch and commit. Push still waits for the owner. The packet is phase 1 below. + +| Gate | Applies | Evidence | +| --- | --- | --- | +| Blast radius before code | yes | `docs/plans/artifacts/html-static-repair-redo/blast-radius.md` | +| Pre-acceptance probe | yes | `perf-gate/evaluate-a1.log`: every frozen line passes and each known-bad arm fails | +| Red tests fail at HEAD for their defect | yes | `docs/plans/artifacts/html-static-repair-redo/red/`: tests a, b, c, d (attempt 2), e (runtime and types), f (named root and comment body), g, i (attempt 2) and j each fail alone at `666f02406e` for their named defect | +| Writing passes on product code (`deslop`, `no-comments`) | yes | `docs/plans/artifacts/html-static-repair-redo/comment-sicko/answer.md` and rounds r2 to r4; writing rows in the log | +| Lint fix and type-aware oxlint on task files | yes | `docs/plans/artifacts/html-static-repair-redo/build/lint-check-a7.log`, `docs/plans/artifacts/html-static-repair-redo/build/lint-typeaware-a7.log`: 25 of 25 files | +| `api-build` panel on the diff | yes | three rounds (cap); round 3 left two critical findings open, listed in Open work | +| Benchmark rerun on the final path | yes | `docs/plans/artifacts/html-static-repair-redo/perf-gate/final/evaluate-a2.log`: partial, every line passes except the 100-paragraph line, inconclusive in both runs | +| Browser proof through `verify` | yes | `docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a4.log` (4 of 4) and a red run at 666f02406e | +| `plate-docs` on the named pages | yes | six pages plus the authored-changes and serializing guides; `check:docs`'s first link needs built package declarations | +| Registry output regenerated (`pnpm --filter www build:registry`) | yes | `docs/plans/artifacts/html-static-repair-redo/build/build-registry-check-a3.log` | +| Barrels (`pnpm brl`) | yes | `docs/plans/artifacts/html-static-repair-redo/build/brl-a1.log`: no change | +| `pnpm check` steps the edits reach | yes | test, type-tests, typecheck and lint steps run on the task's partitions and files; the full `pnpm check` ran on the committed branch, where 22 of 25 steps pass and test-slow, review-ledger and knowledge fail the same way at the base commit | +| Changeset and registry changelog | yes | `.changeset/platejs-static-presentation.md`; registry changelog entry `2026-10-09-export-static-presentation` | +| `best-api repair` | yes | `docs/vision/plate.md` presentation law; two guides updated | +| Fold into the subject and `review-ledger.mjs check` | yes | not yet: phases 2 and 3 and the two open findings keep the plan open, so the subject keeps showing the state before it | + +## Steps + +Phase 1 fixes both defects and is the next build. Phases 2 and 3 answer the owner's question about the whole direction; each gets its own plan iteration and ends with a keep, revert or quarantine call. + +### Phase 1: draw export with the static kit + +Against today, phase 1 makes every block export in both formats and keeps nested list levels in Word. It leaves settings read at drawing time, as today; the separate capture plan under Open work owns that. The list fix belongs here. Drawing exports with the static kit would otherwise indent HTML lists twice, a regression this phase would cause. Removing that extra indent without the converter change would flatten Word lists. + +- [x] Run the pre-acceptance probe for the presentation compile and the toolbar bundle before any production edit. Frozen lines, each run on its baseline first: warm export of 100, 1,000, 5,000 and 10,000 paragraphs stays inside `docs/plans/artifacts/html-static-repair-2026-10-09/performance-contract.json` (median at most 1.5 times the baseline plus its allowance, one render per paragraph, equal bytes); presentation compiles per process equal 1 per kit identity and 0 on later exports; the first export's added time stays under 100 ms against the baseline of one `createEditor` with `BaseEditorKit`; the toolbar's client chunk grows by less than 20 KB gzip, or the kit loads when the export menu opens. A planted slowdown must fail. The 170-block demo corpus is a correctness check only, because today's export crashes on it. Proof: `proof.mjs` logs in this plan's run directory, then `pstack:benchmark-checklist` before any verified row; a loaded host leaves the row gate. (`docs/plans/artifacts/html-static-repair-redo/perf-gate/evaluate-a1.log`) +- [x] Write the red tests at public boundaries in `packages/platejs`, each run alone at `HEAD` through `node tooling/scripts/proof-worktree.mjs --expect-fail` with its own failure line. (a) An element whose component throws like an editing one draws its static peer with `presentation`. (b) A function wrapper slot does the same. (c) A text-placement mark does the same. (d) `exportDocx` with `projection: 'review'` and `presentation` returns `ok: true` with one insertion and one deletion. (e) `props.document` and `props.editor` throw a TypeError naming the key, with `@ts-expect-error` cases for a literal and a variable. (f) A named root and a Word comment body draw with the same presentation. (g) A plugin with no static peer never calls its editing component, draws the structural default and returns one `missing-static-presentation` diagnostic naming it. (i) Word keeps levels 0, 1 and 2 for a three-level numbered list and level 1 for a numbered code block at indent 2, also when two exports with different hosts run at once; the test checks levels only. (j) An image whose static peer is missing, a numbered list whose list peer is missing, and AI-highlighted text, each in the body and in a comment body, give a named warning under `lossPolicy: 'allow'` and `ok: false` under `'reject'`. Proof: one expect-fail log per test. (`docs/plans/artifacts/html-static-repair-redo/red/`) +- [x] Implement the presentation binding and dispatch for elements, both mark placements and function wrapper slots, the diagnostic and the props rejection in `platejs/static`. In `exportDocx`, pass `presentation` through, collect render diagnostics from the body and every comment body, and count each missing drawing as lost content. In the Word converter, pass the host block down as an argument. Proof: tests a to j pass; `pnpm --filter platejs typecheck`; the 31 static base controls of `static-base-a2.log` rerun green. (`docs/plans/artifacts/html-static-repair-redo/build/static-partition-a7.log`, `docs/plans/artifacts/html-static-repair-redo/build/docx-export-partition-a8.log`, `docs/plans/artifacts/html-static-repair-redo/build/platejs-typecheck-a5.log`) +- [x] In the registry, pass `presentation: BaseEditorKit` from the toolbar and remove the per-level margin in `block-list-static.tsx`. In `plugins-static.spec.ts`, compare the compiled drawing bindings that static dispatch draws (components and function slots) of `EditorKit` with `BaseEditorKit`, failing on any live drawing with no static peer except the AI text mark, which is named with its reason, and export the demo corpus with `presentation`, failing on any `missing-static-presentation` diagnostic. Proof: the spec fails with the list peer removed from `BaseEditorKit`, and fails with the upload peer removed, which no corpus block uses. (`docs/plans/artifacts/html-static-repair-redo/build/plugins-static-spec-a1.log`, `docs/plans/artifacts/html-static-repair-redo/mutate-run-a1.txt`) +- [x] Rerun the element, capture and list probes on the built code, and export rich fixtures through the real `exportDocx`: a table, a three-level list, a task list, an image and a code block, reading the package's text runs, `w:ilvl` values and diagnostics. Proof: logs at the next attempt index. (`docs/plans/artifacts/html-static-repair-redo/final-element-probe/probe-a1.log`; tests d and i stand in for the capture and list probes, see Close) +- [x] Prove it in the browser through `verify` on `/blocks/docx-demo`: insert a table, task list, callout, column group, code block and image; export HTML, Word and Word with tracked changes; open each download; then type in the table and toggle the task to show editing is unchanged. Proof: screenshots, the saved files and a console log with no new errors. (`docs/plans/artifacts/html-static-repair-redo/browser/browser-case-a4.log`, `docs/plans/artifacts/html-static-repair-redo/browser/browser-case-at-head-errors-a1.log`) +- [x] Run `plate-docs` on `content/docs/examples/export.mdx`, `content/docs/(guides)/static.mdx`, `content/docs/(plugins)/(serializing)/docx.mdx` and their Chinese twins. Proof: the docs checks and a preview of each page. (`docs/plans/artifacts/html-static-repair-redo/build/build-source-a3.log`, `docs/plans/artifacts/html-static-repair-redo/build/docs-source-parity-a3.log`, `docs/plans/artifacts/html-static-repair-redo/build/build-registry-check-a3.log`) +- [x] Write the `platejs` changeset and the registry changelog entry through `changeset`, and run `best-api repair`. Proof: the changeset file and the repair's report. (`.changeset/platejs-static-presentation.md`, `apps/www/src/registry/changelog/entries/2026-10-09-export-static-presentation.mdx`, `docs/vision/plate.md`) +- [x] Run the `api-build` panel on the diff. Proof: its decision-log rows. (`docs/plans/artifacts/html-static-repair-redo/panel-build-r1/`, `docs/plans/artifacts/html-static-repair-redo/panel-build-r2/`, `docs/plans/artifacts/html-static-repair-redo/panel-build-r3/`) + +### Phase 2: one policy for editor-less renders + +Against phase 1, phase 2 stops the server HTML block and the AI preview from drifting from the live editor's settings, such as table width and list targets. + +- [x] Move the semantic options both kits share into runtime-neutral policy kits, after a spike proves a base `.configure` and a `toReactPlugin` descriptor compose in one array. Proof: the drift census shows no render-relevant drift. (narrowed in `docs/plans/2026-10-10-html-static-kit-drift.md`, which fixes the two drifted options that change a static render, the image targets of indent and list, without shared policy kits; census `docs/plans/artifacts/html-static-kit-drift/probe/drift-a6.log`) + +### Phase 3: Word owns Word + +Against phase 2, phase 3 makes Word output follow the app's look and removes the package's hidden theme. `docs/plans/2026-10-10-html-word-owns-word.md` plans and builds it as one iteration. + +- [x] Shrink `DOCX_STATIC_COMPONENTS` to Word-required conversion, one entry at a time, and move its colors, fonts and spacing into the copied `docx-export.tsx` stylesheet. Proof: a Word check per removed entry. (built as one iteration in `docs/plans/2026-10-10-html-word-owns-word.md`; Word XML check `docs/plans/artifacts/html-word-owns-word/browser/final-word-xml-a4.log`) + +## Settled decisions + +Does this contradict Ziad's refactor? Mostly no, with one point for him to judge. The 2026-09-24 document-first export plan kept export on the live editor's compiled configuration and deleted caller-built export editors (`docs/plans/2026-09-24-document-first-export-contracts.md:325`, `:367-372`). This plan keeps both choices and builds no second editor. That plan also asked for frozen configuration (`:325`), which today's code does not fully give; this plan leaves it as is, and a separate plan owns it. This plan does add back a plugin-array option to `exportDocx`, the kind `:325` and `:371` removed as `editorPlugins`. The difference is that export reads only drawing from it, never meaning, and that difference is what Ziad has to judge. + +The draft asked Ziad five questions. Evidence answers four, and the owner settled the fifth. + +| Draft question | Answer | Evidence | +| --- | --- | --- | +| Does one captured projection still hold if rendering moves to the static kit while capture stays live? | Yes. Capture, settings and projection stay on the live editor; only drawing moves. | `capture-probe/probe-a5.log` live rows; `presentation-probe/probe-a3.log` | +| Was drawing the live editor's components on purpose? | No source records it as a choice. Removing caller-built export editors was on purpose, but no plan, review or test the `why` lane read chose or exported the editing components; an older probe directory the plans cite is gone, so this is inferred. | `why/answer.txt` items 1 and 3 | +| Should capture become a public entry? | No. Nothing needs it. | `capture-probe/probe-a5.log` | +| Does Word review still work when capture projects first? | Projection stays where it is today, so review is unchanged; the live review output keeps one insertion and one deletion. | `capture-probe/probe-a5.log` | +| Should Word's component overrides stay private? | Yes for now; phase 3 shrinks them to Word-only rules. | Synthesis, graft 5 | + +This plan expects three things of the authored redesign's switchover, listed in Fit with the authored redesign. Word draws `authored` marks as revisions during the render, comment ranges map inside `exportDocx`, and the toolbar stops building a projected view. Drawing revisions from the marks would also retire `applyReviewProjection`, which today finds rendered blocks by path after the render. + +The owner settled the `presentation` option on 2026-10-09, without waiting for that talk. Its cost is real. Once export draws from the static kit, a plugin missing from that kit exports plain with a warning, so the kit's membership matters for export. The registry spec fails the build when the demo corpus warns. It is not the rejected component map. It reuses the static kit the app already ships for servers and previews, covers elements, marks and wrappers in one array, and cannot change meaning, because export ignores its options, injections and dependencies. + +## Proof + +| Check | Pass condition | +| --- | --- | +| Every block exports | The element probe renders every block the live editor can hold with `presentation`, and real Word files carry their text and list levels. | +| Real toolbar | `/blocks/docx-demo` exports a document with a table, task list, callout, columns, code block and image to HTML, Word and Word with tracked changes, with no new console error. | +| Editing unchanged | Typing in a table and toggling a task still work after the change. | +| Content props | Both bug 2 cases throw and fail to compile. | +| One capture | The five authored cases match today's live text, revisions and diagnostic codes. | +| Cost | The phase 1 probe passes every frozen line. | + +## Open work + +Each item stays here until this plan folds into the subject file, which then lists it under Open work. + +- Styled Word returns the original file byte for byte when the document is unchanged, so a requested look never reaches it. Built in `docs/plans/2026-10-10-html-word-owns-word.md`, where a stylesheet or font family rebuilds the file. owner: natamox. stop: that plan's close, or 2026-11-30. +- Live decoration sources, such as find highlights and active comment marks, run during export. The registry's `EditorKit` installs neither today. owner: natamox. stop: a plan that installs either in an exporting editor, or 2026-11-30. +- A component that throws while Word renders escapes `exportDocx` as a thrown error, not a diagnostic (`exportDocx.tsx:384-395`, `:571-583`). owner: natamox. stop: phase 1's panel, which decides whether the missing-presentation warning is enough. +- A setting or highlighter change made while an export waits for the renderer reaches that export, as it does today (`capture-law-probe/probe-a1.log`). Capturing every render dependency needs contracts at the owners, because the code highlighter keeps a shared mutable engine behind a plain object (panel round 3). owner: natamox. stop: a separate plan for export capture, or 2026-11-30. +- A wrapper written as an object with a `match` never draws in a static render, as today. The registry's three are editing UI, but a third-party wrapper that draws content this way is lost without a diagnostic. owner: natamox. stop: the capture plan above, or a third-party report. +- Word's converter keeps list-numbering state at module level, so two Word exports running at once can share numbering IDs (`render-document-file.ts:631`, `:720`). Built in `docs/plans/2026-10-10-html-word-owns-word.md`, which moves the state onto each document. owner: natamox. stop: that plan's close, or 2026-11-30. +- The authored switchover must still redo Word revisions, comment range mapping and the toolbar's projected view, as Fit with the authored redesign lists, because it deletes the nouns that code calls today. owner: natamox, who adds these three items to the authored plan's Phase 2 step, or builds them in this plan if that switchover lands before phase 1. stop: the authored Phase 2 lands. +- Word's equation override writes LaTeX source, while the static kit draws KaTeX. Built in `docs/plans/2026-10-10-html-word-owns-word.md`, which writes Word math. owner: natamox. stop: that plan's close. +- The static task-list marker draws a ` + {children} + + + ) + : element.listType === 'bulleted' + ? ({ children }) => ( +
      +
    • {children}
    • +
    + ) + : element.listType + ? ({ children }) => ( +
      +
    1. {children}
    2. +
    + ) + : undefined, + }, + targetPlugins: ['paragraph', 'codeBlock'], + }), + ], + initialValue, + }); + const result = await exportDocx(editor, { projection: 'proposed' }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + + return { + documentXml: await zip.file('word/document.xml')!.async('string'), + numberingXml: (await zip.file('word/numbering.xml')?.async('string')) ?? '', + }; +}; + +const listLevels = async (initialValue: Value) => { + const { documentXml } = await exportListPackage(initialValue); + + return [...documentXml.matchAll(//g)].flatMap( + ([paragraph]) => { + const text = [...paragraph.matchAll(/]*>([^<]*)<\/w:t>/g)] + .map(([, run]) => run) + .join(''); + + return text + ? [[text, paragraph.match(/ ({ + children: [{ text }], + indent, + listType: 'numbered', + type: 'paragraph', +}); + +const numberingIds = async (initialValue: Value) => { + const { documentXml, numberingXml } = await exportListPackage(initialValue); + const defined = new Set( + [...numberingXml.matchAll(/ id) + ); + const used = [...documentXml.matchAll(/ id + ); + + return { undefined: used.filter((id) => !defined.has(id)), used }; +}; + +const task = (indent: number, text: string, checked: boolean) => ({ + checked, + children: [{ text }], + indent, + listType: 'task', + type: 'paragraph', +}); + +describe('exportDocx lists', () => { + it('draws task items with a checkbox marker at their list level', async () => { + const { documentXml, numberingXml } = await exportListPackage([ + task(1, 'Done task', true), + task(2, 'Open task', false), + ] as Value); + const paragraphs = [...documentXml.matchAll(//g)] + .map(([paragraph]) => paragraph) + .filter((paragraph) => paragraph.includes('')); + const marker = (paragraph: string) => { + const numId = paragraph.match(/` + ) + )?.[0]; + const levelXml = abstract?.match( + new RegExp(``) + )?.[0]; + + return { + font: levelXml?.match(/ { + const first = [ + numbered(1, 'one'), + numbered(1, 'two'), + numbered(2, 'nested'), + { children: [{ text: 'between' }], type: 'paragraph' }, + numbered(1, 'three'), + ] as Value; + const second = [ + { ...numbered(1, 'bullet'), listType: 'bulleted' }, + numbered(2, 'nested number'), + numbered(1, 'number'), + ] as Value; + const alone = [await numberingIds(first), await numberingIds(second)]; + + expect( + await Promise.all([numberingIds(first), numberingIds(second)]) + ).toEqual(alone); + expect(alone.flatMap((ids) => ids.undefined)).toEqual([]); + }); + + it('takes Word list levels from the indent of the block a list wraps', async () => { + const levels = await Promise.all([ + listLevels([ + numbered(1, 'one'), + numbered(2, 'two'), + numbered(3, 'three'), + ]), + listLevels([ + numbered(1, 'before'), + { + children: [{ text: 'code' }], + indent: 2, + listType: 'numbered', + type: 'codeBlock', + }, + ]), + ]); + + expect(levels).toEqual([ + [ + ['one', '0'], + ['two', '1'], + ['three', '2'], + ], + [ + ['before', '0'], + ['code', '1'], + ], + ]); + }); +}); diff --git a/packages/platejs/src/docx/export/lib/exportDocx.presentation.spec.tsx b/packages/platejs/src/docx/export/lib/exportDocx.presentation.spec.tsx new file mode 100644 index 0000000000..966c553ec8 --- /dev/null +++ b/packages/platejs/src/docx/export/lib/exportDocx.presentation.spec.tsx @@ -0,0 +1,268 @@ +import JSZip from 'jszip'; +import React from 'react'; + +import { AuthoredPlugin } from '../../../authored'; +import { + createEditor, + createEditorView, + definePlugin, + property, + type Value, +} from '../../../core'; +import { BaseBlockquotePlugin } from '../../../features/basic-nodes'; +import { BaseListPlugin } from '../../../features/list'; +import { BaseImagePlugin } from '../../../features/media'; +import { BaseParagraphPlugin } from '../../../lib'; +import type { DocxComment } from '../../internal/types'; +import { exportDocx } from './exportDocx'; + +const EditingOnly = (): React.ReactNode => { + throw new Error('Editing component rendered during export.'); +}; + +const StaticQuote = BaseBlockquotePlugin.configure({ + component: ({ attributes, children }) => ( +
    {children}
    + ), +}); + +const AiMarkPlugin = definePlugin('ai', { + schema: { mark: property.boolean({ default: false, omitDefault: true }) }, + render: { mark: { placement: 'text' } }, +}); + +const commentOn = (body: Value): DocxComment => ({ + author: null, + body, + createdAt: null, + durableId: null, + id: 'comment-1', + parentId: null, + resolved: null, + target: { + range: { + anchor: { offset: 0, path: [0, 0] }, + focus: { offset: 6, path: [0, 0] }, + }, + }, +}); + +const readPart = async (blob: Blob, part: string) => { + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + + return zip.file(part)!.async('string'); +}; + +describe('exportDocx presentation', () => { + it('writes review revisions through the static presentation', async () => { + const editor = createEditor({ + plugins: [ + BaseParagraphPlugin, + BaseBlockquotePlugin.configure({ component: EditingOnly }), + AuthoredPlugin, + ], + initialValue: [ + { + children: [{ children: [{ text: 'ABCDE' }], type: 'paragraph' }], + type: 'blockquote', + }, + ], + userId: 'alice', + }); + + createEditorView(editor, { + authored: { intent: 'propose', projection: 'markup' }, + }).update.text.insert('XY', { + at: { + anchor: { offset: 1, path: [0, 0, 0] }, + focus: { offset: 3, path: [0, 0, 0] }, + }, + }); + + const result = await exportDocx(editor, { + presentation: [StaticQuote], + projection: 'review', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const documentXml = await readPart(result.blob, 'word/document.xml'); + + expect([ + documentXml.match(/ { + const editor = createEditor({ + plugins: [ + BaseParagraphPlugin, + BaseBlockquotePlugin.configure({ component: EditingOnly }), + ], + initialValue: [ + { children: [{ text: 'Review this sentence.' }], type: 'paragraph' }, + ], + }); + + const result = await exportDocx(editor, { + comments: [ + { + author: null, + body: [ + { + children: [ + { children: [{ text: 'Quoted reply' }], type: 'paragraph' }, + ], + type: 'blockquote', + }, + ], + createdAt: null, + durableId: null, + id: 'comment-1', + parentId: null, + resolved: null, + target: { + range: { + anchor: { offset: 0, path: [0, 0] }, + focus: { offset: 6, path: [0, 0] }, + }, + }, + }, + ], + presentation: [StaticQuote], + projection: 'proposed', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(await readPart(result.blob, 'word/comments.xml')).toContain( + 'Quoted reply' + ); + }); + + const image = { + children: [{ text: 'Chart' }], + type: 'image', + url: 'https://platejs.org/chart.png', + }; + const numbered = { + children: [{ text: 'Step' }], + indent: 1, + listType: 'numbered', + type: 'paragraph', + }; + const highlighted = { + children: [{ ai: true, text: 'Suggested' }], + type: 'paragraph', + }; + + it.each([ + { + block: image, + live: BaseImagePlugin.configure({ component: EditingOnly }), + place: 'body', + }, + { + block: image, + live: BaseImagePlugin.configure({ component: EditingOnly }), + place: 'comment', + }, + { + block: numbered, + live: BaseListPlugin.configure({ + slots: { wrapNodeChildren: () => EditingOnly }, + }), + place: 'body', + }, + { + block: highlighted, + live: AiMarkPlugin.configure({ component: EditingOnly }), + place: 'body', + }, + { + block: highlighted, + live: AiMarkPlugin.configure({ component: EditingOnly }), + place: 'comment', + }, + ])( + 'counts a missing $live.name drawing in the $place as lost content', + async ({ block, live, place }) => { + const editor = createEditor({ + plugins: [BaseParagraphPlugin, live], + initialValue: + place === 'body' + ? [block] + : [ + { + children: [{ text: 'Review this sentence.' }], + type: 'paragraph', + }, + ], + }); + const options = { + comments: place === 'body' ? [] : [commentOn([block])], + presentation: [], + projection: 'proposed', + } as const; + + const allowed = await exportDocx(editor, { + ...options, + lossPolicy: 'allow', + }); + const rejected = await exportDocx(editor, options); + + expect({ + allowed: allowed.ok, + diagnostics: allowed.diagnostics, + rejected: rejected.ok, + }).toMatchObject({ + allowed: true, + diagnostics: [ + { + code: 'missing-static-presentation', + plugin: live.name, + severity: 'warning', + }, + ], + rejected: false, + }); + } + ); + + it('ignores comment bodies the accepted projection omits', async () => { + const editor = createEditor({ + plugins: [ + BaseParagraphPlugin, + AiMarkPlugin.configure({ component: EditingOnly }), + AuthoredPlugin, + ], + initialValue: [{ children: [{ text: 'ABC' }], type: 'paragraph' }], + userId: 'alice', + }); + + createEditorView(editor, { + authored: { intent: 'propose', projection: 'markup' }, + }).update.text.insert('XY', { at: { offset: 3, path: [0, 0] } }); + + const result = await exportDocx(editor, { + comments: [ + { + ...commentOn([ + { children: [{ ai: true, text: 'Suggested' }], type: 'paragraph' }, + ]), + target: { + range: { + anchor: { offset: 3, path: [0, 0] }, + focus: { offset: 5, path: [0, 0] }, + }, + }, + }, + ], + presentation: [], + projection: 'accepted', + }); + + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/platejs/src/docx/export/lib/exportDocx.spec.ts b/packages/platejs/src/docx/export/lib/exportDocx.spec.ts index 3ff8e0aab7..008424d1c0 100644 --- a/packages/platejs/src/docx/export/lib/exportDocx.spec.ts +++ b/packages/platejs/src/docx/export/lib/exportDocx.spec.ts @@ -22,6 +22,142 @@ import { EditorStatic, type EditorStaticProps } from '../../../static'; import { importDocx } from '../../import/lib/importDocx'; import { exportDocx } from './exportDocx'; +const exportEquationXml = async (latex: string, inline = false) => { + const editor = createEditor({ + initialValue: inline + ? [ + { + children: [ + { text: 'Inline ' }, + { children: [{ text: '' }], latex, type: 'inlineEquation' }, + ], + type: 'paragraph', + }, + ] + : [{ children: [{ text: '' }], latex, type: 'equation' }], + plugins: [BaseEquationPlugin, BaseInlineEquationPlugin], + }); + const result = await exportDocx(editor, { projection: 'proposed' }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + + return zip.file('word/document.xml')!.async('string'); +}; + +const xmlText = (text: string) => + text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + +describe('exportDocx equations', () => { + it('exports a block equation as Word math', async () => { + const xml = await exportEquationXml('\\frac{a}{b}'); + + expect(xml).toMatch(/]/); + expect(xml).toContain(''); + expect(xml).not.toContain('\\frac'); + }); + + it('exports an inline equation as Word math beside its text', async () => { + const xml = await exportEquationXml('x^2', true); + + expect(xml).toContain(''); + expect(xml).not.toMatch(/]/); + expect(xml).toContain('Inline '); + }); + + it('keeps a math font such as double-struck', async () => { + const xml = await exportEquationXml('\\mathbb{R}'); + + expect(xml).toContain(''); + }); + + it('converts common notation instead of falling back to its TeX', async () => { + const sources = [ + 'f(x, y)', + "x'", + '\\binom{n}{k}', + 'a\\quad b', + '\\left(x\\right)', + '\\big(x\\big)', + '\\overline{x}', + '\\underline{x}', + '\\sum_{i=1}^n i', + '\\int_0^1 x\\,dx', + '\\alpha \\le \\infty', + '\\sin x', + ]; + const fellBack: string[] = []; + + for (const source of sources) { + const xml = await exportEquationXml(source); + + if (!/]/.test(xml) || xml.includes(xmlText(source))) { + fellBack.push(source); + } + } + + expect(fellBack).toEqual([]); + }); + + it('keeps malformed TeX as one normal-text math run', async () => { + const xml = await exportEquationXml('\\frac{'); + + expect(xml).toMatch( + /]*>\s*\s*\s*\s*<\/m:rPr>\s*]*>\\frac\{<\/m:t>\s*<\/m:r>\s*<\/m:oMath>/ + ); + }); + + it('keeps notation Word math cannot express as its TeX', async () => { + const xml = await exportEquationXml('\\raisebox{1em}{x}'); + + expect(xml).toContain(''); + expect(xml).toContain('\\raisebox{1em}{x}'); + }); + + it('writes a text-only equation as its TeX', async () => { + const xml = await exportEquationXml('\\text{50\\% off}'); + + expect(xml).toContain(''); + expect(xml).toContain('\\text{50\\% off}'); + }); + + it('writes text beside math that draws nothing as its TeX', async () => { + const xml = await exportEquationXml('\\text{a}\\hspace{0pt}'); + + expect(xml).toMatch(/]/); + expect(xml).toContain(''); + expect(xml).toContain('\\text{a}\\hspace{0pt}'); + }); + + it('keeps a nine-em space as Word math', async () => { + expect(await exportEquationXml('x\\hspace{9em}y')).not.toContain( + '' + ); + }); + + it('keeps a space too wide for Word math as its TeX', async () => { + const xml = await exportEquationXml('x\\hspace{1000000000em}y'); + + expect(xml).toContain(''); + expect(xml).toContain('x\\hspace{1000000000em}y'); + }); + + it('stacks a symbol over a relation', async () => { + expect(await exportEquationXml('\\overset{+}{=}')).toContain(''); + }); + + it('stacks a symbol over a letter', async () => { + expect(await exportEquationXml('\\overset{+}{x}')).toContain(''); + }); + + it('keeps a stretchy brace as its TeX', async () => { + const xml = await exportEquationXml('\\overbrace{a+b}^{n}'); + + expect(xml).toContain(''); + expect(xml).toContain('\\overbrace{a+b}^{n}'); + }); +}); + describe('exportDocx', () => { afterEach(() => { mock.restore(); @@ -150,6 +286,66 @@ describe('exportDocx', () => { expect(documentXml).toContain('>\u00A0\u00A0'); }); + it('exports a callout under a stylesheet that styles table cells', async () => { + const editor = createEditor({ + initialValue: [ + { children: [{ text: 'Callout' }], icon: '💡', type: 'callout' }, + ], + plugins: [BaseCalloutPlugin], + }); + + const result = await exportDocx(editor, { + projection: 'proposed', + stylesheet: 'th, td { padding: 6pt; }', + }); + + expect(result.ok).toBe(true); + }); + + it('shades both callout cells with the callout color', async () => { + const editor = createEditor({ + initialValue: [ + { + backgroundColor: '#fde68a', + children: [{ text: 'Warm' }], + icon: '🔥', + type: 'callout', + }, + ], + plugins: [BaseCalloutPlugin], + }); + + const result = await exportDocx(editor, { projection: 'proposed' }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')!.async('string'); + + expect(documentXml.match(/w:fill="fde68a"/gi)).toHaveLength(2); + }); + + it('leaves callout cells unshaded for a color Word cannot read', async () => { + const editor = createEditor({ + initialValue: [ + { + backgroundColor: 'rgba(253, 230, 138, 0.5)', + children: [{ text: 'Warm' }], + icon: '🔥', + type: 'callout', + }, + ], + plugins: [BaseCalloutPlugin], + }); + + const result = await exportDocx(editor, { projection: 'proposed' }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')!.async('string'); + + expect(documentXml).not.toContain('w:fill="000000"'); + }); + it('owns required Word renderers without registry components', async () => { const editor = createEditor({ initialValue: [ @@ -228,8 +424,6 @@ describe('exportDocx', () => { const documentXml = await zip.file('word/document.xml')!.async('string'); expect(documentXml).toContain('Package heading'); - expect(documentXml).toContain('E = mc^2'); - expect(documentXml).toContain('x^2 + y^2'); expect(documentXml).toContain('Callout body'); expect(documentXml).toContain('First column'); expect(documentXml).toContain('Second column'); diff --git a/packages/platejs/src/docx/export/lib/exportDocx.tsx b/packages/platejs/src/docx/export/lib/exportDocx.tsx index c21c0be493..7f1ebabed2 100644 --- a/packages/platejs/src/docx/export/lib/exportDocx.tsx +++ b/packages/platejs/src/docx/export/lib/exportDocx.tsx @@ -10,6 +10,7 @@ import { projectAuthoredReview, } from '../../../authored'; import { + type BasePluginInput, type Descendant, type Element, DocumentChange, @@ -21,7 +22,7 @@ import { } from '../../../core'; import type { EditorStaticProps } from '../../../static/components/PlateStatic'; import { renderStaticHtmlWithOverrides } from '../../../static/internal/renderStaticHtmlWithOverrides'; -import type { RenderStaticHtmlOptions } from '../../../static/renderStaticHtml'; +import type { StaticHtmlResult } from '../../../static/renderStaticHtml'; import { throwIfDocxAborted } from '../../internal/abort'; import { acquireDocxSource, @@ -51,9 +52,15 @@ export type DocxExportOptions = Readonly<{ allowRemoteImages?: boolean; /** Word comments whose ranges address the proposed editor projection. */ comments?: readonly DocxComment[]; - /** React component used for static HTML rendering. */ + /** + * React component used for static HTML rendering. It does not stop an + * unchanged `source` from returning its original file. + */ component?: React.ComponentType; - /** Font family for the document body. */ + /** + * Font family for the document body. Passing it rebuilds an unchanged + * `source` instead of returning its original file. + */ fontFamily?: string; /** * `reject` fails the export when content would be dropped, such as an image @@ -71,6 +78,15 @@ export type DocxExportOptions = Readonly<{ orientation?: 'landscape' | 'portrait'; /** Page size in twentieths of a point. */ pageSize?: PageSize; + /** + * Static plugins that draw the body and every comment body, such as the + * app's static kit; see `renderStaticHtml`. DOCX's own drawings of code + * blocks, columns, equations, callouts, headings and tables of contents + * take precedence. Each `missing-static-presentation` diagnostic counts as + * lost content under `lossPolicy`. It does not stop an unchanged `source` + * from returning its original file. + */ + presentation?: readonly BasePluginInput[]; /** Visible document projection to export. */ projection: 'accepted' | 'proposed' | 'review'; /** Cooperative cancellation signal. */ @@ -81,7 +97,18 @@ export type DocxExportOptions = Readonly<{ * from editor content. */ source?: DocxSource | null; - /** Exact CSS stylesheet applied before DOCX conversion. */ + /** + * Exact CSS stylesheet applied before DOCX conversion. Passing it rebuilds + * an unchanged `source` instead of returning its original file. + * + * DOCX's own drawings set no color, font or font size beyond an element's + * own values, such as a callout's color, so the stylesheet owns them. Each + * drawn element has the class `editor-`. A callout draws + * `table > tbody > tr > td`, its first cell holding the icon; a code block + * draws `[data-docx-preserve-whitespace]`; an empty equation or table of + * contents marks its placeholder `[data-empty]`. A task list item marks its + * `li` with `data-checked`, which becomes a ☑ or ☐ list marker. + */ stylesheet?: string; /** Document metadata title. */ title?: string; @@ -330,34 +357,40 @@ const listDiagnostics = ( return diagnostics; }; -const renderProjection = async ( +const renderDocxHtml = async ( editor: Editor, - capture: ExportCapture, - options: DocxExportOptions + document: EditorDocumentValue, + { component, fontFamily, presentation, signal }: DocxExportOptions ) => { - const { component, fontFamily } = options; - const { document, review } = capture; - const htmlOptions: Partial = { - document, - props: { - style: { padding: '0', ...(fontFamily ? { fontFamily } : {}) }, - }, - }; - - if (component) htmlOptions.component = component; - const { data: html } = await renderStaticHtmlWithOverrides( + const rendered = await renderStaticHtmlWithOverrides( editor, - htmlOptions, + { + component, + document, + presentation, + props: { + style: { padding: '0', ...(fontFamily ? { fontFamily } : {}) }, + }, + }, DOCX_STATIC_COMPONENTS ); - throwIfDocxAborted(options.signal); + throwIfDocxAborted(signal); - return review - ? applyReviewProjection(html, review) - : Object.freeze({ diagnostics: Object.freeze([]), html }); + return rendered; }; +const missingDrawings = ( + renders: readonly StaticHtmlResult[] +): DocxDiagnostic[] => [ + ...new Map( + renders + .flatMap(({ diagnostics }) => diagnostics) + .filter((diagnostic) => diagnostic.code === 'missing-static-presentation') + .map((diagnostic) => [diagnostic.plugin, diagnostic]) + ).values(), +]; + class DocxGenerationError extends Error { constructor(cause: unknown) { super('DOCX output could not be generated.', { cause }); @@ -439,7 +472,9 @@ const resolveSourceExport = ( options.title !== undefined || options.margins !== undefined || options.orientation !== undefined || - options.pageSize !== undefined + options.pageSize !== undefined || + options.stylesheet !== undefined || + options.fontFamily !== undefined ) { reasons.push('output-options-changed'); } @@ -469,6 +504,7 @@ const resolveSourceExport = ( }; const isContentLoss = (diagnostic: DocxDiagnostic) => + diagnostic.code === 'missing-static-presentation' || diagnostic.code === 'resource-omitted' || (diagnostic.code === 'unsupported-content' && diagnostic.action === 'dropped') || @@ -492,42 +528,6 @@ const generateDocx = async (html: string, options: DocxExportOptions) => { } }; -const renderCommentBodies = async ( - editor: Editor, - comments: readonly DocxComment[], - options: DocxExportOptions -) => { - const entries = await Promise.all( - comments.map(async (comment) => { - const htmlOptions: Partial = { - document: { children: comment.body }, - props: { - style: { - padding: '0', - ...(options.fontFamily ? { fontFamily: options.fontFamily } : {}), - }, - }, - }; - - if (options.component) htmlOptions.component = options.component; - - const { data } = await renderStaticHtmlWithOverrides( - editor, - htmlOptions, - DOCX_STATIC_COMPONENTS - ); - - throwIfDocxAborted(options.signal); - - return [comment.id, data] as const; - }) - ); - - throwIfDocxAborted(options.signal); - - return new Map(entries); -}; - /** Convert one immutable editor snapshot to a deliberate DOCX projection. */ export async function exportDocx( editor: Editor, @@ -601,17 +601,29 @@ export async function exportDocx( ]; try { - const [rendered, commentBodies] = await Promise.all([ - renderProjection(editor, capture, options), - renderCommentBodies(editor, comments, options), + const [main, commentRenders] = await Promise.all([ + renderDocxHtml(editor, capture.document, options), + Promise.all( + capture.comments.map(async ({ body, id }) => ({ + id, + ...(await renderDocxHtml(editor, { children: body }, options)), + })) + ), ]); throwIfDocxAborted(options.signal); - diagnostics.push(...rendered.diagnostics); + const rendered = capture.review + ? applyReviewProjection(main.data, capture.review) + : { diagnostics: [], html: main.data }; + + diagnostics.push( + ...rendered.diagnostics, + ...missingDrawings([main, ...commentRenders]) + ); const preparedComments = prepareDocxComments( rendered.html, capture.comments, - commentBodies + new Map(commentRenders.map(({ data, id }) => [id, data])) ); diagnostics.push(...preparedComments.diagnostics); diff --git a/packages/platejs/src/docx/export/lib/exportHtmlToDocx.internal.ts b/packages/platejs/src/docx/export/lib/exportHtmlToDocx.internal.ts index ee11cc38b6..5ed7027d86 100644 --- a/packages/platejs/src/docx/export/lib/exportHtmlToDocx.internal.ts +++ b/packages/platejs/src/docx/export/lib/exportHtmlToDocx.internal.ts @@ -41,6 +41,7 @@ export const exportHtmlToDocx = async ( const styleElement = stylesheet ? `` : ''; const fullHtml = `${styleElement}${bodyHtml}`; const inlinedHtml = juice(fullHtml, { + decodeStyleAttributes: true, preserveFontFaces: false, preserveMediaQueries: false, removeStyleTags: false, diff --git a/packages/platejs/src/docx/export/lib/internal/docx-document.ts b/packages/platejs/src/docx/export/lib/internal/docx-document.ts index 0979ffa06e..e10b227d33 100644 --- a/packages/platejs/src/docx/export/lib/internal/docx-document.ts +++ b/packages/platejs/src/docx/export/lib/internal/docx-document.ts @@ -162,6 +162,8 @@ export interface RelationshipXMLOutput { /** Numbering object */ export interface NumberingObject { + /** A bullet list's own marker in place of the default bullets. */ + marker?: Readonly<{ font: string; text: string }>; numberingId: number; /** Set on the one Word definition of a Plate list. */ plateList?: PlateListDefinition; @@ -444,6 +446,7 @@ class DocxDocument { >(); private readonly numberingOverrides: NumberingOverride[] = []; private readonly openLists = new Map(); + private readonly taskNumbering = new Map(); allowRemoteImages: boolean; availableDocumentSpace: number; complexScriptFontSize: number; @@ -843,7 +846,7 @@ class DocxDocument { const numberingFragments = fragment(); this.numberingObjects.forEach( - ({ numberingId, plateList, type, properties }) => { + ({ marker, numberingId, plateList, type, properties }) => { const abstractNumberingFragment = fragment({ namespaceAlias: { w: namespaces.w }, }) @@ -894,7 +897,7 @@ class DocxDocument { levelType === 'ol' ? (rules?.label ?? this.ListStyleBuilder.getListPrefixSuffix(style, level)) - : getBulletChar(level) + : (marker?.text ?? getBulletChar(level)) ) .up() .ele('@w', 'lvlJc') @@ -919,8 +922,8 @@ class DocxDocument { fragment({ namespaceAlias: { w: namespaces.w } }) .ele('@w', 'rPr') .ele('@w', 'rFonts') - .att('@w', 'ascii', 'Symbol') - .att('@w', 'hAnsi', 'Symbol') + .att('@w', 'ascii', marker?.font ?? 'Symbol') + .att('@w', 'hAnsi', marker?.font ?? 'Symbol') .att('@w', 'hint', 'default') .up() .up() @@ -1017,9 +1020,30 @@ class DocxDocument { } } - createNumbering(type: 'ol' | 'ul', properties: NumberingProperties): number { + taskNumberingId(checked: boolean): number { + const existing = this.taskNumbering.get(checked); + + if (existing !== undefined) return existing; + // Symbol, the bullet font, has no ballot box glyphs. + const id = this.createNumbering( + 'ul', + {}, + { font: 'Segoe UI Symbol', text: checked ? '☑' : '☐' } + ); + + this.taskNumbering.set(checked, id); + + return id; + } + + createNumbering( + type: 'ol' | 'ul', + properties: NumberingProperties, + marker?: NumberingObject['marker'] + ): number { this.lastNumberingId += 1; this.numberingObjects.push({ + marker, numberingId: this.lastNumberingId, type, properties, diff --git a/packages/platejs/src/docx/export/lib/internal/math.ts b/packages/platejs/src/docx/export/lib/internal/math.ts new file mode 100644 index 0000000000..067f0c1dc4 --- /dev/null +++ b/packages/platejs/src/docx/export/lib/internal/math.ts @@ -0,0 +1,308 @@ +import { fragment } from 'xmlbuilder2'; +import type { XMLBuilder } from 'xmlbuilder2/lib/interfaces'; + +import { + WORD_MATH_ACCENTS, + WORD_MATH_VARIANTS, +} from '../../../internal/wordMathVocabulary'; +import namespaces from './namespaces'; + +type MathNode = { + children?: MathNode[]; + properties?: { attributes?: Record }; + tagName?: string; + text?: string; +}; + +class UnsupportedError extends Error { + override name = 'UnsupportedError'; +} + +/** + * Accepted on any element: Word math spaces and sizes math by itself, + * `isFence` reads `fence`, and `xmlns` draws nothing. + */ +const LAYOUT_ATTRIBUTES = new Set([ + 'fence', + 'form', + 'largeop', + 'lspace', + 'maxsize', + 'minsize', + 'movablelimits', + 'rspace', + 'separator', + 'stretchy', + 'symmetric', + 'xmlns', +]); + +const MEANINGFUL_ATTRIBUTES: Readonly> = { + annotation: ['encoding'], + math: ['display'], + menclose: ['notation'], + mfrac: ['linethickness'], + mi: ['mathvariant'], + mn: ['mathvariant'], + mo: ['mathvariant'], + mover: ['accent'], + mspace: ['width'], + mstyle: ['displaystyle', 'scriptlevel'], + munder: ['accentunder'], +}; + +const attribute = (node: MathNode, name: string) => + node.properties?.attributes?.[name]; + +/** The line KaTeX draws for `\overline` and `\underline`. */ +const BAR_CHARS = new Set(['‾', '¯', '_', '\u0332']); + +const children = (node: MathNode) => + (node.children ?? []).filter((child) => child.tagName !== undefined); + +const textOf = (node: MathNode): string => + node.tagName === undefined + ? (node.text ?? '') + : (node.children ?? []).map((child) => textOf(child)).join(''); + +const escapeXml = (text: string) => + text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + +const run = (text: string, properties = '') => + `${properties ? `${properties}` : ''}${escapeXml(text)}`; + +const checkAttributes = (node: MathNode) => { + const allowed = MEANINGFUL_ATTRIBUTES[node.tagName ?? ''] ?? []; + + for (const name of Object.keys(node.properties?.attributes ?? {})) { + if (!LAYOUT_ATTRIBUTES.has(name) && !allowed.includes(name)) { + throw new UnsupportedError(name); + } + } +}; + +const arity = (node: MathNode, count: number) => { + const items = children(node); + + if (items.length !== count) throw new UnsupportedError(node.tagName); + + return items; +}; + +const token = (node: MathNode) => { + const text = textOf(node); + const mathvariant = attribute(node, 'mathvariant'); + + if (mathvariant) { + const variant = WORD_MATH_VARIANTS.find( + (entry) => entry.mathvariant === mathvariant + ); + + if (!variant) throw new UnsupportedError(mathvariant); + + return run( + text, + `${variant.scr ? `` : ''}${variant.sty ? `` : ''}` + ); + } + + // A multi-letter identifier, such as KaTeX's `sin`, is upright in TeX. + return run( + text, + node.tagName === 'mi' && Array.from(text).length > 1 + ? '' + : '' + ); +}; + +/** A brace KaTeX stretches over its base, which a Word limit draws as text. */ +const isStretchy = (node: MathNode) => + node.tagName === 'mo' && attribute(node, 'stretchy') === 'true'; + +const isFence = (node: MathNode | undefined) => + node?.tagName === 'mo' && attribute(node, 'fence') === 'true'; + +const join = (nodes: MathNode[]) => nodes.map((node) => convert(node)).join(''); + +const convert = (node: MathNode): string => { + checkAttributes(node); + switch (node.tagName) { + case 'mrow': { + const items = children(node); + const inner = items.slice(1, -1); + + if (items.length < 2 || !isFence(items[0]) || !isFence(items.at(-1))) { + return join(items); + } + if (inner.some(isFence)) throw new UnsupportedError('middle fence'); + + return `${join(inner)}`; + } + case 'mstyle': { + return join(children(node)); + } + case 'mi': + case 'mn': + case 'mo': { + // KaTeX wraps a stacked symbol, such as `\overset{+}{=}`, in a token element. + if (children(node).length > 0) return join(children(node)); + + return textOf(node) === '\u2061' ? '' : token(node); + } + case 'mtext': { + return run(textOf(node), ''); + } + case 'mspace': { + const width = Number.parseFloat(attribute(node, 'width') ?? '0'); + + // Wider than this, the equation keeps its TeX instead of a long run of em spaces. + if (width > 100) throw new UnsupportedError('width'); + if (width >= 1) return run('\u2003'.repeat(Math.round(width))); + + return width > 0 ? run('\u2009') : ''; + } + case 'mfrac': { + const [numerator, denominator] = arity(node, 2); + const thickness = attribute(node, 'linethickness'); + + if (thickness !== undefined && Number.parseFloat(thickness) !== 0) { + throw new UnsupportedError('linethickness'); + } + + return `${thickness === undefined ? '' : ''}${convert(numerator)}${convert(denominator)}`; + } + case 'msqrt': { + return `${join(children(node))}`; + } + case 'mroot': { + const [base, degree] = arity(node, 2); + + return `${convert(degree)}${convert(base)}`; + } + case 'msub': { + const [base, sub] = arity(node, 2); + + return `${convert(base)}${convert(sub)}`; + } + case 'msup': { + const [base, sup] = arity(node, 2); + + return `${convert(base)}${convert(sup)}`; + } + case 'msubsup': { + const [base, sub, sup] = arity(node, 3); + + return `${convert(base)}${convert(sub)}${convert(sup)}`; + } + case 'mover': { + const [base, over] = arity(node, 2); + + if (attribute(node, 'accent') !== 'true') { + if (isStretchy(over)) throw new UnsupportedError('stretchy'); + + return `${convert(base)}${convert(over)}`; + } + const char = textOf(over); + + if (BAR_CHARS.has(char)) { + return `${convert(base)}`; + } + const accent = WORD_MATH_ACCENTS.find((entry) => + entry.chars.includes(char) + ); + + if (!accent) throw new UnsupportedError(char); + + return `${convert(base)}`; + } + case 'munder': { + const [base, under] = arity(node, 2); + + if (attribute(node, 'accentunder') !== 'true') { + if (isStretchy(under)) throw new UnsupportedError('stretchy'); + + return `${convert(base)}${convert(under)}`; + } + if (!BAR_CHARS.has(textOf(under))) { + throw new UnsupportedError(textOf(under)); + } + + return `${convert(base)}`; + } + case 'munderover': { + const [base, under, over] = arity(node, 3); + + return `${convert(base)}${convert(under)}${convert(over)}`; + } + case 'menclose': { + if (attribute(node, 'notation') !== 'box') { + throw new UnsupportedError('notation'); + } + + return `${join(children(node))}`; + } + default: { + throw new UnsupportedError(node.tagName); + } + } +}; + +const NORMAL_TEXT_ONLY = + /^(?:<\/m:rPr>[^<]*<\/m:t><\/m:r>)+$/; + +const presentationOf = (math: MathNode) => { + const [first] = children(math); + const items = + first?.tagName === 'semantics' + ? children(first).filter((child) => child.tagName !== 'annotation') + : children(math); + + if (items.length === 0) throw new UnsupportedError('empty'); + + return items.length === 1 ? items[0] : { children: items, tagName: 'mrow' }; +}; + +const texOf = (math: MathNode): string => { + for (const child of children(math)) { + if ( + child.tagName === 'annotation' && + attribute(child, 'encoding') === 'application/x-tex' + ) { + return textOf(child); + } + const nested = texOf(child); + + if (nested) return nested; + } + + return ''; +}; + +/** + * Anything Word math cannot express becomes one normal-text run holding the + * TeX. An all-text equation takes the same shape, so import can read it back. + */ +export const buildMath = (math: MathNode): XMLBuilder => { + let body: string; + + try { + checkAttributes(math); + body = convert(presentationOf(math)); + if (NORMAL_TEXT_ONLY.test(body)) throw new UnsupportedError('text only'); + } catch (error) { + if (!(error instanceof UnsupportedError)) throw error; + body = run(texOf(math), ''); + } + + const omath = `${body}`; + + return fragment().ele( + attribute(math, 'display') === 'block' + ? `${omath}` + : omath + ); +}; diff --git a/packages/platejs/src/docx/export/lib/internal/render-document-file.ts b/packages/platejs/src/docx/export/lib/internal/render-document-file.ts index d80457c6fd..57bc899c5a 100644 --- a/packages/platejs/src/docx/export/lib/internal/render-document-file.ts +++ b/packages/platejs/src/docx/export/lib/internal/render-document-file.ts @@ -67,6 +67,7 @@ type DocxDocumentInstance = { createFont: (fontFamily: string) => string; createMediaFile: (base64Uri: string) => MediaFileResponse; createNumbering: (type: 'ol' | 'ul', properties?: VNodeProperties) => number; + taskNumberingId: (checked: boolean) => number; getBookmark: (htmlId: string) => { id: number; name: string } | undefined; openList: ( type: 'ol' | 'ul', @@ -182,11 +183,23 @@ type ContentGroup = { type: 'block' | 'inline'; }; +const INDENT_CLASS_REGEX = /\beditor-indent-\d+\b/; + +const isIndented = (vNode: VNodeType) => + !['ol', 'ul'].includes(vNode.tagName || '') && + Boolean( + vNode.properties?.style?.['margin-left'] || + INDENT_CLASS_REGEX.test(vNode.properties?.attributes?.class ?? '') + ); + async function findXMLEquivalent( docxDocumentInstance: DocxDocumentInstance, vNode: VNodeType, - xmlFragment: XMLBuilderType + xmlFragment: XMLBuilderType, + indentedAncestor: VNodeType | undefined ): Promise { + const childIndentedAncestor = isIndented(vNode) ? vNode : indentedAncestor; + if ( vNode.tagName === 'div' && (vNode.properties?.attributes?.class === 'page-break' || @@ -205,37 +218,6 @@ async function findXMLEquivalent( return; } - // Handle block equation with OMML - if ( - vNode.tagName === 'div' && - vNode.properties && - vNode.properties.attributes && - vNode.properties.attributes['data-equation-omml'] - ) { - const ommlString = vNode.properties.attributes['data-equation-omml']; - try { - // Create a paragraph containing the OMML - const paragraphFragment = fragment({ - namespaceAlias: { w: namespaces.w }, - }) - .ele('@w', 'p') - .ele('@w', 'pPr') - .ele('@w', 'jc') - .att('@w', 'val', 'center') - .up() - .up(); - // Parse and import the OMML - const ommlFragment = fragment().ele(ommlString); - paragraphFragment.first().import(ommlFragment); - paragraphFragment.first().up(); - - xmlFragment.import(paragraphFragment); - return; - } catch { - console.warn('Failed to parse OMML for block equation'); - } - } - // Handle div elements - check if they contain only inline children // Skip divs that contain special elements that need their own processing if (vNode.tagName === 'div' && vNodeHasChildren(vNode)) { @@ -300,7 +282,8 @@ async function findXMLEquivalent( await convertVTreeToXML( docxDocumentInstance, group.node, - xmlFragment + xmlFragment, + childIndentedAncestor ); } } @@ -516,7 +499,13 @@ async function findXMLEquivalent( } case 'ol': case 'ul': { - await buildList(vNode, docxDocumentInstance, xmlFragment); + // A list drawn without its own margin takes its indented ancestor's indent. + await buildList( + vNode, + docxDocumentInstance, + xmlFragment, + indentedAncestor + ); return; } case 'img': { @@ -546,9 +535,19 @@ async function findXMLEquivalent( isVNode(childVNode) && ['ul', 'ol'].includes((childVNode as VNodeType).tagName || '') ) { - await buildList(childVNode, docxDocumentInstance, xmlFragment, vNode); + await buildList( + childVNode, + docxDocumentInstance, + xmlFragment, + childIndentedAncestor ?? vNode + ); } else { - await convertVTreeToXML(docxDocumentInstance, childVNode, xmlFragment); + await convertVTreeToXML( + docxDocumentInstance, + childVNode, + xmlFragment, + childIndentedAncestor + ); } } } @@ -557,20 +556,27 @@ async function findXMLEquivalent( export async function convertVTreeToXML( docxDocumentInstance: DocxDocumentInstance, vTree: VTree | null, - xmlFragment: XMLBuilderType + xmlFragment: XMLBuilderType, + indentedAncestor?: VNodeType ): Promise { if (!vTree) { return ''; } if (Array.isArray(vTree) && vTree.length) { for (const vNode of vTree) { - await convertVTreeToXML(docxDocumentInstance, vNode, xmlFragment); + await convertVTreeToXML( + docxDocumentInstance, + vNode, + xmlFragment, + indentedAncestor + ); } } else if (isVNode(vTree)) { await findXMLEquivalent( docxDocumentInstance, vTree as VNodeType, - xmlFragment + xmlFragment, + indentedAncestor ); } else if (isVText(vTree)) { const paragraphFragment = await buildParagraph( diff --git a/packages/platejs/src/docx/export/lib/internal/xml-builder.ts b/packages/platejs/src/docx/export/lib/internal/xml-builder.ts index fe0073391d..75ced6f264 100644 --- a/packages/platejs/src/docx/export/lib/internal/xml-builder.ts +++ b/packages/platejs/src/docx/export/lib/internal/xml-builder.ts @@ -22,6 +22,7 @@ import { paragraphBordersObject, verticalAlignValues, } from './constants'; +import { buildMath } from './math'; import namespaces from './namespaces'; import { cmRegex, @@ -311,6 +312,7 @@ type DocxDocumentInstance = { createFont: (fontFamily: string) => string; createMediaFile: (base64Uri: string) => MediaFileResponse; createNumbering: (type: 'ol' | 'ul', properties?: VNodeProperties) => number; + taskNumberingId: (checked: boolean) => number; getBookmark: (htmlId: string) => { id: number; name: string } | undefined; htmlString: string; openList: ( @@ -579,6 +581,14 @@ export const buildList = async ( node.children?.length && ['ul', 'ol', 'li'].includes(node.tagName || '') ) { + const checked = + node.tagName === 'li' + ? node.properties?.attributes?.['data-checked'] + : undefined; + const numberingId = + checked === 'true' || checked === 'false' + ? docxDocumentInstance.taskNumberingId(checked === 'true') + : current.numberingId; let items = 0; const children = node.children.reduce( (accumulator, child) => { @@ -673,7 +683,7 @@ export const buildList = async ( instance: current.instance, level: current.level, type: current.type, - numberingId: current.numberingId, + numberingId, ...(itemIndex > 1 && node.properties?.attributes?.['data-list-id'] !== undefined ? { @@ -805,7 +815,7 @@ type FormattingOptions = { fontSize?: number; }; -const fixupColorCode = (colorCodeString: string): string => { +const parseColorCode = (colorCodeString: string): string | undefined => { if (Object.hasOwn(colorNames, colorCodeString.toLowerCase())) { const [red, green, blue] = colorNames[colorCodeString.toLowerCase() as keyof typeof colorNames]; @@ -856,9 +866,12 @@ const fixupColorCode = (colorCodeString: string): string => { return hex3ToHex(red, green, blue); } } - return '000000'; + return undefined; }; +const fixupColorCode = (colorCodeString: string) => + parseColorCode(colorCodeString) ?? '000000'; + const buildRunFontFragment = (fontName: string = defaultFont): XMLBuilderType => fragment({ namespaceAlias: { w: namespaces.w } }) .ele('@w', 'rFonts') @@ -1182,14 +1195,14 @@ const modifiedStyleAttributesBuilder = ( modifiedAttributes.color = fixupColorCode(style.color); } - if ( + // A background parseColorCode cannot read is dropped, not painted black. + const backgroundColor = style['background-color'] && !colorlessColors.includes(style['background-color']) - ) { - modifiedAttributes.backgroundColor = fixupColorCode( - style['background-color'] - ); - } + ? parseColorCode(style['background-color']) + : undefined; + + if (backgroundColor) modifiedAttributes.backgroundColor = backgroundColor; if ( style['vertical-align'] && @@ -1393,6 +1406,9 @@ const buildRun = async ( initialAttributes: ParagraphAttributes, docxDocumentInstance?: DocxDocumentInstance ): Promise => { + if (isVNode(vNode) && (vNode as VNodeType).tagName === 'math') { + return buildMath(vNode as VNodeType); + } let attributes = initialAttributes; const trackedRevision = (() => { if (!isVNode(vNode)) return null; @@ -1653,35 +1669,6 @@ const buildRunOrRuns = async ( attributes: ParagraphAttributes, docxDocumentInstance?: DocxDocumentInstance ): Promise => { - // Check for OMML equation data attribute - if ( - isVNode(vNode) && - (vNode as VNodeType).properties && - ( - (vNode as VNodeType).properties ?? - failInvariant('Expected value to be defined') - ).attributes && - ( - (vNode as VNodeType).properties ?? - failInvariant('Expected value to be defined') - ).attributes?.['data-equation-omml'] - ) { - const ommlString = - ( - (vNode as VNodeType).properties ?? - failInvariant('Expected value to be defined') - ).attributes?.['data-equation-omml'] ?? - failInvariant('Expected value to be defined'); - try { - // Parse the OMML string and create a fragment - const ommlFragment = fragment().ele(ommlString); - return ommlFragment; - } catch { - // If parsing fails, fall through to normal text handling - console.warn('Failed to parse OMML, falling back to text'); - } - } - if (isVNode(vNode) && (vNode as VNodeType).tagName === 'span') { let runFragments: XMLBuilderType[] = []; const vn = vNode as VNodeType; diff --git a/packages/platejs/src/docx/export/lib/sourcePreservation.spec.ts b/packages/platejs/src/docx/export/lib/sourcePreservation.spec.ts index 201c4d2086..2c5f9c724e 100644 --- a/packages/platejs/src/docx/export/lib/sourcePreservation.spec.ts +++ b/packages/platejs/src/docx/export/lib/sourcePreservation.spec.ts @@ -206,6 +206,50 @@ describe('retained DOCX source', () => { expect(result.diagnostics).toEqual([]); }); + it('rebuilds an unchanged source when the caller passes a stylesheet', async () => { + const sourceBlob = await addPassiveHeader(await createGeneratedSource()); + const imported = await importEligible(sourceBlob); + const editor = createEditor({ initialValue: imported.document }); + const result = await exportDocx(editor, { + projection: 'review', + source: imported.source, + stylesheet: 'span { color: #123456; }', + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + + expect(await readText(zip, 'word/document.xml')).toMatch( + //i + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'source-rewritten', + reason: 'output-options-changed', + }) + ); + }); + + it('rebuilds an unchanged source when the caller passes a font family', async () => { + const sourceBlob = await addPassiveHeader(await createGeneratedSource()); + const imported = await importEligible(sourceBlob); + const editor = createEditor({ initialValue: imported.document }); + const result = await exportDocx(editor, { + fontFamily: 'Arial', + projection: 'review', + source: imported.source, + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + expect(await bytes(result.blob)).not.toEqual(await bytes(sourceBlob)); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'source-rewritten', + reason: 'output-options-changed', + }) + ); + }); + it('keeps safe hyperlinks through exact reuse and the header overlay', async () => { const passive = await addPassiveHeader(await createGeneratedSource()); const zip = await JSZip.loadAsync(await passive.arrayBuffer()); diff --git a/packages/platejs/src/docx/import/lib/importDocx.math.slow.tsx b/packages/platejs/src/docx/import/lib/importDocx.math.slow.tsx new file mode 100644 index 0000000000..a12f668a93 --- /dev/null +++ b/packages/platejs/src/docx/import/lib/importDocx.math.slow.tsx @@ -0,0 +1,539 @@ +import JSZip from 'jszip'; +import katex from 'katex'; +import React from 'react'; + +import { createEditor } from '../../../core'; +import { BaseIndentPlugin } from '../../../features/indent'; +import { BaseListPlugin } from '../../../features/list'; +import { BaseParagraphPlugin } from '../../../lib'; +import { BaseEquationPlugin, BaseInlineEquationPlugin } from '../../../math'; +import { exportDocx } from '../../export/lib/exportDocx'; +import { importDocx } from './importDocx'; + +const plugins = [ + BaseParagraphPlugin, + BaseEquationPlugin, + BaseInlineEquationPlugin, +]; + +const M = + 'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"'; +const W = + 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; +const run = (text: string, properties = '') => + `${properties ? `${properties}` : ''}${text}`; + +const wordFile = async ( + replacements: Readonly>, + comments: readonly string[] = [] +) => { + const editor = createEditor({ + initialValue: Object.keys(replacements).map((text) => ({ + children: [{ text }], + type: 'paragraph', + })), + plugins, + }); + const result = await exportDocx(editor, { + comments: comments.map((text, index) => ({ + author: { name: 'Ada' }, + body: [{ children: [{ text }], type: 'paragraph' }], + createdAt: '2026-10-10T00:00:00.000Z', + durableId: null, + id: `comment-${index}`, + parentId: null, + resolved: null, + target: { + range: { + anchor: { offset: 0, path: [0, 0] }, + focus: { offset: 1, path: [0, 0] }, + }, + }, + })), + projection: 'proposed', + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + + for (const part of ['word/document.xml', 'word/comments.xml']) { + let xml = await zip.file(part)?.async('string'); + + if (!xml) continue; + for (const [placeholder, math] of Object.entries(replacements)) { + xml = xml.replace( + new RegExp( + `(?:(?!)[\\s\\S])*?${placeholder}\\s*` + ), + math + ); + } + zip.file(part, xml); + } + + return zip.generateAsync({ type: 'arraybuffer' }); +}; + +const commentedWordFile = async (rewrite: (xml: string) => string) => { + const editor = createEditor({ + initialValue: [{ children: [{ text: 'PLACE' }], type: 'paragraph' }], + plugins, + }); + const result = await exportDocx(editor, { + comments: [ + { + author: { name: 'Ada' }, + body: [{ children: [{ text: 'Note' }], type: 'paragraph' }], + createdAt: '2026-10-10T00:00:00.000Z', + durableId: null, + id: 'comment-0', + parentId: null, + resolved: null, + target: { + range: { + anchor: { offset: 0, path: [0, 0] }, + focus: { offset: 5, path: [0, 0] }, + }, + }, + }, + ], + projection: 'proposed', + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + const xml = await zip.file('word/document.xml')!.async('string'); + + zip.file('word/document.xml', rewrite(xml)); + + return zip.generateAsync({ type: 'arraybuffer' }); +}; + +const commentedRun = /(?:(?!\s*<\/r>/; + +const roundTrip = async ( + latex: string, + type: 'equation' | 'inlineEquation' +) => { + const editor = createEditor({ + initialValue: + type === 'equation' + ? [{ children: [{ text: '' }], latex, type }] + : [ + { + children: [ + { text: 'a ' }, + { children: [{ text: '' }], latex, type }, + ], + type: 'paragraph', + }, + ], + plugins, + }); + const exported = await exportDocx(editor, { projection: 'proposed' }); + + if (!exported.ok) throw new Error(JSON.stringify(exported.diagnostics)); + const imported = await importDocx(await exported.blob.arrayBuffer(), { + plugins, + }); + + if (!imported.ok) throw new Error(JSON.stringify(imported.diagnostics)); + + return equations(imported.document.children) as Array<{ + latex: string; + type: string; + }>; +}; + +const meaningful = new Set([ + 'accent', + 'accentunder', + 'linethickness', + 'mathvariant', + 'notation', + 'width', +]); + +const presentation = (tex: string) => + katex + .renderToString(tex, { output: 'mathml', throwOnError: true }) + .replace(//, '') + .replaceAll(/<\/?mrow>/g, '') + .replaceAll( + /<(\w+)((?:\s[^>]*)?)>/g, + (_tag, name: string, rest: string) => { + const kept = [...rest.matchAll(/(\w+)="([^"]*)"/g)] + .filter(([, attribute]) => meaningful.has(attribute)) + .map(([attribute]) => ` ${attribute}`); + + return `<${name}${kept.join('')}>`; + } + ); + +const equations = (value: readonly unknown[]): unknown[] => + value.flatMap((node) => { + const element = node as { + children?: unknown[]; + latex?: string; + type?: string; + }; + + if (element.type === 'equation' || element.type === 'inlineEquation') { + return [{ latex: element.latex, type: element.type }]; + } + + return equations(element.children ?? []); + }); + +describe('importDocx Word math', () => { + it('imports Word math as equation nodes whose TeX renders the same', async () => { + const file = await wordFile({ + BLOCKMATH: `${run('a')}${run('b')}`, + INLINEMATH: `${run('α')}${run('≤')}${run('sin', '')}${run('x')}${run('{')}${run('a')}${run('}')}`, + TEXTMATH: `${run('\\text{50\\% off}', '')}`, + }); + const result = await importDocx(file, { plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const found = equations(result.document.children) as Array<{ + latex: string; + type: string; + }>; + + expect(found.map(({ type }) => type)).toEqual([ + 'equation', + 'inlineEquation', + 'inlineEquation', + ]); + expect(presentation(found[0].latex)).toBe(presentation('\\frac{a}{b}')); + expect(presentation(found[1].latex)).toBe( + presentation('\\alpha \\le \\sin x \\{a\\}') + ); + expect(found[2].latex).toBe('\\text{50\\% off}'); + }); + + it('keeps the bold of a bold digit', async () => { + const file = await wordFile({ + BOLDMATH: `${run('1', '')}`, + }); + const result = await importDocx(file, { plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const [found] = equations(result.document.children) as Array<{ + latex: string; + }>; + + expect( + katex.renderToString(found.latex, { + output: 'mathml', + throwOnError: true, + }) + ).toContain('mathvariant="bold"'); + }); + + it('exports imported Word math as Word math again', async () => { + const file = await wordFile({ + BLOCKMATH: `${run('a')}${run('b')}`, + }); + const imported = await importDocx(file, { plugins }); + + if (!imported.ok) throw new Error(JSON.stringify(imported.diagnostics)); + const editor = createEditor({ + initialValue: imported.document.children, + plugins, + }); + const result = await exportDocx(editor, { + projection: 'proposed', + stylesheet: 'p { color: #123456; }', + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()); + + expect(await zip.file('word/document.xml')!.async('string')).toContain( + '' + ); + }); + + it('keeps Word math in a comment body as its TeX', async () => { + const file = await wordFile({ BODYTEXT: 'Body' }, [ + 'COMMENTMATH', + ]); + const zip = await JSZip.loadAsync(file); + const commentsXml = await zip.file('word/comments.xml')!.async('string'); + const comments = commentsXml.replace( + /(?:(?!)[\s\S])*?COMMENTMATH<\/t>\s*<\/r>/, + `${run('a')}${run('b')}` + ); + + zip.file('word/comments.xml', comments); + const result = await importDocx( + await zip.generateAsync({ type: 'arraybuffer' }), + { plugins } + ); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(JSON.stringify(result.comments)).toContain('\\\\frac{a}{b}'); + }); + + it('drops deleted runs inside Word math and reports the flattened revision', async () => { + const file = await wordFile({ + REVISIONMATH: `${run('x')}${run('y')}${run('z')}`, + }); + const result = await importDocx(file, { lossPolicy: 'allow', plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(equations(result.document.children)).toEqual([ + { latex: 'xz', type: 'inlineEquation' }, + ]); + expect( + result.diagnostics.some( + (diagnostic) => + diagnostic.code === 'unsupported-content' && + diagnostic.feature === 'tracked-revision' + ) + ).toBe(true); + }); + + it('ends a named delimiter before the next letter', async () => { + for (const latex of [ + '\\left\\langle x\\right\\rangle', + '\\left\\lceil x\\right\\rceil y', + ]) { + const [found] = await roundTrip(latex, 'equation'); + + expect(presentation(found.latex)).toBe(presentation(latex)); + } + }); + + it('reads the bar delimiters KaTeX draws', async () => { + for (const latex of ['\\left| x \\right|', '\\left\\Vert x\\right\\Vert']) { + const [found] = await roundTrip(latex, 'equation'); + + expect(presentation(found.latex)).toBe(presentation(latex)); + } + }); + + it('reads every other delimiter KaTeX draws', async () => { + for (const latex of [ + '\\left\\backslash x\\right/', + '\\left\\uparrow x\\right\\downarrow', + '\\left\\Uparrow x\\right\\Downarrow', + '\\left\\updownarrow x\\right\\Updownarrow', + '\\left\\lgroup x\\right\\rgroup', + '\\left\\lmoustache x\\right\\rmoustache', + '\\left< x \\right>', + ]) { + const [found] = await roundTrip(latex, 'equation'); + + expect(presentation(found.latex)).toBe(presentation(latex)); + } + }); + + it('keeps a delimiter TeX cannot stretch as its character', async () => { + const file = await wordFile({ + ANGLEMATH: `${run('x')}`, + }); + const result = await importDocx(file, { plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const [found] = equations(result.document.children) as Array<{ + latex: string; + }>; + + expect(() => + katex.renderToString(found.latex, { throwOnError: true }) + ).not.toThrow(); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: 'converter-message' }) + ); + }); + + it('keeps a trailing no-break space as TeX', async () => { + const file = await wordFile({ + SPACEMATH: `x `, + }); + const result = await importDocx(file, { plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const [found] = equations(result.document.children) as Array<{ + latex: string; + }>; + + expect(() => + katex.renderToString(found.latex, { throwOnError: true }) + ).not.toThrow(); + }); + + it('reads the exporter TeX fallback back exactly', async () => { + expect(await roundTrip('\\raisebox{1em}{x}\\ ', 'equation')).toEqual([ + { latex: '\\raisebox{1em}{x}\\ ', type: 'equation' }, + ]); + expect(await roundTrip('\\frac{', 'inlineEquation')).toEqual([ + { latex: '\\frac{', type: 'inlineEquation' }, + ]); + }); + + it('keeps a comment inside an equation on the equation', async () => { + const file = await commentedWordFile((xml) => + xml.replace( + /[\s\S]*?<\/w:r>/, + `${run('x')}` + ) + ); + const result = await importDocx(file, { plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(result.comments[0].target).not.toBeNull(); + }); + + it('keeps a comment on a display equation by importing it inline', async () => { + const file = await commentedWordFile((xml) => + xml.replace( + commentedRun, + `${run('x')}` + ) + ); + const result = await importDocx(file, { plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(equations(result.document.children)).toEqual([ + { latex: 'x', type: 'inlineEquation' }, + ]); + expect(result.comments[0].target).not.toBeNull(); + }); + + it('keeps an inserted display equation', async () => { + const file = await wordFile({ + INSERTEDMATH: `${run('x')}`, + }); + const result = await importDocx(file, { lossPolicy: 'allow', plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(equations(result.document.children)).toEqual([ + { latex: 'x', type: 'inlineEquation' }, + ]); + }); + + it('keeps a list item that holds only a display equation in its list', async () => { + const listPlugins = [ + ...plugins, + BaseIndentPlugin, + BaseListPlugin.configure({ + slots: { + wrapNodeChildren: ({ element }) => + element.listType + ? ({ children }) => ( +
      +
    1. {children}
    2. +
    + ) + : undefined, + }, + }), + ]; + const editor = createEditor({ + initialValue: [ + { + children: [{ text: 'LISTMATH' }], + indent: 1, + listType: 'numbered', + type: 'paragraph', + }, + { + children: [{ text: 'Second' }], + indent: 1, + listType: 'numbered', + type: 'paragraph', + }, + ], + plugins: listPlugins, + }); + const exported = await exportDocx(editor, { projection: 'proposed' }); + + if (!exported.ok) throw new Error(JSON.stringify(exported.diagnostics)); + const zip = await JSZip.loadAsync(await exported.blob.arrayBuffer()); + const xml = (await zip.file('word/document.xml')?.async('string')) ?? ''; + + zip.file( + 'word/document.xml', + xml.replace( + /(?:(?!)[\s\S])*?LISTMATH<\/w:t>\s*<\/w:r>/, + `${run('x')}` + ) + ); + const result = await importDocx( + await zip.generateAsync({ type: 'arraybuffer' }), + { plugins: listPlugins } + ); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(result.document.children[0]).toMatchObject({ + indent: 1, + listType: 'numbered', + }); + expect(equations(result.document.children)).toEqual([ + { latex: 'x', type: 'inlineEquation' }, + ]); + }); + + it('keeps a comment on an equation read as TeX text', async () => { + const file = await commentedWordFile((xml) => + xml.replace( + /[\s\S]*?<\/w:r>/, + `a ${run('x')} b` + ) + ); + const result = await importDocx(file, { + plugins: [BaseParagraphPlugin, BaseEquationPlugin], + }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const range = result.comments[0].target?.range; + const [paragraph] = result.document.children as ReadonlyArray<{ + children: ReadonlyArray<{ text: string }>; + }>; + + expect(range?.anchor.path).toEqual([0, 0]); + expect(range?.focus.path).toEqual([0, 0]); + expect( + paragraph.children[0].text.slice( + range?.anchor.offset, + range?.focus.offset + ) + ).toBe('x'); + }); + + it('reports a tracked formatting change inside an equation', async () => { + const file = await wordFile({ + FORMATMATH: `x`, + }); + const result = await importDocx(file, { lossPolicy: 'allow', plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported-content', + feature: 'tracked-revision', + }) + ); + }); + + it('drops an equation whose runs are all deleted', async () => { + const file = await wordFile({ + GONEMATH: `${run('y')}`, + }); + const result = await importDocx(file, { lossPolicy: 'allow', plugins }); + + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + + expect(equations(result.document.children)).toEqual([]); + }); +}); diff --git a/packages/platejs/src/docx/import/lib/importDocx.ts b/packages/platejs/src/docx/import/lib/importDocx.ts index a06f13e120..9051df41e3 100644 --- a/packages/platejs/src/docx/import/lib/importDocx.ts +++ b/packages/platejs/src/docx/import/lib/importDocx.ts @@ -57,6 +57,7 @@ import { type DocxListItem, readDocxNumbering, } from './numbering/readDocxNumbering'; +import { instrumentWordMath, materializeEquations } from './wordMath'; export { DocxSource } from '../../internal/source'; export type { @@ -156,6 +157,7 @@ type DocxImportTarget = Readonly<{ document: EditorDocumentValue; repairs: readonly DocxSchemaRepairDiagnostic[]; }>; + hasElement: (type: string) => boolean; markerNonce: string; schemaIdentity: EditorSchemaIdentity; toHtml: typeof mammoth.convertToHtml; @@ -287,6 +289,7 @@ const compileDocxImportTarget = ( repairs: Object.freeze(report.repairs.map(repairDiagnostic)), }); }, + hasElement: (type) => editorSchema.element(type) !== null, markerNonce: createMarkerNonce(), schemaIdentity: editorSchema.identity(), toHtml, @@ -441,6 +444,11 @@ const createMarkerCodec = (documentXml: string, markerNonce: string) => { ); return { + equation: Object.freeze({ + block: `${prefix}equation-block${suffix}`, + end: `${prefix}equation-end${suffix}`, + inline: `${prefix}equation-inline${suffix}`, + }), listMarkers: (text: string) => text.match(listPattern) ?? [], marker, parse( @@ -1327,7 +1335,15 @@ const importProjection = async ( nodes = target.decodeHtml(wrapper, (loss) => losses.push(loss)); } if (nodes?.every((node) => ElementApi.isElement(node))) { - bodyById.set(id, [...nodes]); + bodyById.set( + id, + rootValue( + materializeEquations(nodes, codec.equation, { + block: false, + inline: false, + }) + ) + ); diagnostics.push( ...losses.map((loss) => mappingLossDiagnostic(loss, 'comment')) ); @@ -1364,7 +1380,13 @@ const importProjection = async ( if (!nodes) throw new Error('DOCX HTML could not be decoded.'); - return { bodyById, nodes }; + return { + bodyById, + nodes: materializeEquations(nodes, codec.equation, { + block: target.hasElement('equation'), + inline: target.hasElement('inlineEquation'), + }), + }; }; const unsupportedPackageDiagnostics = ( @@ -1491,6 +1513,32 @@ const importBoundedDocx = async ( const documentXml = target.dom.decodeUtf8(source); const codec = createMarkerCodec(documentXml, target.markerNonce); const document = target.dom.parseXml(documentXml, 'word/document.xml'); + const rewritten = new Map(); + const commentsSource = pkg.entries.get('word/comments.xml'); + + instrumentWordMath( + document, + codec.equation, + diagnostics, + 'word/document.xml' + ); + if (commentsSource) { + const commentsDocument = target.dom.parseXml( + target.dom.decodeUtf8(commentsSource), + 'word/comments.xml' + ); + + instrumentWordMath( + commentsDocument, + codec.equation, + diagnostics, + 'word/comments.xml' + ); + rewritten.set( + 'word/comments.xml', + target.dom.serializeXml(commentsDocument) + ); + } const revisions = collectDocxRevisions(document, limits, diagnostics); const comments = commentMetadata(target.dom, pkg.entries, limits); const lists = instrumentNumbering(target.dom, pkg.entries, document, codec); @@ -1499,9 +1547,8 @@ const importBoundedDocx = async ( instrumentContentRevisions(document, codec, diagnostics); instrumentComments(document, codec); throwIfDocxAborted(signal, target.dom.abortError); - const normalized = await pkg.toArrayBuffer( - new Map([['word/document.xml', target.dom.serializeXml(document)]]) - ); + rewritten.set('word/document.xml', target.dom.serializeXml(document)); + const normalized = await pkg.toArrayBuffer(rewritten); throwIfDocxAborted(signal, target.dom.abortError); let projection: Awaited>; diff --git a/packages/platejs/src/docx/import/lib/wordMath.ts b/packages/platejs/src/docx/import/lib/wordMath.ts new file mode 100644 index 0000000000..4197ac155a --- /dev/null +++ b/packages/platejs/src/docx/import/lib/wordMath.ts @@ -0,0 +1,551 @@ +import { type Descendant, TextApi } from '../../../core'; +import type { DocxDiagnostic } from '../../internal/types'; +import { + WORD_MATH_ACCENTS, + WORD_MATH_VARIANTS, +} from '../../internal/wordMathVocabulary'; + +const MATH_NAMESPACE = + 'http://schemas.openxmlformats.org/officeDocument/2006/math'; +const WORD_NAMESPACE = + 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace'; + +type EquationMarkers = Readonly<{ + block: string; + end: string; + inline: string; +}>; + +const elements = (element: Element) => Array.from(element.children); + +const descendants = (element: Element): Element[] => + elements(element).flatMap((child) => [child, ...descendants(child)]); + +const isMath = (element: Element, name: string) => + element.namespaceURI === MATH_NAMESPACE && element.localName === name; + +const isWord = (element: Element, name: string) => + element.namespaceURI === WORD_NAMESPACE && element.localName === name; + +const childNamed = (element: Element, name: string) => + elements(element).find((child) => isMath(child, name)); + +const mathValue = (element: Element | undefined) => + element?.getAttributeNS(MATH_NAMESPACE, 'val') ?? + element?.getAttribute('m:val') ?? + undefined; + +const propertyValue = (element: Element, property: string) => { + const properties = childNamed(element, `${element.localName}Pr`); + + return mathValue(properties && childNamed(properties, property)); +}; + +const runText = (element: Element) => + descendants(element) + .filter((child) => child.localName === 't') + .map((text) => text.textContent ?? '') + .join(''); + +const MATH_ESCAPES: Readonly> = { + '\u00A0': '\\ ', + '\u2003': '\\quad ', + '\u2009': '\\,', + '#': '\\#', + $: '\\$', + '%': '\\%', + '&': '\\&', + '\\': '\\backslash ', + '^': '\\char"5E ', + _: '\\_', + '{': '\\{', + '}': '\\}', + '~': '\\char"7E ', +}; + +const TEXT_ESCAPES: Readonly> = { + ...MATH_ESCAPES, + '\u00A0': ' ', + '\\': '\\textbackslash{}', + '^': '\\textasciicircum{}', + '~': '\\textasciitilde{}', +}; + +const escape = (text: string, escapes: Readonly>) => + Array.from(text.replaceAll('\u2061', '')) + .map((char) => escapes[char] ?? char) + .join(''); + +/** Every delimiter KaTeX's \left accepts, as the TeX that draws it. */ +const DELIMITERS: Readonly> = { + '': '.', + '(': '(', + ')': ')', + '/': '/', + '<': '<', + '>': '>', + '[': '[', + '\\': '\\backslash', + ']': ']', + '{': '\\{', + '|': '|', + '}': '\\}', + '‖': '\\|', + '↑': '\\uparrow', + '↓': '\\downarrow', + '↕': '\\updownarrow', + '⇑': '\\Uparrow', + '⇓': '\\Downarrow', + '⇕': '\\Updownarrow', + '∣': '|', + '∥': '\\|', + '⌈': '\\lceil', + '⌉': '\\rceil', + '⌊': '\\lfloor', + '⌋': '\\rfloor', + '⎰': '\\lmoustache', + '⎱': '\\rmoustache', + '⟨': '\\langle', + '⟩': '\\rangle', + '⟮': '\\lgroup', + '⟯': '\\rgroup', +}; + +/** A named delimiter ends with a space, so the letters after it stay apart. */ +const delimiter = (char: string) => { + const tex = DELIMITERS[char]; + + return tex && /[a-z]$/i.test(tex) ? `${tex} ` : tex; +}; + +const LARGE_OPERATORS = new Set('∑∏∐∫∬∭∮⋀⋁⋂⋃⨀⨁⨂⨄⨆'); + +const FUNCTIONS = new Set([ + 'arccos', + 'arcsin', + 'arctan', + 'arg', + 'cos', + 'cosh', + 'cot', + 'coth', + 'csc', + 'deg', + 'det', + 'dim', + 'exp', + 'gcd', + 'hom', + 'inf', + 'ker', + 'lg', + 'lim', + 'liminf', + 'limsup', + 'ln', + 'log', + 'max', + 'min', + 'Pr', + 'sec', + 'sin', + 'sinh', + 'sup', + 'tan', + 'tanh', +]); + +const largeOperator = (element: Element | undefined) => { + const base = element && childNamed(element, 'e'); + const [only] = base ? elements(base) : []; + + return only && isMath(only, 'r') && LARGE_OPERATORS.has(runText(only)) + ? runText(only) + : null; +}; + +/** Word math as TeX; `onUnknown` hears about structure only its text survives. */ +const toTex = ( + element: Element, + onUnknown: (name: string) => void, + inFunctionName = false +): string => { + const join = (node: Element, functionName = inFunctionName) => + elements(node) + .map((child) => toTex(child, onUnknown, functionName)) + .join(''); + const part = (name: string) => { + const child = childNamed(element, name); + + return child ? toTex(child, onUnknown, inFunctionName) : ''; + }; + + if (element.namespaceURI !== MATH_NAMESPACE) { + return isWord(element, 'r') ? escape(runText(element), MATH_ESCAPES) : ''; + } + if (element.localName.endsWith('Pr')) return ''; + switch (element.localName) { + case 'oMathPara': + case 'oMath': + case 'e': + case 'num': + case 'den': + case 'sub': + case 'sup': + case 'deg': + case 'lim': + case 'box': { + return join(element); + } + case 'fName': { + return join(element, true); + } + case 'r': { + const text = runText(element); + const properties = childNamed(element, 'rPr'); + + if (properties && childNamed(properties, 'nor')) { + return `\\text{${escape(text, TEXT_ESCAPES)}}`; + } + const scr = mathValue(properties && childNamed(properties, 'scr')); + const sty = mathValue(properties && childNamed(properties, 'sty')); + + if ((sty === 'p' || inFunctionName) && FUNCTIONS.has(text)) { + return `\\${text} `; + } + // A lone italic letter, or an upright run with no letters, needs no command. + const plain = + sty === 'p' + ? !/\p{L}/u.test(text) + : sty === 'i' && /^\p{L}$/u.test(text); + const variant = + scr || (sty && !plain) + ? (WORD_MATH_VARIANTS.find( + (entry) => + (entry.scr ?? null) === (scr ?? null) && entry.sty === sty + ) ?? WORD_MATH_VARIANTS.find((entry) => scr && entry.scr === scr)) + : undefined; + + return variant + ? `${variant.tex}{${escape(text, MATH_ESCAPES)}}` + : escape(text, MATH_ESCAPES); + } + case 'f': { + const type = propertyValue(element, 'type'); + const parts = `{${part('num')}}{${part('den')}}`; + + if (type === 'noBar') return `\\genfrac{}{}{0pt}{}${parts}`; + if (type === 'lin') return `${part('num')}/${part('den')}`; + + return `\\frac${parts}`; + } + case 'sSup': { + return `{${part('e')}}^{${part('sup')}}`; + } + case 'sSub': { + return `{${part('e')}}_{${part('sub')}}`; + } + case 'sSubSup': { + return `{${part('e')}}_{${part('sub')}}^{${part('sup')}}`; + } + case 'rad': { + const degree = part('deg'); + + return propertyValue(element, 'degHide') === '1' || !degree + ? `\\sqrt{${part('e')}}` + : `\\sqrt[${degree}]{${part('e')}}`; + } + case 'nary': { + const operator = propertyValue(element, 'chr') ?? '∫'; + const sub = propertyValue(element, 'subHide') === '1' ? '' : part('sub'); + const sup = propertyValue(element, 'supHide') === '1' ? '' : part('sup'); + + return `${operator}${sub ? `_{${sub}}` : ''}${sup ? `^{${sup}}` : ''}{${part('e')}}`; + } + case 'd': { + const openChar = propertyValue(element, 'begChr') ?? '('; + const closeChar = propertyValue(element, 'endChr') ?? ')'; + const [open, close] = [openChar, closeChar].map(delimiter); + const separator = escape( + propertyValue(element, 'sepChr') ?? '|', + MATH_ESCAPES + ); + const items = elements(element) + .filter((child) => isMath(child, 'e')) + .map((child) => join(child)); + + if (open === undefined || close === undefined) { + onUnknown('d'); + + return `${escape(openChar, MATH_ESCAPES)}${items.join(separator)}${escape(closeChar, MATH_ESCAPES)}`; + } + + return `\\left${open}${items.join(` ${separator} `)}\\right${close}`; + } + case 'acc': { + const char = propertyValue(element, 'chr') ?? '\u0302'; + const accent = WORD_MATH_ACCENTS.find( + (entry) => entry.combining === char + ); + + if (!accent) onUnknown('acc'); + + return accent ? `${accent.tex}{${part('e')}}` : part('e'); + } + case 'bar': { + return propertyValue(element, 'pos') === 'bot' + ? `\\underline{${part('e')}}` + : `\\overline{${part('e')}}`; + } + case 'limLow': { + const operator = largeOperator(element); + + return operator + ? `${operator}_{${part('lim')}}` + : `\\underset{${part('lim')}}{${part('e')}}`; + } + case 'limUpp': { + const base = childNamed(element, 'e'); + const [lower] = base ? elements(base) : []; + const operator = + lower && isMath(lower, 'limLow') + ? largeOperator(lower) + : largeOperator(element); + + if (operator && lower && isMath(lower, 'limLow')) { + return `${operator}_{${toTex(childNamed(lower, 'lim') ?? lower, onUnknown)}}^{${part('lim')}}`; + } + + return operator + ? `${operator}^{${part('lim')}}` + : `\\overset{${part('lim')}}{${part('e')}}`; + } + case 'func': { + return `${part('fName')}${part('e')}`; + } + case 'borderBox': { + return `\\boxed{${part('e')}}`; + } + default: { + onUnknown(element.localName); + + return escape(runText(element), MATH_ESCAPES); + } + } +}; + +/** An equation of normal-text runs only is TeX the exporter kept verbatim. */ +const verbatimTex = (math: Element) => { + const nodes = descendants(math).filter( + (node) => node.namespaceURI === MATH_NAMESPACE + ); + const runs = nodes.filter((node) => node.localName === 'r'); + const structured = nodes.some( + (node) => + !['oMath', 'r', 'rPr', 'nor', 't', 'ctrlPr'].includes(node.localName) + ); + + return runs.length > 0 && + !structured && + runs.every((run) => { + const properties = childNamed(run, 'rPr'); + + return properties && childNamed(properties, 'nor'); + }) + ? runs.map(runText).join('') + : null; +}; + +const flattenRevisions = (math: Element) => { + let flattened = 0; + + for (const revision of descendants(math).filter( + (node) => node.namespaceURI === WORD_NAMESPACE + )) { + if (!revision.parentNode) continue; + if (['del', 'moveFrom', 'rPrChange'].includes(revision.localName)) { + revision.remove(); + flattened += 1; + } else if (['ins', 'moveTo'].includes(revision.localName)) { + while (revision.firstChild) revision.before(revision.firstChild); + revision.remove(); + flattened += 1; + } + } + + return flattened; +}; + +/** + * Replace each Word equation in a part with one text run holding its TeX + * between equation markers, before Mammoth reads the part and drops its math. + */ +export const instrumentWordMath = ( + document: Document, + markers: EquationMarkers, + diagnostics: DocxDiagnostic[], + part: string +) => { + const equations: Element[] = []; + const pending: Element[] = document.documentElement + ? [document.documentElement] + : []; + + for (let element = pending.pop(); element; element = pending.pop()) { + if (isMath(element, 'oMathPara') || isMath(element, 'oMath')) { + equations.push(element); + } else { + for ( + let child = element.firstElementChild; + child; + child = child.nextElementSibling + ) { + pending.push(child); + } + } + } + let simplified = false; + + for (const math of equations) { + const ends: Element[] = []; + + for (const marker of descendants(math).filter( + (node) => node.namespaceURI === WORD_NAMESPACE + )) { + if (marker.localName === 'commentRangeStart') { + math.before(marker); + } else if ( + marker.localName === 'commentRangeEnd' || + (marker.localName === 'r' && + descendants(marker).some( + (node) => node.localName === 'commentReference' + )) + ) { + ends.push(marker); + } + } + math.after(...ends); + if (flattenRevisions(math) > 0) { + diagnostics.push({ + action: 'replaced', + code: 'unsupported-content', + feature: 'tracked-revision', + message: 'A tracked revision inside an equation was flattened.', + part, + severity: 'warning', + }); + } + const tex = + verbatimTex(math) ?? + toTex(math, () => { + simplified = true; + }); + + if (!tex.trim()) { + math.remove(); + continue; + } + const run = document.createElementNS(WORD_NAMESPACE, 'w:r'); + const text = document.createElementNS(WORD_NAMESPACE, 'w:t'); + + text.setAttributeNS(XML_NAMESPACE, 'xml:space', 'preserve'); + text.textContent = `${math.localName === 'oMathPara' ? markers.block : markers.inline}${tex}${markers.end}`; + run.append(text); + math.replaceWith(run); + } + if (simplified) { + diagnostics.push({ + code: 'converter-message', + message: 'An equation used Word math that imports as its text only.', + severity: 'warning', + }); + } +}; + +type Equation = Readonly<{ block: boolean; tex: string }>; + +const splitEquations = (text: string, markers: EquationMarkers) => { + const pieces: Array = []; + let rest = text; + + for (;;) { + const block = rest.indexOf(markers.block); + const inline = rest.indexOf(markers.inline); + const start = + block === -1 ? inline : inline === -1 ? block : Math.min(block, inline); + + if (start === -1) break; + const marker = start === block ? markers.block : markers.inline; + const end = rest.indexOf(markers.end, start + marker.length); + + if (end === -1) break; + pieces.push(rest.slice(0, start), { + block: marker === markers.block, + tex: rest.slice(start + marker.length, end), + }); + rest = rest.slice(end + markers.end.length); + } + pieces.push(rest); + + return pieces; +}; + +export const materializeEquations = ( + nodes: readonly Descendant[], + markers: EquationMarkers, + kinds: Readonly<{ block: boolean; inline: boolean }> +): Descendant[] => + nodes.flatMap((node): Descendant[] => { + if (TextApi.isText(node)) { + const pieces = splitEquations(node.text, markers); + + // TeX kept as text stays in this leaf, so comment markers beside it keep their offsets. + if (!kinds.inline) { + return [ + { + ...node, + text: pieces + .map((piece) => (typeof piece === 'string' ? piece : piece.tex)) + .join(''), + }, + ]; + } + + return pieces.map((piece) => + typeof piece === 'string' + ? { ...node, text: piece } + : { + children: [{ text: '' }], + latex: piece.tex, + type: 'inlineEquation', + } + ); + } + const [before, equation, after, ...rest] = + node.children.length === 1 && TextApi.isText(node.children[0]) + ? splitEquations(node.children[0].text, markers) + : []; + const keepsLayout = 'listType' in node || 'indent' in node; + + if ( + kinds.block && + !keepsLayout && + rest.length === 0 && + before === '' && + after === '' && + typeof equation === 'object' && + equation.block + ) { + return [ + { children: [{ text: '' }], latex: equation.tex, type: 'equation' }, + ] as Descendant[]; + } + + return [ + { + ...node, + children: materializeEquations(node.children, markers, kinds), + }, + ]; + }); diff --git a/packages/platejs/src/docx/internal/types.ts b/packages/platejs/src/docx/internal/types.ts index a16e18c930..614ab6f954 100644 --- a/packages/platejs/src/docx/internal/types.ts +++ b/packages/platejs/src/docx/internal/types.ts @@ -111,6 +111,15 @@ export type DocxDiagnostic = message: string; severity: 'warning'; }> + | DocxPolicyDiagnostic<{ + code: 'missing-static-presentation'; + message: string; + /** + * Name of the plugin whose element, mark or slot drawing the + * presentation lacked. + */ + plugin: string; + }> | DocxPolicyDiagnostic<{ code: 'lossy-content' | 'resource-omitted'; feature?: string; diff --git a/packages/platejs/src/docx/internal/wordMathVocabulary.ts b/packages/platejs/src/docx/internal/wordMathVocabulary.ts new file mode 100644 index 0000000000..086da1600e --- /dev/null +++ b/packages/platejs/src/docx/internal/wordMathVocabulary.ts @@ -0,0 +1,39 @@ +export const WORD_MATH_VARIANTS: ReadonlyArray< + Readonly<{ + mathvariant: string; + scr?: string; + sty?: string; + tex: string; + }> +> = [ + { mathvariant: 'normal', sty: 'p', tex: '\\mathrm' }, + { mathvariant: 'bold', sty: 'b', tex: '\\mathbf' }, + { mathvariant: 'italic', sty: 'i', tex: '\\mathit' }, + { mathvariant: 'bold-italic', sty: 'bi', tex: '\\boldsymbol' }, + { mathvariant: 'double-struck', scr: 'double-struck', tex: '\\mathbb' }, + { mathvariant: 'script', scr: 'script', tex: '\\mathcal' }, + { mathvariant: 'fraktur', scr: 'fraktur', tex: '\\mathfrak' }, + { mathvariant: 'sans-serif', scr: 'sans-serif', sty: 'p', tex: '\\mathsf' }, + { mathvariant: 'monospace', scr: 'monospace', sty: 'p', tex: '\\mathtt' }, +]; + +/** Accents KaTeX draws over a base, as Word's combining character and TeX. */ +export const WORD_MATH_ACCENTS: ReadonlyArray< + Readonly<{ + chars: readonly string[]; + combining: string; + tex: string; + }> +> = [ + { chars: ['^', 'ˆ'], combining: '\u0302', tex: '\\hat' }, + { chars: ['ˇ'], combining: '\u030C', tex: '\\check' }, + { chars: ['~', '˜'], combining: '\u0303', tex: '\\tilde' }, + { chars: ['ˊ', '´'], combining: '\u0301', tex: '\\acute' }, + { chars: ['ˋ', '`'], combining: '\u0300', tex: '\\grave' }, + { chars: ['˙'], combining: '\u0307', tex: '\\dot' }, + { chars: ['¨'], combining: '\u0308', tex: '\\ddot' }, + { chars: ['ˉ', '¯'], combining: '\u0304', tex: '\\bar' }, + { chars: ['\u20D7', '→'], combining: '\u20D7', tex: '\\vec' }, + { chars: ['˘'], combining: '\u0306', tex: '\\breve' }, + { chars: ['˚'], combining: '\u030A', tex: '\\mathring' }, +]; diff --git a/packages/platejs/src/static/internal/renderStaticHtmlWithOverrides.tsx b/packages/platejs/src/static/internal/renderStaticHtmlWithOverrides.tsx index 0d77da476b..e7b8411f9f 100644 --- a/packages/platejs/src/static/internal/renderStaticHtmlWithOverrides.tsx +++ b/packages/platejs/src/static/internal/renderStaticHtmlWithOverrides.tsx @@ -9,9 +9,9 @@ import type { StaticHtmlResult, } from '../renderStaticHtml'; import { - setStaticComponentOverrides, + bindStaticPresentation, type StaticComponentOverrides, -} from './staticComponentOverrides'; +} from './staticPresentation'; // Each runtime loads the React server build that works there. The browser // build keeps a Node process alive through its MessagePort, Next's webpack @@ -28,11 +28,21 @@ export const renderStaticHtmlWithOverrides = async ( { component: Component = EditorStatic, document = editor.read.value(), + presentation, projection, props = {}, }: RenderStaticHtmlOptions = {}, overrides?: StaticComponentOverrides ): Promise => { + const appearance = { ...props }; + + for (const key of ['document', 'editor'] as const) { + if (appearance[key] !== undefined) { + throw new TypeError( + `Static HTML props cannot include "${key}". Pass the editor to render as the first argument and another document as the document option.` + ); + } + } if (document.meta?.authored !== undefined && projection === undefined) { throw new TypeError( 'Static HTML serialization requires projection when the document contains authored changes.' @@ -47,14 +57,27 @@ export const renderStaticHtmlWithOverrides = async ( document: projected.document, }) as unknown as Editor; - if (overrides) setStaticComponentOverrides(renderEditor, overrides); + const missing = bindStaticPresentation(renderEditor, { + components: overrides, + plugins: presentation, + }); const { renderToStaticMarkup } = await loadStaticRenderer(); const html = renderToStaticMarkup( - React.createElement(Component, { editor: renderEditor, ...props }) + React.createElement(Component, { ...appearance, editor: renderEditor }) ); return Object.freeze({ data: html, - diagnostics: projected.diagnostics, + diagnostics: Object.freeze([ + ...projected.diagnostics, + ...Array.from(missing, (plugin) => + Object.freeze({ + code: 'missing-static-presentation' as const, + message: `Plugin "${plugin}" has no drawing in the presentation, so its content was drawn without it.`, + plugin, + severity: 'warning' as const, + }) + ), + ]), }); }; diff --git a/packages/platejs/src/static/internal/staticComponentOverrides.ts b/packages/platejs/src/static/internal/staticComponentOverrides.ts deleted file mode 100644 index 9401e09131..0000000000 --- a/packages/platejs/src/static/internal/staticComponentOverrides.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type React from 'react'; - -import type { Editor } from '../../lib'; - -export type StaticComponentOverrides = Readonly< - Record | undefined> ->; - -const OVERRIDES = new WeakMap(); - -export const getStaticComponentOverride = (editor: Editor, name: string) => - OVERRIDES.get(editor)?.[name]; - -export const setStaticComponentOverrides = ( - editor: Editor, - overrides: StaticComponentOverrides -) => { - OVERRIDES.set(editor, overrides); -}; diff --git a/packages/platejs/src/static/internal/staticDocumentView.ts b/packages/platejs/src/static/internal/staticDocumentView.ts index a95d959c44..65afe9a81b 100644 --- a/packages/platejs/src/static/internal/staticDocumentView.ts +++ b/packages/platejs/src/static/internal/staticDocumentView.ts @@ -4,6 +4,7 @@ import { readAuthoredView, } from '../../facade'; import type { Editor } from '../../lib'; +import { inheritStaticPresentation } from './staticPresentation'; type RootReader = Readonly<{ authored: unknown; @@ -36,6 +37,7 @@ export const getStaticDocumentView = ( if (!view) { view = createEditorView(editor, { document }); + inheritStaticPresentation(editor, view); byDocument.set(document, view); } @@ -68,6 +70,7 @@ export const getStaticRootView = (reader: Editor, root: string): Editor => { const view = createEditorView(base, { root }) as unknown as Editor; + inheritStaticPresentation(base, view); byRoot.set(root, { authored, readOnly, view }); rootViewBases.set(view, base); diff --git a/packages/platejs/src/static/internal/staticPresentation.ts b/packages/platejs/src/static/internal/staticPresentation.ts new file mode 100644 index 0000000000..d74f42d26e --- /dev/null +++ b/packages/platejs/src/static/internal/staticPresentation.ts @@ -0,0 +1,162 @@ +import type React from 'react'; + +import { getPlateRuntime } from '../../internal/plugin/compilePlateModel'; +import { isEditOnly } from '../../internal/plugin/isEditOnlyDisabled'; +import type { + AnyBasePlugin, + AnyBasePluginPortal, + AnyPluginBase, + BasePluginInput, + Editor, +} from '../../lib'; +import { withPlateFormatCompilation } from '../../lib/editor/withPlite'; + +export type StaticComponentOverrides = Readonly< + Record | undefined> +>; + +type Peers = Readonly>; + +type StaticPresentation = Readonly<{ + components?: StaticComponentOverrides; + missing: Set; + peers?: Peers; +}>; + +const PRESENTATIONS = new WeakMap(); +const COMPILED = new WeakMap< + object, + Readonly<{ peers: Peers; plugins: readonly BasePluginInput[] }> +>(); + +const compilePeers = (plugins: readonly BasePluginInput[]) => { + const compiled = COMPILED.get(plugins); + + if ( + compiled?.plugins.length === plugins.length && + compiled.plugins.every((plugin, index) => plugin === plugins[index]) + ) { + return compiled.peers; + } + + const peers = withPlateFormatCompilation( + { plugins }, + ({ editor }) => getPlateRuntime(editor).plugins + ); + + COMPILED.set(plugins, { peers, plugins: [...plugins] }); + + return peers; +}; + +/** + * Returns the set that rendering fills with the names of plugins whose drawing + * the presentation lacked. + */ +export const bindStaticPresentation = ( + editor: Editor, + { + components, + plugins, + }: Readonly<{ + components?: StaticComponentOverrides; + plugins?: readonly BasePluginInput[]; + }> +): ReadonlySet => { + const missing = new Set(); + + if (components || plugins) { + PRESENTATIONS.set(editor, { + components, + missing, + peers: plugins && compilePeers(plugins), + }); + } + + return missing; +}; + +export const hasStaticPresentation = (editor: Editor) => + !!PRESENTATIONS.get(editor)?.peers; + +export const inheritStaticPresentation = (base: Editor, view: Editor) => { + const presentation = PRESENTATIONS.get(base); + + if (presentation) PRESENTATIONS.set(view, presentation); +}; + +type DrawnPlugin = AnyBasePluginPortal | AnyPluginBase; + +const isComponent = (component: unknown) => + !!component && typeof component !== 'string'; + +const resolveDrawing = ( + editor: Editor, + plugin: DrawnPlugin, + feature: 'render' | 'slots', + read: (plugin: DrawnPlugin) => T | null | undefined, + readInstalled: (plugin: DrawnPlugin) => unknown = read +) => { + const own = read(plugin) ?? undefined; + const presentation = PRESENTATIONS.get(editor); + + if (!presentation?.peers) return own; + if (isEditOnly(true, plugin, feature)) return undefined; + const peer = presentation.peers[plugin.name]; + const drawing = peer ? (read(peer) ?? undefined) : undefined; + + if (drawing !== undefined) return drawing; + if (!isComponent(readInstalled(plugin))) return own; + presentation.missing.add(plugin.name); + + return undefined; +}; + +export const getStaticElementComponent = ( + editor: Editor, + plugin: DrawnPlugin +) => + PRESENTATIONS.get(editor)?.components?.[plugin.name] ?? + resolveDrawing(editor, plugin, 'render', ({ component }) => component); + +export const getStaticMarkComponent = ( + editor: Editor, + plugin: DrawnPlugin, + placement: 'leaf' | 'text' +) => + resolveDrawing(editor, plugin, 'render', ({ component, render }) => + placement === 'text' + ? component + : (render.mark?.leafComponent ?? + (render.mark?.placement === 'text' ? undefined : component)) + ); + +/** + * Static rendering calls the returned slot as a plain function, so a slot in + * any other shape, such as a `wrapNode` wrapper descriptor + * (`{ component, match }`) or a `React.memo` `afterNodeChildren`, is skipped. + * With a presentation, an installed `afterNodeChildren` component, memo + * included, is reported missing when the presentation has no function for it; + * an installed wrapper in another shape is editing UI and is not reported. + */ +export const getStaticSlot = ( + editor: Editor, + plugin: DrawnPlugin, + slot: 'afterNodeChildren' | 'wrapNode' | 'wrapNodeChildren' +) => { + const readFunction = ({ slots }: DrawnPlugin) => { + const drawing = slots[slot]; + + return typeof drawing === 'function' ? drawing : undefined; + }; + + return resolveDrawing( + editor, + plugin, + 'slots', + readFunction, + slot === 'afterNodeChildren' + ? ({ slots }) => slots.afterNodeChildren + : readFunction + ); +}; diff --git a/packages/platejs/src/static/pipeRenderElementStatic.internal.tsx b/packages/platejs/src/static/pipeRenderElementStatic.internal.tsx index 999eb51b56..cb45e08d34 100644 --- a/packages/platejs/src/static/pipeRenderElementStatic.internal.tsx +++ b/packages/platejs/src/static/pipeRenderElementStatic.internal.tsx @@ -9,9 +9,11 @@ import { } from '../internal/plugin/compilePlateModel'; import type { AnyPluginBase, Editor } from '../lib'; import { EditorElement } from './components/plite-nodes'; +import { hasStaticPresentation } from './internal/staticPresentation'; import { type PliteRenderElement, pluginRenderElementStatic, + renderStaticAfterNodeChildren, } from './pluginRenderElementStatic.internal'; import { getRenderNodeStaticProps } from './utils/getRenderNodeStaticProps.internal'; @@ -50,15 +52,17 @@ export const pipeRenderElementStatic = ( {props.children} - {getPlateRuntime(editor).pluginCache.slots.afterNodeChildren.map( - (name) => { - const innerPlugin = (getCompiledPlatePlugin(editor, name) ?? - failInvariant('Expected value to be defined')) as any; - const Component = innerPlugin.slots.afterNodeChildren; + {hasStaticPresentation(editor) + ? renderStaticAfterNodeChildren(editor, ctxProps) + : getPlateRuntime(editor).pluginCache.slots.afterNodeChildren.map( + (name) => { + const innerPlugin = (getCompiledPlatePlugin(editor, name) ?? + failInvariant('Expected value to be defined')) as any; + const Component = innerPlugin.slots.afterNodeChildren; - return ; - } - )} + return ; + } + )} ); }; diff --git a/packages/platejs/src/static/pipeRenderElementStatic.spec.tsx b/packages/platejs/src/static/pipeRenderElementStatic.spec.tsx index d734cce6d9..ec169ab817 100644 --- a/packages/platejs/src/static/pipeRenderElementStatic.spec.tsx +++ b/packages/platejs/src/static/pipeRenderElementStatic.spec.tsx @@ -138,4 +138,32 @@ describe('pipeRenderElementStatic', () => { expect(markup).toContain('Body'); expect(markup).toContain('data-role="root"'); }); + + it('renders a memoized afterNodeChildren component around the default output', () => { + const RootPlugin = definePlugin('rootExtra', { + slots: { + afterNodeChildren: React.memo(() =>