@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -0,0 +1,707 @@
1
+ import type {
2
+ CompatibilityReport,
3
+ DocumentNavigationSnapshot,
4
+ FieldSnapshot,
5
+ ProtectionSnapshot,
6
+ RuntimeRenderSnapshot,
7
+ } from "../api/public-types.ts";
8
+ import { parseCommentsFromOoxml } from "../io/ooxml/parse-comments.ts";
9
+ import { readOpcPackage } from "../io/opc/package-reader.ts";
10
+
11
+ export interface ClosureValidationContext {
12
+ renderSnapshot: Pick<
13
+ RuntimeRenderSnapshot,
14
+ | "isReady"
15
+ | "fatalError"
16
+ | "comments"
17
+ | "trackedChanges"
18
+ | "surface"
19
+ | "pageLayout"
20
+ >;
21
+ navigation?: Pick<DocumentNavigationSnapshot, "pages">;
22
+ compatibility: Pick<CompatibilityReport, "errors" | "featureEntries">;
23
+ surface: RuntimeRenderSnapshot["surface"];
24
+ fieldSnapshot?: FieldSnapshot;
25
+ protectionSnapshot?: ProtectionSnapshot;
26
+ harnessShowcase?: HarnessShowcaseValidationContext;
27
+ }
28
+
29
+ export type HarnessShowcaseArea =
30
+ | "workflow-overlay"
31
+ | "blocked-commands"
32
+ | "preserve-only"
33
+ | "search-candidates"
34
+ | "right-rail"
35
+ | "multi-scope"
36
+ | "public-api"
37
+ | "sidebar-integration"
38
+ | "policy-yaml"
39
+ | "harness-lifecycle"
40
+ | "export-continuity";
41
+
42
+ export interface HarnessShowcaseAreaProof {
43
+ demonstrated: boolean;
44
+ detail?: string;
45
+ proofArtifacts?: string[];
46
+ }
47
+
48
+ export interface HarnessShowcaseValidationContext {
49
+ areas: Partial<Record<HarnessShowcaseArea, HarnessShowcaseAreaProof>>;
50
+ }
51
+
52
+ export type ClosureValidationCheck =
53
+ | { type: "mustLoad" }
54
+ | { type: "compatibilityFeature"; featureKey: string; featureClass: string }
55
+ | { type: "minTrackedChanges"; count: number }
56
+ | { type: "maxTrackedChanges"; count: number }
57
+ | { type: "minCommentThreads"; count: number }
58
+ | { type: "lockedFragmentCountAtLeast"; count: number }
59
+ | { type: "surfaceBlockKind"; kind: string; count?: number }
60
+ | { type: "opaqueBlockLabelPrefix"; value: string }
61
+ | { type: "opaqueInlineLabelPrefix"; value: string }
62
+ | {
63
+ type: "trackedChangeMatch";
64
+ kind?: "insertion" | "deletion" | "formatting" | "move" | "property-change";
65
+ actionability?: "actionable" | "preserve-only";
66
+ status?: "active" | "accepted" | "rejected" | "detached";
67
+ preserveOnlyReasonIncludes?: string[];
68
+ }
69
+ | {
70
+ type: "fieldEntryMatch";
71
+ fieldFamily?: string;
72
+ supported?: boolean;
73
+ refreshStatus?: string;
74
+ displayTextIncludes?: string[];
75
+ fieldTarget?: string;
76
+ }
77
+ | {
78
+ type: "protectionMatch";
79
+ hasDocumentProtection?: boolean;
80
+ editType?: string;
81
+ enforcementActive?: boolean;
82
+ enforcedRangeCountAtLeast?: number;
83
+ preservedRangeCountAtLeast?: number;
84
+ rangeId?: string;
85
+ rangeEnforced?: boolean;
86
+ }
87
+ | {
88
+ type: "secondaryStoryMatch";
89
+ kind: "header" | "footer" | "footnote" | "endnote";
90
+ relationshipId?: string;
91
+ variant?: "default" | "first" | "even";
92
+ noteId?: string;
93
+ minBlockCount?: number;
94
+ }
95
+ | {
96
+ type: "pageLayoutMatch";
97
+ sectionIndex?: number;
98
+ orientation?: "portrait" | "landscape";
99
+ columns?: number;
100
+ differentFirstPage?: boolean;
101
+ differentOddEvenPages?: boolean;
102
+ headerVariantCountAtLeast?: number;
103
+ footerVariantCountAtLeast?: number;
104
+ }
105
+ | {
106
+ type: "commentThreadMatch";
107
+ commentId?: string;
108
+ status?: "open" | "resolved" | "detached";
109
+ entryCount?: number;
110
+ anchorKind?: "range" | "node" | "detached";
111
+ bodyIncludes?: string[];
112
+ entryBodies?: string[];
113
+ entryAuthorIds?: string[];
114
+ authorId?: string;
115
+ }
116
+ | {
117
+ type: "harnessShowcase";
118
+ area: HarnessShowcaseArea;
119
+ claim: string;
120
+ };
121
+
122
+ export interface ClosureValidationResult {
123
+ type: string;
124
+ passed: boolean;
125
+ reason: string;
126
+ }
127
+
128
+ export interface DocxCommentProof {
129
+ partPresence: {
130
+ commentsXml: boolean;
131
+ commentsExtendedXml: boolean;
132
+ commentsIdsXml: boolean;
133
+ peopleXml: boolean;
134
+ };
135
+ serializedCommentIds: string[];
136
+ peopleAuthors: string[];
137
+ threads: Array<{
138
+ commentId: string;
139
+ rootCommentId?: string;
140
+ status: "open" | "resolved" | "detached";
141
+ entryCount: number;
142
+ bodies: string[];
143
+ authorIds: string[];
144
+ anchorKind: "range" | "node" | "detached";
145
+ }>;
146
+ }
147
+
148
+ export function evaluateHarnessShowcaseCheck(
149
+ check: Extract<ClosureValidationCheck, { type: "harnessShowcase" }>,
150
+ context: HarnessShowcaseValidationContext,
151
+ ): ClosureValidationResult {
152
+ const areaProof = context.areas[check.area];
153
+ if (!areaProof) {
154
+ return {
155
+ type: check.type,
156
+ passed: false,
157
+ reason: `missing harness showcase proof for area ${check.area}: ${check.claim}`,
158
+ };
159
+ }
160
+
161
+ return {
162
+ type: check.type,
163
+ passed: areaProof.demonstrated,
164
+ reason:
165
+ areaProof.detail ??
166
+ `expected harness showcase proof for area ${check.area}: ${check.claim}`,
167
+ };
168
+ }
169
+
170
+ export function evaluateClosureCheck(
171
+ check: ClosureValidationCheck,
172
+ context: ClosureValidationContext,
173
+ ): ClosureValidationResult {
174
+ switch (check.type) {
175
+ case "mustLoad":
176
+ return {
177
+ type: check.type,
178
+ passed:
179
+ context.renderSnapshot.isReady &&
180
+ context.renderSnapshot.fatalError === undefined &&
181
+ context.compatibility.errors.length === 0,
182
+ reason: "exported artifact did not reload into a healthy runtime",
183
+ };
184
+ case "compatibilityFeature": {
185
+ const passed = context.compatibility.featureEntries.some(
186
+ (entry) =>
187
+ entry.featureKey === check.featureKey &&
188
+ entry.featureClass === check.featureClass,
189
+ );
190
+ return {
191
+ type: check.type,
192
+ passed,
193
+ reason: `missing compatibility feature ${check.featureKey}:${check.featureClass}`,
194
+ };
195
+ }
196
+ case "minTrackedChanges":
197
+ return {
198
+ type: check.type,
199
+ passed: context.renderSnapshot.trackedChanges.totalCount >= check.count,
200
+ reason: `expected at least ${check.count} tracked changes`,
201
+ };
202
+ case "maxTrackedChanges":
203
+ return {
204
+ type: check.type,
205
+ passed: context.renderSnapshot.trackedChanges.totalCount <= check.count,
206
+ reason: `expected at most ${check.count} tracked changes`,
207
+ };
208
+ case "minCommentThreads":
209
+ return {
210
+ type: check.type,
211
+ passed: context.renderSnapshot.comments.totalCount >= check.count,
212
+ reason: `expected at least ${check.count} comment threads`,
213
+ };
214
+ case "lockedFragmentCountAtLeast":
215
+ return {
216
+ type: check.type,
217
+ passed: (context.surface?.lockedFragmentIds.length ?? 0) >= check.count,
218
+ reason: `expected at least ${check.count} locked fragments`,
219
+ };
220
+ case "surfaceBlockKind":
221
+ return {
222
+ type: check.type,
223
+ passed:
224
+ (context.surface?.blocks.filter((block) => block.kind === check.kind).length ?? 0) >=
225
+ (check.count ?? 1),
226
+ reason: `expected at least ${check.count ?? 1} surface blocks of kind ${check.kind}`,
227
+ };
228
+ case "opaqueBlockLabelPrefix":
229
+ return {
230
+ type: check.type,
231
+ passed:
232
+ context.surface?.blocks.some(
233
+ (block) =>
234
+ block.kind === "opaque_block" &&
235
+ typeof block.label === "string" &&
236
+ block.label.startsWith(check.value),
237
+ ) ?? false,
238
+ reason: `expected an opaque block label starting with ${check.value}`,
239
+ };
240
+ case "opaqueInlineLabelPrefix":
241
+ return {
242
+ type: check.type,
243
+ passed: hasOpaqueInlineLabelPrefix(context.surface, check.value),
244
+ reason: `expected an opaque inline label starting with ${check.value}`,
245
+ };
246
+ case "trackedChangeMatch": {
247
+ const passed = context.renderSnapshot.trackedChanges.revisions.some((revision) => {
248
+ if (check.kind && revision.kind !== check.kind) {
249
+ return false;
250
+ }
251
+ if (check.actionability && revision.actionability !== check.actionability) {
252
+ return false;
253
+ }
254
+ if (check.status && revision.status !== check.status) {
255
+ return false;
256
+ }
257
+ if (
258
+ check.preserveOnlyReasonIncludes &&
259
+ !check.preserveOnlyReasonIncludes.every((fragment) =>
260
+ (revision.preserveOnlyReason ?? "").includes(fragment),
261
+ )
262
+ ) {
263
+ return false;
264
+ }
265
+ return true;
266
+ });
267
+ return {
268
+ type: check.type,
269
+ passed,
270
+ reason: `expected a tracked change matching ${describeTrackedChangeMatch(check)}`,
271
+ };
272
+ }
273
+ case "fieldEntryMatch": {
274
+ const passed =
275
+ context.fieldSnapshot?.fields.some((field) => {
276
+ if (check.fieldFamily && field.fieldFamily !== check.fieldFamily) {
277
+ return false;
278
+ }
279
+ if (check.supported !== undefined && field.supported !== check.supported) {
280
+ return false;
281
+ }
282
+ if (check.refreshStatus && field.refreshStatus !== check.refreshStatus) {
283
+ return false;
284
+ }
285
+ if (check.fieldTarget && field.fieldTarget !== check.fieldTarget) {
286
+ return false;
287
+ }
288
+ if (
289
+ check.displayTextIncludes &&
290
+ !check.displayTextIncludes.every((fragment) => field.displayText.includes(fragment))
291
+ ) {
292
+ return false;
293
+ }
294
+ return true;
295
+ }) ?? false;
296
+ return {
297
+ type: check.type,
298
+ passed,
299
+ reason: `expected a field entry matching ${describeFieldEntryMatch(check)}`,
300
+ };
301
+ }
302
+ case "protectionMatch": {
303
+ const snapshot = context.protectionSnapshot;
304
+ const passed = Boolean(
305
+ snapshot &&
306
+ (check.hasDocumentProtection === undefined ||
307
+ snapshot.hasDocumentProtection === check.hasDocumentProtection) &&
308
+ (check.editType === undefined || snapshot.editType === check.editType) &&
309
+ (check.enforcementActive === undefined ||
310
+ snapshot.enforcementActive === check.enforcementActive) &&
311
+ (check.enforcedRangeCountAtLeast === undefined ||
312
+ snapshot.enforcedRangeCount >= check.enforcedRangeCountAtLeast) &&
313
+ (check.preservedRangeCountAtLeast === undefined ||
314
+ snapshot.preservedRangeCount >= check.preservedRangeCountAtLeast) &&
315
+ (!check.rangeId ||
316
+ snapshot.ranges.some((range) =>
317
+ range.rangeId === check.rangeId &&
318
+ (check.rangeEnforced === undefined || range.enforced === check.rangeEnforced),
319
+ ))
320
+ );
321
+ return {
322
+ type: check.type,
323
+ passed,
324
+ reason: `expected protection snapshot matching ${describeProtectionMatch(check)}`,
325
+ };
326
+ }
327
+ case "secondaryStoryMatch": {
328
+ const passed =
329
+ context.surface?.secondaryStories.some((story) => {
330
+ if (story.target.kind !== check.kind) {
331
+ return false;
332
+ }
333
+ if (
334
+ "relationshipId" in story.target &&
335
+ check.relationshipId &&
336
+ story.target.relationshipId !== check.relationshipId
337
+ ) {
338
+ return false;
339
+ }
340
+ if ("variant" in story.target && check.variant && story.target.variant !== check.variant) {
341
+ return false;
342
+ }
343
+ if ("noteId" in story.target && check.noteId && story.target.noteId !== check.noteId) {
344
+ return false;
345
+ }
346
+ if ((check.minBlockCount ?? 0) > story.blocks.length) {
347
+ return false;
348
+ }
349
+ return true;
350
+ }) ?? false;
351
+ return {
352
+ type: check.type,
353
+ passed,
354
+ reason: `expected secondary story ${describeSecondaryStoryMatch(check)}`,
355
+ };
356
+ }
357
+ case "pageLayoutMatch": {
358
+ const layouts = getLayoutsForCheck(context, check);
359
+ const headerVariantCount = countUniqueStoryVariants(layouts, "headerVariants");
360
+ const footerVariantCount = countUniqueStoryVariants(layouts, "footerVariants");
361
+ const passed = Boolean(
362
+ layouts.length > 0 &&
363
+ layouts.some(
364
+ (layout) =>
365
+ (check.orientation === undefined || layout.orientation === check.orientation) &&
366
+ (check.columns === undefined || layout.columns === check.columns) &&
367
+ (check.differentFirstPage === undefined ||
368
+ layout.differentFirstPage === check.differentFirstPage) &&
369
+ (check.differentOddEvenPages === undefined ||
370
+ layout.differentOddEvenPages === check.differentOddEvenPages),
371
+ ) &&
372
+ (check.headerVariantCountAtLeast === undefined ||
373
+ headerVariantCount >= check.headerVariantCountAtLeast) &&
374
+ (check.footerVariantCountAtLeast === undefined ||
375
+ footerVariantCount >= check.footerVariantCountAtLeast)
376
+ );
377
+ return {
378
+ type: check.type,
379
+ passed,
380
+ reason: `expected page layout ${describePageLayoutMatch(check)}`,
381
+ };
382
+ }
383
+ case "commentThreadMatch": {
384
+ const passed = context.renderSnapshot.comments.threads.some((thread) =>
385
+ matchesCommentThread(thread, check),
386
+ );
387
+ return {
388
+ type: check.type,
389
+ passed,
390
+ reason: `expected a comment thread matching ${describeCommentThreadMatch(check)}`,
391
+ };
392
+ }
393
+ case "harnessShowcase":
394
+ return evaluateHarnessShowcaseCheck(check, context.harnessShowcase ?? { areas: {} });
395
+ default:
396
+ return {
397
+ type: (check as { type: string }).type,
398
+ passed: false,
399
+ reason: `unknown closure validation check type ${(check as { type: string }).type}`,
400
+ };
401
+ }
402
+ }
403
+
404
+ export function extractDocxCommentProof(bytes: Uint8Array): DocxCommentProof {
405
+ const packageFile = readOpcPackage(bytes);
406
+ const documentXml = decodePartText(packageFile.parts.get("/word/document.xml")?.bytes);
407
+ const commentsXml = decodePartText(packageFile.parts.get("/word/comments.xml")?.bytes);
408
+ const commentsExtendedXml = decodePartText(
409
+ packageFile.parts.get("/word/commentsExtended.xml")?.bytes,
410
+ );
411
+ const commentsIdsXml = decodePartText(packageFile.parts.get("/word/commentsIds.xml")?.bytes);
412
+ const peopleXml = decodePartText(packageFile.parts.get("/word/people.xml")?.bytes);
413
+ const parsedComments =
414
+ documentXml && commentsXml
415
+ ? parseCommentsFromOoxml(documentXml, {
416
+ commentsXml,
417
+ commentsExtendedXml,
418
+ commentsIdsXml,
419
+ peopleXml,
420
+ })
421
+ : {
422
+ threads: [],
423
+ definitions: [],
424
+ peopleAuthors: [],
425
+ };
426
+
427
+ return {
428
+ partPresence: {
429
+ commentsXml: commentsXml.length > 0,
430
+ commentsExtendedXml: commentsExtendedXml.length > 0,
431
+ commentsIdsXml: commentsIdsXml.length > 0,
432
+ peopleXml: peopleXml.length > 0,
433
+ },
434
+ serializedCommentIds: parsedComments.definitions.map((definition) => definition.commentId),
435
+ peopleAuthors: [...parsedComments.peopleAuthors],
436
+ threads: parsedComments.threads.map((thread) => ({
437
+ commentId: thread.commentId,
438
+ rootCommentId: thread.metadata?.rootOoxmlCommentId,
439
+ status: thread.status,
440
+ entryCount: thread.entries.length,
441
+ bodies: thread.entries.map((entry) => entry.body),
442
+ authorIds: [...new Set(thread.entries.map((entry) => entry.authorId))],
443
+ anchorKind: thread.anchor.kind,
444
+ })),
445
+ };
446
+ }
447
+
448
+ function hasOpaqueInlineLabelPrefix(
449
+ surface: RuntimeRenderSnapshot["surface"],
450
+ prefix: string,
451
+ ): boolean {
452
+ if (!surface) {
453
+ return false;
454
+ }
455
+
456
+ for (const block of surface.blocks) {
457
+ if (block.kind !== "paragraph") {
458
+ continue;
459
+ }
460
+
461
+ if (
462
+ block.segments.some(
463
+ (segment) =>
464
+ segment.kind === "opaque_inline" &&
465
+ typeof segment.label === "string" &&
466
+ segment.label.startsWith(prefix),
467
+ )
468
+ ) {
469
+ return true;
470
+ }
471
+ }
472
+
473
+ return false;
474
+ }
475
+
476
+ function matchesCommentThread(
477
+ thread: RuntimeRenderSnapshot["comments"]["threads"][number],
478
+ check: Extract<ClosureValidationCheck, { type: "commentThreadMatch" }>,
479
+ ): boolean {
480
+ if (check.commentId && thread.commentId !== check.commentId) {
481
+ return false;
482
+ }
483
+ if (check.status && thread.status !== check.status) {
484
+ return false;
485
+ }
486
+ if (typeof check.entryCount === "number" && thread.entries.length !== check.entryCount) {
487
+ return false;
488
+ }
489
+ if (check.anchorKind && thread.anchor.kind !== check.anchorKind) {
490
+ return false;
491
+ }
492
+ if (check.authorId && thread.createdBy !== check.authorId) {
493
+ return false;
494
+ }
495
+ if (check.bodyIncludes?.length) {
496
+ const bodies = thread.entries.map((entry) => entry.body);
497
+ for (const expectedBody of check.bodyIncludes) {
498
+ if (!bodies.some((body) => body.includes(expectedBody))) {
499
+ return false;
500
+ }
501
+ }
502
+ }
503
+ if (check.entryBodies?.length) {
504
+ if (thread.entries.length < check.entryBodies.length) {
505
+ return false;
506
+ }
507
+ for (let index = 0; index < check.entryBodies.length; index += 1) {
508
+ const expectedBody = check.entryBodies[index];
509
+ const actualBody = thread.entries[index]?.body;
510
+ if (typeof expectedBody !== "string" || actualBody !== expectedBody) {
511
+ return false;
512
+ }
513
+ }
514
+ }
515
+ if (check.entryAuthorIds?.length) {
516
+ if (thread.entries.length < check.entryAuthorIds.length) {
517
+ return false;
518
+ }
519
+ for (let index = 0; index < check.entryAuthorIds.length; index += 1) {
520
+ const expectedAuthorId = check.entryAuthorIds[index];
521
+ const actualAuthorId = thread.entries[index]?.authorId;
522
+ if (typeof expectedAuthorId !== "string" || actualAuthorId !== expectedAuthorId) {
523
+ return false;
524
+ }
525
+ }
526
+ }
527
+ return true;
528
+ }
529
+
530
+ function describeTrackedChangeMatch(
531
+ check: Extract<ClosureValidationCheck, { type: "trackedChangeMatch" }>,
532
+ ): string {
533
+ return [
534
+ check.kind ? `kind=${check.kind}` : undefined,
535
+ check.actionability ? `actionability=${check.actionability}` : undefined,
536
+ check.status ? `status=${check.status}` : undefined,
537
+ check.preserveOnlyReasonIncludes?.length
538
+ ? `preserveOnlyReasonIncludes=${check.preserveOnlyReasonIncludes.join(",")}`
539
+ : undefined,
540
+ ]
541
+ .filter(Boolean)
542
+ .join(" ");
543
+ }
544
+
545
+ function describeFieldEntryMatch(
546
+ check: Extract<ClosureValidationCheck, { type: "fieldEntryMatch" }>,
547
+ ): string {
548
+ return [
549
+ check.fieldFamily ? `fieldFamily=${check.fieldFamily}` : undefined,
550
+ check.supported !== undefined ? `supported=${String(check.supported)}` : undefined,
551
+ check.refreshStatus ? `refreshStatus=${check.refreshStatus}` : undefined,
552
+ check.fieldTarget ? `fieldTarget=${check.fieldTarget}` : undefined,
553
+ check.displayTextIncludes?.length
554
+ ? `displayTextIncludes=${check.displayTextIncludes.join(",")}`
555
+ : undefined,
556
+ ]
557
+ .filter(Boolean)
558
+ .join(" ");
559
+ }
560
+
561
+ function describeProtectionMatch(
562
+ check: Extract<ClosureValidationCheck, { type: "protectionMatch" }>,
563
+ ): string {
564
+ return [
565
+ check.hasDocumentProtection !== undefined
566
+ ? `hasDocumentProtection=${String(check.hasDocumentProtection)}`
567
+ : undefined,
568
+ check.editType ? `editType=${check.editType}` : undefined,
569
+ check.enforcementActive !== undefined
570
+ ? `enforcementActive=${String(check.enforcementActive)}`
571
+ : undefined,
572
+ check.enforcedRangeCountAtLeast !== undefined
573
+ ? `enforcedRangeCountAtLeast=${String(check.enforcedRangeCountAtLeast)}`
574
+ : undefined,
575
+ check.preservedRangeCountAtLeast !== undefined
576
+ ? `preservedRangeCountAtLeast=${String(check.preservedRangeCountAtLeast)}`
577
+ : undefined,
578
+ check.rangeId ? `rangeId=${check.rangeId}` : undefined,
579
+ check.rangeEnforced !== undefined
580
+ ? `rangeEnforced=${String(check.rangeEnforced)}`
581
+ : undefined,
582
+ ]
583
+ .filter(Boolean)
584
+ .join(" ");
585
+ }
586
+
587
+ function getLayoutsForCheck(
588
+ context: ClosureValidationContext,
589
+ check: Extract<ClosureValidationCheck, { type: "pageLayoutMatch" }>,
590
+ ) {
591
+ const navigationLayouts =
592
+ context.navigation?.pages
593
+ .filter((page) => check.sectionIndex === undefined || page.sectionIndex === check.sectionIndex)
594
+ .map((page) => page.layout) ?? [];
595
+ if (navigationLayouts.length > 0) {
596
+ return navigationLayouts;
597
+ }
598
+ const activeLayout = context.renderSnapshot.pageLayout;
599
+ if (
600
+ activeLayout &&
601
+ (check.sectionIndex === undefined || activeLayout.sectionIndex === check.sectionIndex)
602
+ ) {
603
+ return [activeLayout];
604
+ }
605
+ return [];
606
+ }
607
+
608
+ function countUniqueStoryVariants(
609
+ layouts: Array<{
610
+ headerVariants: Array<{ variant: string; relationshipId: string }>;
611
+ footerVariants: Array<{ variant: string; relationshipId: string }>;
612
+ }>,
613
+ key: "headerVariants" | "footerVariants",
614
+ ) {
615
+ const variants = new Set<string>();
616
+ for (const layout of layouts) {
617
+ for (const variant of layout[key]) {
618
+ variants.add(`${variant.variant}:${variant.relationshipId}`);
619
+ }
620
+ }
621
+ return variants.size;
622
+ }
623
+
624
+ function describeCommentThreadMatch(
625
+ check: Extract<ClosureValidationCheck, { type: "commentThreadMatch" }>,
626
+ ): string {
627
+ const parts: string[] = [];
628
+ if (check.commentId) {
629
+ parts.push(`commentId=${check.commentId}`);
630
+ }
631
+ if (check.status) {
632
+ parts.push(`status=${check.status}`);
633
+ }
634
+ if (typeof check.entryCount === "number") {
635
+ parts.push(`entryCount=${check.entryCount}`);
636
+ }
637
+ if (check.anchorKind) {
638
+ parts.push(`anchorKind=${check.anchorKind}`);
639
+ }
640
+ if (check.authorId) {
641
+ parts.push(`authorId=${check.authorId}`);
642
+ }
643
+ if (check.bodyIncludes?.length) {
644
+ parts.push(`bodyIncludes=${check.bodyIncludes.join(" | ")}`);
645
+ }
646
+ if (check.entryBodies?.length) {
647
+ parts.push(`entryBodies=${check.entryBodies.join(" | ")}`);
648
+ }
649
+ if (check.entryAuthorIds?.length) {
650
+ parts.push(`entryAuthorIds=${check.entryAuthorIds.join(" | ")}`);
651
+ }
652
+ return parts.length > 0 ? parts.join(", ") : "any thread";
653
+ }
654
+
655
+ function describeSecondaryStoryMatch(
656
+ check: Extract<ClosureValidationCheck, { type: "secondaryStoryMatch" }>,
657
+ ): string {
658
+ const parts = [`kind=${check.kind}`];
659
+ if (check.relationshipId) {
660
+ parts.push(`relationshipId=${check.relationshipId}`);
661
+ }
662
+ if (check.variant) {
663
+ parts.push(`variant=${check.variant}`);
664
+ }
665
+ if (check.noteId) {
666
+ parts.push(`noteId=${check.noteId}`);
667
+ }
668
+ if (typeof check.minBlockCount === "number") {
669
+ parts.push(`minBlockCount=${check.minBlockCount}`);
670
+ }
671
+ return parts.join(", ");
672
+ }
673
+
674
+ function describePageLayoutMatch(
675
+ check: Extract<ClosureValidationCheck, { type: "pageLayoutMatch" }>,
676
+ ): string {
677
+ const parts: string[] = [];
678
+ if (typeof check.sectionIndex === "number") {
679
+ parts.push(`sectionIndex=${check.sectionIndex}`);
680
+ }
681
+ if (check.orientation) {
682
+ parts.push(`orientation=${check.orientation}`);
683
+ }
684
+ if (typeof check.columns === "number") {
685
+ parts.push(`columns=${check.columns}`);
686
+ }
687
+ if (typeof check.differentFirstPage === "boolean") {
688
+ parts.push(`differentFirstPage=${check.differentFirstPage}`);
689
+ }
690
+ if (typeof check.differentOddEvenPages === "boolean") {
691
+ parts.push(`differentOddEvenPages=${check.differentOddEvenPages}`);
692
+ }
693
+ if (typeof check.headerVariantCountAtLeast === "number") {
694
+ parts.push(`headerVariantCountAtLeast=${check.headerVariantCountAtLeast}`);
695
+ }
696
+ if (typeof check.footerVariantCountAtLeast === "number") {
697
+ parts.push(`footerVariantCountAtLeast=${check.footerVariantCountAtLeast}`);
698
+ }
699
+ return parts.length > 0 ? parts.join(", ") : "present";
700
+ }
701
+
702
+ function decodePartText(bytes: Uint8Array | undefined): string {
703
+ if (!bytes) {
704
+ return "";
705
+ }
706
+ return new TextDecoder("utf-8").decode(bytes);
707
+ }