@beyondwork/docx-react-component 1.0.42 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Load scheduler — main-thread time-slicing primitive for the staged
3
+ * document-load pipeline.
4
+ *
5
+ * The loader calls `scheduler.yield()` between parse stages so the browser
6
+ * can paint, service input, and run React commits. `scheduleIdle(task)`
7
+ * queues low-priority work (e.g., sub-part hydration, compatibility report)
8
+ * for post-skeleton execution.
9
+ *
10
+ * Backend cascade (first available wins):
11
+ * 1. `globalThis.scheduler.yield()` — native browser API (Chrome 129+, Edge).
12
+ * 2. `MessageChannel.postMessage` — universal DOM fallback, ~0.1ms per yield.
13
+ * 3. `setTimeout(0)` — last-resort fallback.
14
+ * 4. `sync` — SSR / Node test harness. `yield()` resolves immediately;
15
+ * `scheduleIdle` runs inline.
16
+ *
17
+ * The `sync` backend is selected when `typeof document === "undefined"` so
18
+ * existing Node-side tests drive the staged pipeline with byte-identical
19
+ * behavior to the eager pipeline (no real yielding, no idle deferral).
20
+ */
21
+
22
+ export type LoadSchedulerBackend =
23
+ | "scheduler-api"
24
+ | "message-channel"
25
+ | "timeout"
26
+ | "sync";
27
+
28
+ export interface LoadScheduler {
29
+ readonly backend: LoadSchedulerBackend;
30
+ /** Yield to the browser. Resolves on next scheduled task / microtask. */
31
+ yield(): Promise<void>;
32
+ /** Schedule low-priority work for post-skeleton execution. */
33
+ scheduleIdle(task: () => void): void;
34
+ /** Cancel pending idle tasks. Must be called on unmount / dispose. */
35
+ dispose(): void;
36
+ }
37
+
38
+ export interface CreateLoadSchedulerOptions {
39
+ /** Frame deadline in ms. Default 4ms (keeps browser at 60fps). */
40
+ frameDeadlineMs?: number;
41
+ /**
42
+ * Force a specific backend (test-only). When omitted, the scheduler
43
+ * detects the best available backend at construction time.
44
+ */
45
+ backendOverride?: LoadSchedulerBackend;
46
+ }
47
+
48
+ const DEFAULT_FRAME_DEADLINE_MS = 4;
49
+
50
+ /**
51
+ * Returns true when the elapsed time since `lastYieldAt` exceeds the
52
+ * scheduler's frame deadline. Callers use this inside tight loops to decide
53
+ * when to `await scheduler.yield()`.
54
+ */
55
+ export function shouldYield(
56
+ scheduler: LoadScheduler & { readonly frameDeadlineMs?: number },
57
+ lastYieldAt: number,
58
+ ): boolean {
59
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
60
+ const deadline = scheduler.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
61
+ return now - lastYieldAt >= deadline;
62
+ }
63
+
64
+ /**
65
+ * Returns a monotonic timestamp suitable for `shouldYield` comparisons.
66
+ */
67
+ export function nowMs(): number {
68
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
69
+ }
70
+
71
+ interface InternalScheduler extends LoadScheduler {
72
+ readonly frameDeadlineMs: number;
73
+ }
74
+
75
+ export function createLoadScheduler(
76
+ options: CreateLoadSchedulerOptions = {},
77
+ ): LoadScheduler {
78
+ const frameDeadlineMs = options.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
79
+ const backend = options.backendOverride ?? detectBackend();
80
+
81
+ switch (backend) {
82
+ case "scheduler-api":
83
+ return createSchedulerApiBackend(frameDeadlineMs);
84
+ case "message-channel":
85
+ return createMessageChannelBackend(frameDeadlineMs);
86
+ case "timeout":
87
+ return createTimeoutBackend(frameDeadlineMs);
88
+ case "sync":
89
+ return createSyncBackend(frameDeadlineMs);
90
+ }
91
+ }
92
+
93
+ function detectBackend(): LoadSchedulerBackend {
94
+ if (typeof document === "undefined") {
95
+ return "sync";
96
+ }
97
+ const g = globalThis as unknown as {
98
+ scheduler?: { yield?: () => Promise<void> };
99
+ };
100
+ if (typeof g.scheduler?.yield === "function") {
101
+ return "scheduler-api";
102
+ }
103
+ if (typeof MessageChannel !== "undefined") {
104
+ return "message-channel";
105
+ }
106
+ return "timeout";
107
+ }
108
+
109
+ function createSchedulerApiBackend(frameDeadlineMs: number): InternalScheduler {
110
+ const g = globalThis as unknown as {
111
+ scheduler: { yield: () => Promise<void> };
112
+ };
113
+ const pendingIdleHandles = new Set<number>();
114
+ return {
115
+ backend: "scheduler-api",
116
+ frameDeadlineMs,
117
+ yield: () => g.scheduler.yield(),
118
+ scheduleIdle(task) {
119
+ const handle = scheduleIdleCallback(task, pendingIdleHandles);
120
+ pendingIdleHandles.add(handle);
121
+ },
122
+ dispose() {
123
+ disposeIdleHandles(pendingIdleHandles);
124
+ },
125
+ };
126
+ }
127
+
128
+ function createMessageChannelBackend(frameDeadlineMs: number): InternalScheduler {
129
+ const pendingIdleHandles = new Set<number>();
130
+ return {
131
+ backend: "message-channel",
132
+ frameDeadlineMs,
133
+ yield() {
134
+ return new Promise<void>((resolve) => {
135
+ const channel = new MessageChannel();
136
+ channel.port1.onmessage = () => {
137
+ channel.port1.close();
138
+ channel.port2.close();
139
+ resolve();
140
+ };
141
+ channel.port2.postMessage(null);
142
+ });
143
+ },
144
+ scheduleIdle(task) {
145
+ const handle = scheduleIdleCallback(task, pendingIdleHandles);
146
+ pendingIdleHandles.add(handle);
147
+ },
148
+ dispose() {
149
+ disposeIdleHandles(pendingIdleHandles);
150
+ },
151
+ };
152
+ }
153
+
154
+ function createTimeoutBackend(frameDeadlineMs: number): InternalScheduler {
155
+ const pendingIdleHandles = new Set<number>();
156
+ return {
157
+ backend: "timeout",
158
+ frameDeadlineMs,
159
+ yield() {
160
+ return new Promise<void>((resolve) => {
161
+ setTimeout(resolve, 0);
162
+ });
163
+ },
164
+ scheduleIdle(task) {
165
+ const handle = setTimeout(task, 0) as unknown as number;
166
+ pendingIdleHandles.add(handle);
167
+ },
168
+ dispose() {
169
+ for (const handle of pendingIdleHandles) {
170
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
171
+ }
172
+ pendingIdleHandles.clear();
173
+ },
174
+ };
175
+ }
176
+
177
+ function createSyncBackend(frameDeadlineMs: number): InternalScheduler {
178
+ return {
179
+ backend: "sync",
180
+ frameDeadlineMs,
181
+ yield: () => Promise.resolve(),
182
+ scheduleIdle(task) {
183
+ task();
184
+ },
185
+ dispose() {
186
+ /* no-op */
187
+ },
188
+ };
189
+ }
190
+
191
+ type IdleHandle = number;
192
+
193
+ function scheduleIdleCallback(
194
+ task: () => void,
195
+ store: Set<IdleHandle>,
196
+ ): IdleHandle {
197
+ const g = globalThis as unknown as {
198
+ requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number;
199
+ cancelIdleCallback?: (handle: number) => void;
200
+ };
201
+ if (typeof g.requestIdleCallback === "function") {
202
+ const handle = g.requestIdleCallback(
203
+ () => {
204
+ store.delete(handle);
205
+ task();
206
+ },
207
+ { timeout: 50 },
208
+ );
209
+ return handle;
210
+ }
211
+ const handle = setTimeout(() => {
212
+ store.delete(handle as unknown as number);
213
+ task();
214
+ }, 0) as unknown as number;
215
+ return handle;
216
+ }
217
+
218
+ function disposeIdleHandles(store: Set<IdleHandle>): void {
219
+ const g = globalThis as unknown as {
220
+ cancelIdleCallback?: (handle: number) => void;
221
+ };
222
+ for (const handle of store) {
223
+ if (typeof g.cancelIdleCallback === "function") {
224
+ g.cancelIdleCallback(handle);
225
+ } else {
226
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
227
+ }
228
+ }
229
+ store.clear();
230
+ }
@@ -36,6 +36,11 @@ import type {
36
36
  ParsedTableRowNode,
37
37
  } from "../ooxml/parse-main-document.ts";
