@beyondwork/docx-react-component 1.0.41 → 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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -0,0 +1,97 @@
1
+ import type {
2
+ AuthorKind,
3
+ Participant,
4
+ ParticipantRole,
5
+ ParticipantRoster,
6
+ } from "../../api/participants-types.ts";
7
+ import {
8
+ attrNumber,
9
+ childrenOf,
10
+ parseBwXml,
11
+ renderElement,
12
+ stripNs,
13
+ } from "./bw-xml.ts";
14
+
15
+ const NS_URI = "urn:beyondwork:workflow-payload:1";
16
+
17
+ const ROLE_VOCAB = new Set<ParticipantRole>([
18
+ "author",
19
+ "reviewer",
20
+ "observer",
21
+ ]);
22
+ const KIND_VOCAB = new Set<AuthorKind>(["human", "agent", "system"]);
23
+
24
+ export function buildParticipantsXml(roster: ParticipantRoster): string {
25
+ const rows = roster.entries.map(buildRow).join("");
26
+ return renderElement(
27
+ "bw:participants",
28
+ {
29
+ "xmlns:bw": NS_URI,
30
+ schemaVersion: String(roster.schemaVersion),
31
+ },
32
+ [rows],
33
+ );
34
+ }
35
+
36
+ function buildRow(p: Participant): string {
37
+ return renderElement("bw:participant", {
38
+ userId: p.userId,
39
+ email: p.email,
40
+ displayName: p.displayName,
41
+ collabIdentity: p.collabIdentity,
42
+ authorKind: p.authorKind,
43
+ role: p.role,
44
+ organization: p.organization,
45
+ avatarHref: p.avatarHref,
46
+ });
47
+ }
48
+
49
+ export function parseParticipantsXml(xml: string): ParticipantRoster {
50
+ const root = parseBwXml(xml);
51
+ if (stripNs(root.name) !== "participants") {
52
+ throw new Error(
53
+ `parseParticipantsXml: expected <bw:participants>, got <${root.name}>`,
54
+ );
55
+ }
56
+ const schemaVersion = attrNumber(root.attributes["schemaVersion"]) ?? 1;
57
+ if (schemaVersion !== 1) {
58
+ return { schemaVersion: 1, entries: [] };
59
+ }
60
+
61
+ const entries: Participant[] = [];
62
+ for (const el of childrenOf(root, "participant")) {
63
+ const userId = el.attributes["userId"];
64
+ const email = el.attributes["email"];
65
+ const displayName = el.attributes["displayName"];
66
+ const collabIdentity = el.attributes["collabIdentity"];
67
+ const authorKind = el.attributes["authorKind"] as AuthorKind;
68
+ if (
69
+ !userId ||
70
+ !email ||
71
+ !displayName ||
72
+ !collabIdentity ||
73
+ !KIND_VOCAB.has(authorKind)
74
+ ) {
75
+ continue;
76
+ }
77
+ const row: Participant = {
78
+ userId,
79
+ email: email.toLowerCase(),
80
+ displayName,
81
+ collabIdentity,
82
+ authorKind,
83
+ };
84
+ const role = el.attributes["role"] as ParticipantRole | undefined;
85
+ if (role !== undefined && ROLE_VOCAB.has(role)) row.role = role;
86
+ if (el.attributes["organization"] !== undefined) {
87
+ row.organization = el.attributes["organization"];
88
+ }
89
+ const avatarHref = el.attributes["avatarHref"];
90
+ if (avatarHref !== undefined && avatarHref.startsWith("https://")) {
91
+ row.avatarHref = avatarHref;
92
+ }
93
+ entries.push(row);
94
+ }
95
+
96
+ return { schemaVersion: 1, entries };
97
+ }
@@ -0,0 +1,112 @@
1
+ import { canonicalizePayload } from "./canonicalize-payload.ts";
2
+
3
+ export type SignatureAlgorithm = "hmac-sha256" | "ed25519";
4
+
5
+ export interface PayloadSignature {
6
+ algorithm: SignatureAlgorithm;
7
+ keyId: string;
8
+ signedAt: string;
9
+ canonicalizationProfile: "bw-canon/1";
10
+ value: string; // base64
11
+ }
12
+
13
+ export interface PayloadSigner {
14
+ keyId: string;
15
+ algorithm: SignatureAlgorithm;
16
+ sign(canonicalBytes: Uint8Array): Promise<Uint8Array>;
17
+ }
18
+
19
+ export interface PayloadVerifier {
20
+ verify(
21
+ canonicalBytes: Uint8Array,
22
+ signature: PayloadSignature,
23
+ ): Promise<boolean>;
24
+ }
25
+
26
+ export async function signWorkflowPayloadXml(
27
+ xml: string,
28
+ signer: PayloadSigner,
29
+ now: string = new Date().toISOString(),
30
+ ): Promise<PayloadSignature> {
31
+ const bytes = canonicalizePayload(xml);
32
+ const sig = await signer.sign(bytes);
33
+ return {
34
+ algorithm: signer.algorithm,
35
+ keyId: signer.keyId,
36
+ signedAt: now,
37
+ canonicalizationProfile: "bw-canon/1",
38
+ value: base64Encode(sig),
39
+ };
40
+ }
41
+
42
+ export async function verifyWorkflowPayloadXml(
43
+ xml: string,
44
+ sig: PayloadSignature,
45
+ verifier: PayloadVerifier,
46
+ ): Promise<boolean> {
47
+ if (sig.canonicalizationProfile !== "bw-canon/1") return false;
48
+ const bytes = canonicalizePayload(xml);
49
+ return verifier.verify(bytes, sig);
50
+ }
51
+
52
+ // HMAC-SHA256 helpers ----------------------------------------------------
53
+
54
+ /**
55
+ * Builds a signer + verifier pair backed by WebCrypto HMAC-SHA256.
56
+ * Integration tests use this; production hosts should wire a real key store.
57
+ */
58
+ export async function createHmacSigner(args: {
59
+ keyId: string;
60
+ secret: Uint8Array;
61
+ }): Promise<PayloadSigner> {
62
+ const key = await importHmacKey(args.secret, ["sign"]);
63
+ return {
64
+ keyId: args.keyId,
65
+ algorithm: "hmac-sha256",
66
+ async sign(bytes) {
67
+ const out = await globalThis.crypto.subtle.sign("HMAC", key, bytes as BufferSource);
68
+ return new Uint8Array(out);
69
+ },
70
+ };
71
+ }
72
+
73
+ export async function createHmacVerifier(
74
+ secret: Uint8Array,
75
+ ): Promise<PayloadVerifier> {
76
+ const key = await importHmacKey(secret, ["verify"]);
77
+ return {
78
+ async verify(bytes, sig) {
79
+ if (sig.algorithm !== "hmac-sha256") return false;
80
+ const raw = base64Decode(sig.value);
81
+ return globalThis.crypto.subtle.verify("HMAC", key, raw as BufferSource, bytes as BufferSource);
82
+ },
83
+ };
84
+ }
85
+
86
+ async function importHmacKey(
87
+ secret: Uint8Array,
88
+ usages: KeyUsage[],
89
+ ): Promise<CryptoKey> {
90
+ return globalThis.crypto.subtle.importKey(
91
+ "raw",
92
+ secret as BufferSource,
93
+ { name: "HMAC", hash: "SHA-256" },
94
+ false,
95
+ usages,
96
+ );
97
+ }
98
+
99
+ // base64 ------------------------------------------------------------------
100
+
101
+ function base64Encode(bytes: Uint8Array): string {
102
+ let binary = "";
103
+ for (const byte of bytes) binary += String.fromCharCode(byte);
104
+ return btoa(binary);
105
+ }
106
+
107
+ function base64Decode(b64: string): Uint8Array {
108
+ const binary = atob(b64);
109
+ const out = new Uint8Array(binary.length);
110
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
111
+ return out;
112
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Local-path validator for bw:workflowPayload 1.0 / 1.1.
3
+ *
4
+ * This is a structural sanity check that runs inline on every import.
5
+ * It does NOT replace the Railway OOXML validator — that one checks
6
+ * package-level concerns (part types, relationship sanity, Word-spec
7
+ * compliance). This validator catches malformed bw:workflowPayload
8
+ * content that the parser would silently strip. Results surface to
9
+ * callers so tooling can log, display, or assert on them.
10
+ *
11
+ * Spec: docs/reference/bw-schema-spec.md §8.2.
12
+ */
13
+
14
+ export type ValidatorIssueCode =
15
+ | "unknown_enum_value"
16
+ | "external_entry_missing_ref"
17
+ | "external_entry_body_ignored"
18
+ | "external_field_missing_ref"
19
+ | "external_field_body_ignored"
20
+ | "internal_entry_unexpected_storage_ref"
21
+ | "internal_field_unexpected_storage_ref"
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";
30
+
31
+ export type ValidatorIssueSeverity = "error" | "warning";
32
+
33
+ export interface ValidatorIssue {
34
+ code: ValidatorIssueCode;
35
+ path: string; // e.g. "bw:entry[@key='e1']"
36
+ severity: ValidatorIssueSeverity;
37
+ value?: string; // the invalid value when relevant
38
+ detail?: string; // optional extra context
39
+ }
40
+
41
+ const OVERLAY_PERSISTENCE_VALUES = ["internal", "external"] as const;
42
+ const SCOPE_PERSISTENCE_VALUES = ["internal", "external", "inherit"] as const;
43
+
44
+ export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
45
+ const issues: ValidatorIssue[] = [];
46
+
47
+ // Root version check.
48
+ const versionMatch = xml.match(/<bw:workflowPayload\b[^>]*\sversion="([^"]+)"/u);
49
+ if (versionMatch && !/^1\.[0-9]+$/.test(versionMatch[1])) {
50
+ issues.push({
51
+ code: "unsupported_version",
52
+ path: "bw:workflowPayload/@version",
53
+ value: versionMatch[1],
54
+ severity: "warning",
55
+ });
56
+ }
57
+
58
+ // Overlay-level metadataPersistence.
59
+ const overlayMatch = xml.match(/<bw:workflowOverlay\b([^>]*)>/u);
60
+ let overlayEnum: "internal" | "external" | undefined;
61
+ if (overlayMatch) {
62
+ const attrs = parseAttrs(overlayMatch[1]);
63
+ if (attrs.metadataPersistence !== undefined) {
64
+ if (
65
+ !(OVERLAY_PERSISTENCE_VALUES as readonly string[]).includes(
66
+ attrs.metadataPersistence,
67
+ )
68
+ ) {
69
+ issues.push({
70
+ code: "unknown_enum_value",
71
+ path: "bw:workflowOverlay/@metadataPersistence",
72
+ value: attrs.metadataPersistence,
73
+ severity: "warning",
74
+ });
75
+ } else {
76
+ overlayEnum = attrs.metadataPersistence as "internal" | "external";
77
+ }
78
+ }
79
+ }
80
+
81
+ // Each scope + its fields.
82
+ for (const scope of findAll(xml, /<bw:scope\b([^>]*?)>([\s\S]*?)<\/bw:scope>/gu)) {
83
+ const scopeAttrs = parseAttrs(scope.attrs);
84
+ const scopeId = scopeAttrs.id ?? "?";
85
+ let scopePersistence: string | undefined = scopeAttrs.metadataPersistence;
86
+ if (scopePersistence !== undefined) {
87
+ if (
88
+ !(SCOPE_PERSISTENCE_VALUES as readonly string[]).includes(scopePersistence)
89
+ ) {
90
+ issues.push({
91
+ code: "unknown_enum_value",
92
+ path: `bw:scope[@id='${scopeId}']/@metadataPersistence`,
93
+ value: scopePersistence,
94
+ severity: "warning",
95
+ });
96
+ scopePersistence = undefined;
97
+ }
98
+ }
99
+
100
+ // <bw:field>, two forms: self-closing and with body.
101
+ for (const field of findFields(scope.body)) {
102
+ const fieldAttrs = parseAttrs(field.attrs);
103
+ const fieldKey = fieldAttrs.key ?? "?";
104
+
105
+ let fieldPersistence: string | undefined = fieldAttrs.metadataPersistence;
106
+ if (
107
+ fieldPersistence !== undefined &&
108
+ !(SCOPE_PERSISTENCE_VALUES as readonly string[]).includes(fieldPersistence)
109
+ ) {
110
+ issues.push({
111
+ code: "unknown_enum_value",
112
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']/@metadataPersistence`,
113
+ value: fieldPersistence,
114
+ severity: "warning",
115
+ });
116
+ fieldPersistence = undefined;
117
+ }
118
+
119
+ const effective = resolveEffective({
120
+ overlay: overlayEnum,
121
+ scope: scopePersistence,
122
+ field: fieldPersistence,
123
+ });
124
+ const hasRef = Boolean(fieldAttrs.storageRef);
125
+ const hasBody = field.body.trim().length > 0;
126
+
127
+ if (effective === "external") {
128
+ if (!hasRef) {
129
+ issues.push({
130
+ code: "external_field_missing_ref",
131
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']`,
132
+ severity: "error",
133
+ });
134
+ }
135
+ if (hasBody) {
136
+ issues.push({
137
+ code: "external_field_body_ignored",
138
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']`,
139
+ severity: "warning",
140
+ });
141
+ }
142
+ } else if (effective === "internal" && hasRef) {
143
+ // Internal-mode fields carry their value inline. A `storageRef` on
144
+ // an internal field is a stale pointer from a persistence-mode
145
+ // downgrade and will confuse hosts that branch on its presence.
146
+ issues.push({
147
+ code: "internal_field_unexpected_storage_ref",
148
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']`,
149
+ severity: "warning",
150
+ });
151
+ }
152
+ }
153
+ }
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
+
244
+ // Top-level entries (bw:metadata body, outside scopes).
245
+ // These resolve against overlay-only (no scope context available unless
246
+ // the entry carries its own `scope="scope:{id}"` attribute, in which case
247
+ // a matching scope's override would apply — we keep this simple and just
248
+ // use the overlay default for entries).
249
+ for (const entry of findEntries(xml)) {
250
+ const entryAttrs = parseAttrs(entry.attrs);
251
+ const entryKey = entryAttrs.key ?? "?";
252
+
253
+ // Definition rows (key="definition.*") are schema descriptors, not data
254
+ // entries. Their body is always the human-readable label (per §4.1) and
255
+ // they are never subject to external-persistence rules (§4.3 applies only
256
+ // to data rows, §4.2). Skip persistence checks for them.
257
+ if (entryKey.startsWith("definition.")) {
258
+ continue;
259
+ }
260
+
261
+ let entryPersistence: string | undefined = entryAttrs.metadataPersistence;
262
+ if (
263
+ entryPersistence !== undefined &&
264
+ !(SCOPE_PERSISTENCE_VALUES as readonly string[]).includes(entryPersistence)
265
+ ) {
266
+ issues.push({
267
+ code: "unknown_enum_value",
268
+ path: `bw:entry[@key='${entryKey}']/@metadataPersistence`,
269
+ value: entryPersistence,
270
+ severity: "warning",
271
+ });
272
+ entryPersistence = undefined;
273
+ }
274
+
275
+ const effective = resolveEffective({
276
+ overlay: overlayEnum,
277
+ scope: undefined,
278
+ field: entryPersistence,
279
+ });
280
+ const hasRef = Boolean(entryAttrs.storageRef);
281
+ const hasBody = entry.body.trim().length > 0;
282
+
283
+ if (effective === "external") {
284
+ if (!hasRef) {
285
+ issues.push({
286
+ code: "external_entry_missing_ref",
287
+ path: `bw:entry[@key='${entryKey}']`,
288
+ severity: "error",
289
+ });
290
+ }
291
+ if (hasBody) {
292
+ issues.push({
293
+ code: "external_entry_body_ignored",
294
+ path: `bw:entry[@key='${entryKey}']`,
295
+ severity: "warning",
296
+ });
297
+ }
298
+ } else if (effective === "internal" && hasRef) {
299
+ // Internal-mode entries carry their value inline. A `storageRef` on
300
+ // an internal entry is a stale pointer from a persistence-mode
301
+ // downgrade and will confuse hosts that branch on its presence.
302
+ issues.push({
303
+ code: "internal_entry_unexpected_storage_ref",
304
+ path: `bw:entry[@key='${entryKey}']`,
305
+ severity: "warning",
306
+ });
307
+ }
308
+ }
309
+
310
+ return issues;
311
+ }
312
+
313
+ function parseAttrs(source: string): Record<string, string> {
314
+ const out: Record<string, string> = {};
315
+ for (const m of source.matchAll(/(\w+)="([^"]*)"/gu)) {
316
+ out[m[1]] = m[2];
317
+ }
318
+ return out;
319
+ }
320
+
321
+ function* findAll(
322
+ xml: string,
323
+ re: RegExp,
324
+ ): Generator<{ attrs: string; body: string }> {
325
+ for (const m of xml.matchAll(re)) {
326
+ yield { attrs: m[1] ?? "", body: m[2] ?? "" };
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Emits each `<bw:field>` in the scope body, handling both self-closing
332
+ * and body-bearing forms.
333
+ */
334
+ function* findFields(
335
+ scopeBody: string,
336
+ ): Generator<{ attrs: string; body: string }> {
337
+ // Match both <bw:field .../> and <bw:field ...>body</bw:field>
338
+ const re = /<bw:field\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:field>)/gu;
339
+ for (const m of scopeBody.matchAll(re)) {
340
+ yield { attrs: m[1] ?? "", body: m[2] ?? "" };
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Emits each top-level `<bw:entry>` (inside `<bw:metadata>`), handling
346
+ * both self-closing and body-bearing forms. Scope-internal entries
347
+ * are handled by findFields — this only looks at the flat metadata list.
348
+ */
349
+ function* findEntries(
350
+ xml: string,
351
+ ): Generator<{ attrs: string; body: string }> {
352
+ const re = /<bw:entry\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:entry>)/gu;
353
+ for (const m of xml.matchAll(re)) {
354
+ yield { attrs: m[1] ?? "", body: m[2] ?? "" };
355
+ }
356
+ }
357
+
358
+ function resolveEffective(input: {
359
+ overlay?: "internal" | "external";
360
+ scope?: string;
361
+ field?: string;
362
+ }): "internal" | "external" {
363
+ if (input.field === "internal" || input.field === "external") return input.field;
364
+ if (input.scope === "internal" || input.scope === "external") return input.scope;
365
+ if (input.overlay === "external") return "external";
366
+ return "internal";
367
+ }