@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.
- package/package.json +30 -41
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +194 -1
- package/src/core/commands/index.ts +33 -8
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +13 -0
- package/src/io/docx-session.ts +672 -2
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +364 -36
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +17 -2
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +400 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +67 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +110 -11
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- 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
|
|
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
|
};
|