38
38
  import { classifyFieldInstruction, buildFieldRegistry } from "../ooxml/parse-fields.ts";
39
+ import {
40
+ type LoadScheduler,
41
+ nowMs,
42
+ shouldYield,
43
+ } from "../load-scheduler.ts";
39
44
 
40
45
  export interface NormalizedTextDocument {
41
46
  content: DocumentRootNode;
@@ -115,6 +120,84 @@ export function normalizeParsedTextDocument(
115
120
  };
116
121
  }
117
122
 
123
+ /**
124
+ * Fastload P6: async sibling of `normalizeParsedTextDocument` that yields to
125
+ * the browser every {@link NORMALIZE_YIELD_STRIDE} top-level blocks when
126
+ * {@link shouldYield} fires against the scheduler's frame deadline. Shares
127
+ * the private normalizeBlocks / normalizeParagraph / normalizeInlineChildren
128
+ * helpers with the sync export — only the outermost block walk is duplicated.
129
+ *
130
+ * Byte-equivalent to the sync export on any corpus (fixture parity is asserted
131
+ * in `test/io/normalize-text-async.test.ts` across every F*.docx fixture).
132
+ */
133
+ const NORMALIZE_YIELD_STRIDE = 256;
134
+
135
+ export async function normalizeParsedTextDocumentAsync(
136
+ document: ParsedMainDocument,
137
+ packagePartName = "/word/document.xml",
138
+ scheduler: LoadScheduler,
139
+ options?: { styles?: import("../../model/canonical-document.ts").StylesCatalog },
140
+ ): Promise<NormalizedTextDocument> {
141
+ const state: NormalizationState = {
142
+ nextFragmentIndex: 1,
143
+ nextWarningIndex: 1,
144
+ nextDiagnosticIndex: 1,
145
+ cursor: 0,
146
+ media: {
147
+ items: {},
148
+ },
149
+ preservation: {
150
+ opaqueFragments: {},
151
+ packageParts: {},
152
+ },
153
+ diagnostics: {
154
+ warnings: [],
155
+ errors: [],
156
+ },
157
+ };
158
+
159
+ const children: BlockNode[] = [];
160
+ let previousParagraph = false;
161
+ let lastYieldAt = nowMs();
162
+
163
+ for (let i = 0; i < document.blocks.length; i += 1) {
164
+ const block = document.blocks[i];
165
+ const normalizedBlocks = normalizeBlocks(block, state, packagePartName);
166
+ for (const normalizedBlock of normalizedBlocks) {
167
+ if (previousParagraph && normalizedBlock.type === "paragraph") {
168
+ state.cursor += 1;
169
+ }
170
+ children.push(normalizedBlock);
171
+ previousParagraph = normalizedBlock.type === "paragraph";
172
+ }
173
+ if (
174
+ i > 0 &&
175
+ i % NORMALIZE_YIELD_STRIDE === 0 &&
176
+ shouldYield(scheduler, lastYieldAt)
177
+ ) {
178
+ await scheduler.yield();
179
+ lastYieldAt = nowMs();
180
+ }
181
+ }
182
+
183
+ const content: DocumentRootNode = { type: "doc", children };
184
+
185
+ const styles = options?.styles ?? { paragraphs: {}, characters: {}, tables: {} };
186
+ const fieldRegistry = buildFieldRegistry({ content, styles });
187
+ const hasFields = fieldRegistry.supported.length > 0 || fieldRegistry.preserveOnly.length > 0;
188
+
189
+ return {
190
+ content,
191
+ media: state.media,
192
+ preservation: state.preservation,
193
+ diagnostics: state.diagnostics,
194
+ ...(document.finalSectionProperties !== undefined
195
+ ? { finalSectionProperties: document.finalSectionProperties }
196
+ : {}),
197
+ ...(hasFields ? { fieldRegistry } : {}),
198
+ };
199
+ }
200
+
118
201
  function normalizeBlocks(
119
202
  block: ParsedBlockNode,
120
203
  state: NormalizationState,
@@ -19,7 +19,14 @@ export type ValidatorIssueCode =
19
19
  | "external_field_body_ignored"
20
20
  | "internal_entry_unexpected_storage_ref"
21
21
  | "internal_field_unexpected_storage_ref"
22
- | "unsupported_version";
22
+ | "unsupported_version"
23
+ // Schema 1.2 editor-state codes
24
+ | "editor_state_unknown_namespace"
25
+ | "editor_state_duplicate_content"
26
+ | "editor_state_empty_content"
27
+ | "editor_state_invalid_inline_json"
28
+ | "editor_state_missing_entry_key"
29
+ | "editor_state_invalid_location";
23
30
 
24
31
  export type ValidatorIssueSeverity = "error" | "warning";
25
32
 
@@ -145,6 +152,95 @@ export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
145
152
  }
146
153
  }
147
154
 
155
+ // Schema 1.2: <bw:editorState> checks.
156
+ const editorStateMatch = xml.match(/<bw:editorState\b[^>]*>([\s\S]*?)<\/bw:editorState>/u);
157
+ if (editorStateMatch) {
158
+ const editorStateBody = editorStateMatch[1] ?? "";
159
+ const nsRe = /<bw:namespace\b([^>]*)>([\s\S]*?)<\/bw:namespace>/gu;
160
+ for (const nsMatch of editorStateBody.matchAll(nsRe)) {
161
+ const attrsStr = nsMatch[1] ?? "";
162
+ const nsBody = nsMatch[2] ?? "";
163
+ const attrs = parseAttrs(attrsStr);
164
+ const name = attrs.name ?? "";
165
+ const nsPath = `bw:editorState/bw:namespace[@name='${name}']`;
166
+
167
+ // 1. Unknown namespace name (not in closed set) → warning.
168
+ const knownNames = ["hostAnnotations", "workflowOverlay", "workflowMetadata", "workItems"];
169
+ if (!knownNames.includes(name)) {
170
+ issues.push({
171
+ code: "editor_state_unknown_namespace",
172
+ path: nsPath,
173
+ severity: "warning",
174
+ value: name,
175
+ });
176
+ // Still check structural rules below for forward-compat awareness.
177
+ }
178
+
179
+ // 2. Detect presence of storageRef and inline.
180
+ const hasStorageRef = /<bw:storageRef\b/u.test(nsBody);
181
+ const hasInline = /<bw:inline\b/u.test(nsBody);
182
+
183
+ if (hasStorageRef && hasInline) {
184
+ // Both present → duplicate_content error.
185
+ issues.push({
186
+ code: "editor_state_duplicate_content",
187
+ path: nsPath,
188
+ severity: "error",
189
+ });
190
+ } else if (!hasStorageRef && !hasInline) {
191
+ // Neither present → empty_content error.
192
+ issues.push({
193
+ code: "editor_state_empty_content",
194
+ path: nsPath,
195
+ severity: "error",
196
+ });
197
+ } else if (hasStorageRef) {
198
+ // 3a. storageRef checks.
199
+ const refMatch = nsBody.match(/<bw:storageRef\b([^>]*)\/>/u);
200
+ if (refMatch) {
201
+ const refAttrs = parseAttrs(refMatch[1] ?? "");
202
+ const entryKey = refAttrs.entryKey ?? "";
203
+ const location = refAttrs.location ?? "";
204
+
205
+ if (entryKey === "") {
206
+ issues.push({
207
+ code: "editor_state_missing_entry_key",
208
+ path: `${nsPath}/bw:storageRef`,
209
+ severity: "error",
210
+ });
211
+ }
212
+
213
+ const knownLocations = ["rowstore", "key-only"];
214
+ if (location !== "" && !knownLocations.includes(location)) {
215
+ issues.push({
216
+ code: "editor_state_invalid_location",
217
+ path: `${nsPath}/bw:storageRef/@location`,
218
+ severity: "warning",
219
+ value: location,
220
+ });
221
+ }
222
+ }
223
+ } else if (hasInline) {
224
+ // 3b. inline JSON parse check.
225
+ const inlineMatch = nsBody.match(/<bw:inline\b[^>]*>([\s\S]*?)<\/bw:inline>/u);
226
+ if (inlineMatch) {
227
+ const rawContent = inlineMatch[1] ?? "";
228
+ // Strip CDATA markers (handles split CDATA too).
229
+ const text = rawContent.replace(/<!\[CDATA\[|\]\]>/g, "").trim();
230
+ try {
231
+ JSON.parse(text);
232
+ } catch {
233
+ issues.push({
234
+ code: "editor_state_invalid_inline_json",
235
+ path: `${nsPath}/bw:inline`,
236
+ severity: "error",
237
+ });
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
243
+
148
244
  // Top-level entries (bw:metadata body, outside scopes).
149
245
  // These resolve against overlay-only (no scope context available unless
150
246
  // the entry carries its own `scope="scope:{id}"` attribute, in which case
@@ -12,11 +12,36 @@ import type {
12
12
  WorkflowScope,
13
13
  WorkflowWorkItem,
14
14
  } from "../../api/public-types.ts";
15
+ import type { EditorStateNamespace, EditorStateLocation } from "../../api/editor-state-types.ts";
15
16
  import {
16
17
  validateWorkflowPayloadEnvelope,
17
18
  type ValidatorIssue,
18
19
  } from "./workflow-payload-validator.ts";
19
20
 
21
+ // ---------------------------------------------------------------------------
22
+ // Schema 1.2 editor-state types (edge-of-module shape, channel-free)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface EditorStatePayloadNamespaceEntry {
26
+ namespace: EditorStateNamespace;
27
+ schemaVersion: string;
28
+ /** JSON-serializable blob (CDATA-wrapped on serialize). Exactly one of inline/storageRef. */
29
+ inline?: unknown;
30
+ storageRef?: { location: Exclude<EditorStateLocation, "in-document">; entryKey: string };
31
+ /**
32
+ * Parser-internal flag: set when the `<bw:inline>` CDATA block contained
33
+ * malformed JSON. Hydration surfaces this as `editor_state_part_load_failed`
34
+ * rather than silently dropping the entry.
35
+ */
36
+ malformedInline?: boolean;
37
+ }
38
+
39
+ export interface EditorStatePayload {
40
+ entries: EditorStatePayloadNamespaceEntry[];
41
+ /** Unknown namespaces preserved for round-trip — raw XML fragment per entry, keyed by name. */
42
+ unknownNamespaces?: Array<{ name: string; rawXml: string }>;
43
+ }
44
+
20
45
  // ---------------------------------------------------------------------------
21
46
  // Schema 1.1 parser helpers (fail-closed per spec §8.2)
22
47
  // ---------------------------------------------------------------------------
@@ -62,6 +87,8 @@ type EmbeddedWorkflowPayloadDescriptor = {
62
87
  export interface WorkflowPayloadEnvelope {
63
88
  workflowMetadata: WorkflowMetadataSnapshot;
64
89
  workflowOverlay?: WorkflowOverlay;
90
+ /** Present only when the payload was version 1.2+ and carried a non-empty <bw:editorState> block. */
91
+ editorState?: EditorStatePayload;
65
92
  validatorIssues?: readonly ValidatorIssue[];
66
93
  }
67
94
 
@@ -87,6 +114,8 @@ export function buildWorkflowPayloadParts(input: {
87
114
  sourcePackage: OpcPackage;
88
115
  workflowMetadata: WorkflowMetadataSnapshot | undefined;
89
116
  workflowOverlay?: WorkflowOverlay;
117
+ /** Optional schema 1.2 editor-state block. Omitted when empty. */
118
+ editorState?: EditorStatePayload;
90
119
  documentId: string;
91
120
  createdAt: string;
92
121
  updatedAt: string;
@@ -126,6 +155,7 @@ export function buildWorkflowPayloadParts(input: {
126
155
  producerVersion: input.producerVersion,
127
156
  workflowMetadata,
128
157
  workflowOverlay: input.workflowOverlay,
158
+ editorState: input.editorState,
129
159
  preservedExtensionsXml: getPreservedExtensionsXml(input.sourcePackage, descriptor.payloadPartPath),
130
160
  });
131
161
  const itemPropsXml = buildItemPropsXml(descriptor.itemId);
@@ -238,12 +268,14 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
238
268
  }
239
269
 
240
270
  const validatorIssues = validateWorkflowPayloadEnvelope(xml);
271
+ const editorState = parseEditorStateXml(xml);
241
272
  return {
242
273
  workflowMetadata: {
243
274
  definitions,
244
275
  entries,
245
276
  },
246
277
  workflowOverlay: parseWorkflowOverlay(xml),
278
+ ...(editorState !== undefined ? { editorState } : {}),
247
279
  ...(validatorIssues.length > 0 ? { validatorIssues } : {}),
248
280
  };
249
281
  }
@@ -287,6 +319,136 @@ function needsSchemaV11(input: {
287
319
  return false;
288
320
  }
289
321
 
322
+ /** Returns true when the payload has at least one namespace entry or unknown namespace to emit. */
323
+ function hasNonEmptyEditorState(es: EditorStatePayload | undefined): boolean {
324
+ if (!es) return false;
325
+ return (es.entries.length > 0) || ((es.unknownNamespaces?.length ?? 0) > 0);
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Known namespace names (closed set for round-trip; unknown names are opaque)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ const KNOWN_EDITOR_STATE_NAMESPACES: readonly EditorStateNamespace[] = [
333
+ "hostAnnotations",
334
+ "workflowOverlay",
335
+ "workflowMetadata",
336
+ "workItems",
337
+ ];
338
+
339
+ const KNOWN_STORAGE_LOCATIONS: ReadonlyArray<Exclude<EditorStateLocation, "in-document">> = [
340
+ "rowstore",
341
+ "key-only",
342
+ ];
343
+
344
+ /**
345
+ * Escapes `]]>` inside CDATA content using the standard XML split:
346
+ * `]]>` → `]]]]><![CDATA[>`
347
+ */
348
+ function escapeCdata(text: string): string {
349
+ return text.replace(/\]\]>/g, "]]]]><![CDATA[>");
350
+ }
351
+
352
+ /**
353
+ * Builds the `<bw:editorState>…</bw:editorState>` block.
354
+ * Returns an empty string when both entries and unknownNamespaces are empty.
355
+ */
356
+ export function buildEditorStateXml(payload: EditorStatePayload): string {
357
+ if (!hasNonEmptyEditorState(payload)) {
358
+ return "";
359
+ }
360
+
361
+ const knownLines = payload.entries.map((entry) => {
362
+ const nsOpen = `<bw:namespace name="${escapeXml(entry.namespace)}" schemaVersion="${escapeXml(entry.schemaVersion)}">`;
363
+ let content: string;
364
+ if (entry.storageRef !== undefined) {
365
+ content = `<bw:storageRef location="${escapeXml(entry.storageRef.location)}" entryKey="${escapeXml(entry.storageRef.entryKey)}" />`;
366
+ } else {
367
+ const json = escapeCdata(JSON.stringify(entry.inline));
368
+ content = `<bw:inline><![CDATA[${json}]]></bw:inline>`;
369
+ }
370
+ return `${nsOpen}${content}</bw:namespace>`;
371
+ });
372
+
373
+ const unknownLines = (payload.unknownNamespaces ?? []).map((u) => u.rawXml);
374
+
375
+ const innerXml = [...knownLines, ...unknownLines].join("\n");
376
+ return `<bw:editorState>\n${indentLines(innerXml, 2)}\n</bw:editorState>`;
377
+ }
378
+
379
+ /**
380
+ * Parses the `<bw:editorState>` block from a full workflow payload XML string.
381
+ * Returns `undefined` when no block is present.
382
+ * Malformed JSON is silently skipped (validator flags it separately).
383
+ */
384
+ export function parseEditorStateXml(xml: string): EditorStatePayload | undefined {
385
+ const blockMatch = xml.match(/<bw:editorState\b[^>]*>([\s\S]*?)<\/bw:editorState>/u);
386
+ if (!blockMatch) {
387
+ return undefined;
388
+ }
389
+ const blockBody = blockMatch[1] ?? "";
390
+
391
+ const entries: EditorStatePayloadNamespaceEntry[] = [];
392
+ const unknownNamespaces: Array<{ name: string; rawXml: string }> = [];
393
+
394
+ // Match each <bw:namespace ... > ... </bw:namespace>
395
+ const nsRe = /<bw:namespace\b([^>]*)>([\s\S]*?)<\/bw:namespace>/gu;
396
+ for (const nsMatch of blockBody.matchAll(nsRe)) {
397
+ const attrsStr = nsMatch[1] ?? "";
398
+ const nsBody = nsMatch[2] ?? "";
399
+ const rawXml = nsMatch[0] ?? "";
400
+ const attrs = parseAttributes(attrsStr);
401
+ const name = attrs.name ?? "";
402
+ const schemaVersion = attrs.schemaVersion ?? "";
403
+
404
+ if (!(KNOWN_EDITOR_STATE_NAMESPACES as readonly string[]).includes(name)) {
405
+ unknownNamespaces.push({ name, rawXml });
406
+ continue;
407
+ }
408
+
409
+ const namespace = name as EditorStateNamespace;
410
+
411
+ // Parse <bw:storageRef ... />
412
+ const storageRefMatch = nsBody.match(/<bw:storageRef\b([^>]*)\/>/u);
413
+ if (storageRefMatch) {
414
+ const refAttrs = parseAttributes(storageRefMatch[1] ?? "");
415
+ const location = refAttrs.location as Exclude<EditorStateLocation, "in-document"> | undefined;
416
+ const entryKey = refAttrs.entryKey ?? "";
417
+ entries.push({
418
+ namespace,
419
+ schemaVersion,
420
+ storageRef: { location: location ?? "rowstore", entryKey },
421
+ });
422
+ continue;
423
+ }
424
+
425
+ // Parse <bw:inline>...</bw:inline> — extract CDATA content
426
+ const inlineMatch = nsBody.match(/<bw:inline\b[^>]*>([\s\S]*?)<\/bw:inline>/u);
427
+ if (inlineMatch) {
428
+ const raw = inlineMatch[1] ?? "";
429
+ // Extract CDATA content — handles split CDATA sections from ]]> escaping
430
+ const cdataText = raw.replace(/<!\[CDATA\[|\]\]>/g, "").trim();
431
+ try {
432
+ const parsed = JSON.parse(cdataText) as unknown;
433
+ entries.push({ namespace, schemaVersion, inline: parsed });
434
+ } catch {
435
+ // Malformed JSON: surface to the runtime so the host sees a
436
+ // load-failure event rather than silently losing the entry.
437
+ entries.push({ namespace, schemaVersion, malformedInline: true });
438
+ }
439
+ }
440
+ }
441
+
442
+ if (entries.length === 0 && unknownNamespaces.length === 0) {
443
+ return undefined;
444
+ }
445
+
446
+ return {
447
+ entries,
448
+ ...(unknownNamespaces.length > 0 ? { unknownNamespaces } : {}),
449
+ };
450
+ }
451
+
290
452
  function buildPayloadXml(input: {
291
453
  descriptor: EmbeddedWorkflowPayloadDescriptor;
292
454
  createdAt: string;
@@ -294,9 +456,15 @@ function buildPayloadXml(input: {
294
456
  producerVersion: string;
295
457
  workflowMetadata: WorkflowMetadataSnapshot;
296
458
  workflowOverlay?: WorkflowOverlay;
459
+ editorState?: EditorStatePayload;
297
460
  preservedExtensionsXml: string;
298
461
  }): string {
299
- const schemaVersion = needsSchemaV11(input) ? "1.1" : "1.0";
462
+ const hasEditorState = hasNonEmptyEditorState(input.editorState);
463
+ const schemaVersion = hasEditorState
464
+ ? "1.2"
465
+ : needsSchemaV11(input)
466
+ ? "1.1"
467
+ : "1.0";
300
468
 
301
469
  const definitionEntriesXml = input.workflowMetadata.definitions
302
470
  .map((definition) => [
@@ -351,6 +519,8 @@ function buildPayloadXml(input: {
351
519
  .filter((value) => value.trim().length > 0)
352
520
  .join("\n");
353
521
 
522
+ const editorStateXml = hasEditorState ? buildEditorStateXml(input.editorState!) : "";
523
+
354
524
  return [
355
525
  `<?xml version="1.0" encoding="UTF-8"?>`,
356
526
  `<bw:workflowPayload xmlns:bw="urn:beyondwork:workflow-payload:1" version="${schemaVersion}" payloadId="${escapeXml(input.descriptor.payloadId)}" itemId="${escapeXml(input.descriptor.itemId)}" documentId="${escapeXml(input.descriptor.documentId)}" createdAt="${escapeXml(input.createdAt)}" updatedAt="${escapeXml(input.updatedAt)}">`,
@@ -362,6 +532,7 @@ function buildPayloadXml(input: {
362
532
  definitionEntriesXml ? indentLines(definitionEntriesXml, 4) : "",
363
533
  metadataEntriesXml ? indentLines(metadataEntriesXml, 4) : "",
364
534
  ` </bw:metadata>`,
535
+ editorStateXml,
365
536
  extensionsXml
366
537
  ? ` <bw:extensions>\n${indentLines(extensionsXml, 4)}\n </bw:extensions>`
367
538
  : ` <bw:extensions />`,
@@ -64,7 +64,7 @@ export interface AttachPayloadArgs {
64
64
 
65
65
  export type SendToExternalCallArgs = Omit<
66
66
  RuntimeSendToExternalArgs,
67
- "bridge" | "tamperGate" | "signer" | "role"
67
+ "bridge" | "tamperGate" | "signer" | "role" | "resolver"
68
68
  > & {
69
69
  role?: "author" | "reviewer" | "observer";
70
70
  };