@beyondwork/docx-react-component 1.0.18 → 1.0.19

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 (74) 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 +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -104,23 +104,13 @@ function collectContentFeatures(
104
104
  lists: false,
105
105
  hyperlinks: false,
106
106
  images: false,
107
+ tables: false,
108
+ sections: false,
109
+ contentControls: false,
107
110
  };
108
111
 
109
112
  for (let index = 0; index < content.children.length; index += 1) {
110
- const block = content.children[index];
111
- if (block.type !== "paragraph") {
112
- continue;
113
- }
114
-
115
- flags.paragraphs = true;
116
- if (block.styleId?.toLowerCase().startsWith("heading")) {
117
- flags.headings = true;
118
- }
119
- if (block.numbering) {
120
- flags.lists = true;
121
- }
122
-
123
- measureParagraph(block, flags);
113
+ measureBlock(content.children[index], flags);
124
114
  }
125
115
 
126
116
  const entries: CompatibilityFeatureEntry[] = [];
@@ -145,16 +135,94 @@ function collectContentFeatures(
145
135
  if (flags.images) {
146
136
  entries.push(supportedEntry("inline-images", "Inline image placements stay attached to preserved media parts."));
147
137
  }
138
+ if (flags.tables) {
139
+ entries.push(
140
+ supportedEntry(
141
+ "tables",
142
+ "Structured tables keep cell topology and common visual properties through runtime editing and export.",
143
+ ),
144
+ );
145
+ }
146
+ if (flags.sections) {
147
+ entries.push(
148
+ supportedEntry(
149
+ "sections",
150
+ "Section boundaries and page-layout properties remain structured and export-safe.",
151
+ ),
152
+ );
153
+ }
154
+ if (flags.contentControls) {
155
+ entries.push(
156
+ supportedEntry(
157
+ "content-controls",
158
+ "Common content controls render through structured SDT state and round-trip without flattening to opaque placeholders.",
159
+ ),
160
+ );
161
+ }
148
162
  return entries;
149
163
  }
150
164
 
165
+ function measureBlock(
166
+ block: BlockNode,
167
+ flags: {
168
+ paragraphs: boolean;
169
+ runs: boolean;
170
+ whitespace: boolean;
171
+ headings: boolean;
172
+ lists: boolean;
173
+ hyperlinks: boolean;
174
+ images: boolean;
175
+ tables: boolean;
176
+ sections: boolean;
177
+ contentControls: boolean;
178
+ },
179
+ ): number {
180
+ switch (block.type) {
181
+ case "paragraph":
182
+ flags.paragraphs = true;
183
+ if (block.styleId?.toLowerCase().startsWith("heading")) {
184
+ flags.headings = true;
185
+ }
186
+ if (block.numbering) {
187
+ flags.lists = true;
188
+ }
189
+ return measureParagraph(block, flags);
190
+ case "table":
191
+ flags.tables = true;
192
+ return block.rows.reduce(
193
+ (size, row) =>
194
+ size +
195
+ row.cells.reduce(
196
+ (cellSize, cell) =>
197
+ cellSize + cell.children.reduce((childSize, child) => childSize + measureBlock(child, flags), 0),
198
+ 0,
199
+ ),
200
+ 0,
201
+ );
202
+ case "sdt":
203
+ flags.contentControls = true;
204
+ return block.children.reduce((size, child) => size + measureBlock(child, flags), 0);
205
+ case "custom_xml":
206
+ return block.children.reduce((size, child) => size + measureBlock(child, flags), 0);
207
+ case "section_break":
208
+ flags.sections = true;
209
+ return 1;
210
+ default:
211
+ return 0;
212
+ }
213
+ }
214
+
151
215
  function measureParagraph(
152
216
  paragraph: ParagraphNode,
153
217
  flags: {
218
+ paragraphs: boolean;
154
219
  runs: boolean;
155
220
  whitespace: boolean;
156
221
  hyperlinks: boolean;
157
222
  images: boolean;
223
+ tables: boolean;
224
+ sections: boolean;
225
+ contentControls: boolean;
158
226
  },
159
227
  ): number {
160
228
  let size = 0;
@@ -170,10 +238,14 @@ function measureParagraph(
170
238
  function measureInlineNode(
171
239
  node: InlineNode,
172
240
  flags: {
241
+ paragraphs: boolean;
173
242
  runs: boolean;
174
243
  whitespace: boolean;
175
244
  hyperlinks: boolean;
176
245
  images: boolean;
246
+ tables: boolean;
247
+ sections: boolean;
248
+ contentControls: boolean;
177
249
  },
178
250
  ): number {
179
251
  switch (node.type) {
@@ -310,12 +382,12 @@ function collectSubPartFeatures(
310
382
  Object.keys(subParts.footnoteCollection?.footnotes ?? {}).length +
311
383
  Object.keys(subParts.footnoteCollection?.endnotes ?? {}).length;
312
384
 
313
- if (hasHeaderFooterContent) {
385
+ if (hasHeaderFooterContent && !entries.some((entry) => entry.featureKey === "headers-footers-lossy")) {
314
386
  entries.push({
315
387
  featureEntryId: "feature:subparts:headers-footers",
316
388
  featureKey: "headers-footers",
317
- featureClass: "preserve-only",
318
- message: "Headers and footers are preserved through structured sub-part ownership.",
389
+ featureClass: "supported-roundtrip",
390
+ message: "Headers and footers resolve as structured secondary stories with export-safe ownership.",
319
391
  details: {
320
392
  headerCount: subParts.headers?.length ?? 0,
321
393
  footerCount: subParts.footers?.length ?? 0,
@@ -323,12 +395,12 @@ function collectSubPartFeatures(
323
395
  });
324
396
  }
325
397
 
326
- if (noteCount > 0) {
398
+ if (noteCount > 0 && !entries.some((entry) => entry.featureKey === "notes-lossy")) {
327
399
  entries.push({
328
400
  featureEntryId: "feature:subparts:notes",
329
401
  featureKey: "notes",
330
- featureClass: "preserve-only",
331
- message: "Footnotes and endnotes are preserved through structured sub-part ownership.",
402
+ featureClass: "supported-roundtrip",
403
+ message: "Footnotes and endnotes resolve as structured secondary stories with export-safe ownership.",
332
404
  details: {
333
405
  footnoteCount: Object.keys(subParts.footnoteCollection?.footnotes ?? {}).length,
334
406
  endnoteCount: Object.keys(subParts.footnoteCollection?.endnotes ?? {}).length,
@@ -48,6 +48,7 @@ export const EDITOR_ERROR_SOURCES = [
48
48
  "runtime",
49
49
  "validation",
50
50
  "datastore",
51
+ "host",
51
52
  "export",
52
53
  ] as const;
53
54
 
@@ -0,0 +1,487 @@
1
+ import type {
2
+ CompatibilityReport,
3
+ DocumentNavigationSnapshot,
4
+ RuntimeRenderSnapshot,
5
+ } from "../api/public-types.ts";
6
+ import { parseCommentsFromOoxml } from "../io/ooxml/parse-comments.ts";
7
+ import { readOpcPackage } from "../io/opc/package-reader.ts";
8
+
9
+ export interface ClosureValidationContext {
10
+ renderSnapshot: Pick<
11
+ RuntimeRenderSnapshot,
12
+ | "isReady"
13
+ | "fatalError"
14
+ | "comments"
15
+ | "trackedChanges"
16
+ | "surface"
17
+ | "pageLayout"
18
+ >;
19
+ navigation?: Pick<DocumentNavigationSnapshot, "pages">;
20
+ compatibility: Pick<CompatibilityReport, "errors" | "featureEntries">;
21
+ surface: RuntimeRenderSnapshot["surface"];
22
+ }
23
+
24
+ export type ClosureValidationCheck =
25
+ | { type: "mustLoad" }
26
+ | { type: "compatibilityFeature"; featureKey: string; featureClass: string }
27
+ | { type: "minTrackedChanges"; count: number }
28
+ | { type: "maxTrackedChanges"; count: number }
29
+ | { type: "minCommentThreads"; count: number }
30
+ | { type: "lockedFragmentCountAtLeast"; count: number }
31
+ | { type: "surfaceBlockKind"; kind: string; count?: number }
32
+ | { type: "opaqueBlockLabelPrefix"; value: string }
33
+ | { type: "opaqueInlineLabelPrefix"; value: string }
34
+ | {
35
+ type: "secondaryStoryMatch";
36
+ kind: "header" | "footer" | "footnote" | "endnote";
37
+ relationshipId?: string;
38
+ variant?: "default" | "first" | "even";
39
+ noteId?: string;
40
+ minBlockCount?: number;
41
+ }
42
+ | {
43
+ type: "pageLayoutMatch";
44
+ sectionIndex?: number;
45
+ orientation?: "portrait" | "landscape";
46
+ columns?: number;
47
+ differentFirstPage?: boolean;
48
+ differentOddEvenPages?: boolean;
49
+ headerVariantCountAtLeast?: number;
50
+ footerVariantCountAtLeast?: number;
51
+ }
52
+ | {
53
+ type: "commentThreadMatch";
54
+ commentId?: string;
55
+ status?: "open" | "resolved" | "detached";
56
+ entryCount?: number;
57
+ anchorKind?: "range" | "node" | "detached";
58
+ bodyIncludes?: string[];
59
+ entryBodies?: string[];
60
+ entryAuthorIds?: string[];
61
+ authorId?: string;
62
+ };
63
+
64
+ export interface ClosureValidationResult {
65
+ type: string;
66
+ passed: boolean;
67
+ reason: string;
68
+ }
69
+
70
+ export interface DocxCommentProof {
71
+ partPresence: {
72
+ commentsXml: boolean;
73
+ commentsExtendedXml: boolean;
74
+ commentsIdsXml: boolean;
75
+ peopleXml: boolean;
76
+ };
77
+ serializedCommentIds: string[];
78
+ peopleAuthors: string[];
79
+ threads: Array<{
80
+ commentId: string;
81
+ rootCommentId?: string;
82
+ status: "open" | "resolved" | "detached";
83
+ entryCount: number;
84
+ bodies: string[];
85
+ authorIds: string[];
86
+ anchorKind: "range" | "node" | "detached";
87
+ }>;
88
+ }
89
+
90
+ export function evaluateClosureCheck(
91
+ check: ClosureValidationCheck,
92
+ context: ClosureValidationContext,
93
+ ): ClosureValidationResult {
94
+ switch (check.type) {
95
+ case "mustLoad":
96
+ return {
97
+ type: check.type,
98
+ passed:
99
+ context.renderSnapshot.isReady &&
100
+ context.renderSnapshot.fatalError === undefined &&
101
+ context.compatibility.errors.length === 0,
102
+ reason: "exported artifact did not reload into a healthy runtime",
103
+ };
104
+ case "compatibilityFeature": {
105
+ const passed = context.compatibility.featureEntries.some(
106
+ (entry) =>
107
+ entry.featureKey === check.featureKey &&
108
+ entry.featureClass === check.featureClass,
109
+ );
110
+ return {
111
+ type: check.type,
112
+ passed,
113
+ reason: `missing compatibility feature ${check.featureKey}:${check.featureClass}`,
114
+ };
115
+ }
116
+ case "minTrackedChanges":
117
+ return {
118
+ type: check.type,
119
+ passed: context.renderSnapshot.trackedChanges.totalCount >= check.count,
120
+ reason: `expected at least ${check.count} tracked changes`,
121
+ };
122
+ case "maxTrackedChanges":
123
+ return {
124
+ type: check.type,
125
+ passed: context.renderSnapshot.trackedChanges.totalCount <= check.count,
126
+ reason: `expected at most ${check.count} tracked changes`,
127
+ };
128
+ case "minCommentThreads":
129
+ return {
130
+ type: check.type,
131
+ passed: context.renderSnapshot.comments.totalCount >= check.count,
132
+ reason: `expected at least ${check.count} comment threads`,
133
+ };
134
+ case "lockedFragmentCountAtLeast":
135
+ return {
136
+ type: check.type,
137
+ passed: (context.surface?.lockedFragmentIds.length ?? 0) >= check.count,
138
+ reason: `expected at least ${check.count} locked fragments`,
139
+ };
140
+ case "surfaceBlockKind":
141
+ return {
142
+ type: check.type,
143
+ passed:
144
+ (context.surface?.blocks.filter((block) => block.kind === check.kind).length ?? 0) >=
145
+ (check.count ?? 1),
146
+ reason: `expected at least ${check.count ?? 1} surface blocks of kind ${check.kind}`,
147
+ };
148
+ case "opaqueBlockLabelPrefix":
149
+ return {
150
+ type: check.type,
151
+ passed:
152
+ context.surface?.blocks.some(
153
+ (block) =>
154
+ block.kind === "opaque_block" &&
155
+ typeof block.label === "string" &&
156
+ block.label.startsWith(check.value),
157
+ ) ?? false,
158
+ reason: `expected an opaque block label starting with ${check.value}`,
159
+ };
160
+ case "opaqueInlineLabelPrefix":
161
+ return {
162
+ type: check.type,
163
+ passed: hasOpaqueInlineLabelPrefix(context.surface, check.value),
164
+ reason: `expected an opaque inline label starting with ${check.value}`,
165
+ };
166
+ case "secondaryStoryMatch": {
167
+ const passed =
168
+ context.surface?.secondaryStories.some((story) => {
169
+ if (story.target.kind !== check.kind) {
170
+ return false;
171
+ }
172
+ if (
173
+ "relationshipId" in story.target &&
174
+ check.relationshipId &&
175
+ story.target.relationshipId !== check.relationshipId
176
+ ) {
177
+ return false;
178
+ }
179
+ if ("variant" in story.target && check.variant && story.target.variant !== check.variant) {
180
+ return false;
181
+ }
182
+ if ("noteId" in story.target && check.noteId && story.target.noteId !== check.noteId) {
183
+ return false;
184
+ }
185
+ if ((check.minBlockCount ?? 0) > story.blocks.length) {
186
+ return false;
187
+ }
188
+ return true;
189
+ }) ?? false;
190
+ return {
191
+ type: check.type,
192
+ passed,
193
+ reason: `expected secondary story ${describeSecondaryStoryMatch(check)}`,
194
+ };
195
+ }
196
+ case "pageLayoutMatch": {
197
+ const layouts = getLayoutsForCheck(context, check);
198
+ const headerVariantCount = countUniqueStoryVariants(layouts, "headerVariants");
199
+ const footerVariantCount = countUniqueStoryVariants(layouts, "footerVariants");
200
+ const passed = Boolean(
201
+ layouts.length > 0 &&
202
+ layouts.some(
203
+ (layout) =>
204
+ (check.orientation === undefined || layout.orientation === check.orientation) &&
205
+ (check.columns === undefined || layout.columns === check.columns) &&
206
+ (check.differentFirstPage === undefined ||
207
+ layout.differentFirstPage === check.differentFirstPage) &&
208
+ (check.differentOddEvenPages === undefined ||
209
+ layout.differentOddEvenPages === check.differentOddEvenPages),
210
+ ) &&
211
+ (check.headerVariantCountAtLeast === undefined ||
212
+ headerVariantCount >= check.headerVariantCountAtLeast) &&
213
+ (check.footerVariantCountAtLeast === undefined ||
214
+ footerVariantCount >= check.footerVariantCountAtLeast)
215
+ );
216
+ return {
217
+ type: check.type,
218
+ passed,
219
+ reason: `expected page layout ${describePageLayoutMatch(check)}`,
220
+ };
221
+ }
222
+ case "commentThreadMatch": {
223
+ const passed = context.renderSnapshot.comments.threads.some((thread) =>
224
+ matchesCommentThread(thread, check),
225
+ );
226
+ return {
227
+ type: check.type,
228
+ passed,
229
+ reason: `expected a comment thread matching ${describeCommentThreadMatch(check)}`,
230
+ };
231
+ }
232
+ default:
233
+ return {
234
+ type: (check as { type: string }).type,
235
+ passed: false,
236
+ reason: `unknown closure validation check type ${(check as { type: string }).type}`,
237
+ };
238
+ }
239
+ }
240
+
241
+ export function extractDocxCommentProof(bytes: Uint8Array): DocxCommentProof {
242
+ const packageFile = readOpcPackage(bytes);
243
+ const documentXml = decodePartText(packageFile.parts.get("/word/document.xml")?.bytes);
244
+ const commentsXml = decodePartText(packageFile.parts.get("/word/comments.xml")?.bytes);
245
+ const commentsExtendedXml = decodePartText(
246
+ packageFile.parts.get("/word/commentsExtended.xml")?.bytes,
247
+ );
248
+ const commentsIdsXml = decodePartText(packageFile.parts.get("/word/commentsIds.xml")?.bytes);
249
+ const peopleXml = decodePartText(packageFile.parts.get("/word/people.xml")?.bytes);
250
+ const parsedComments =
251
+ documentXml && commentsXml
252
+ ? parseCommentsFromOoxml(documentXml, {
253
+ commentsXml,
254
+ commentsExtendedXml,
255
+ commentsIdsXml,
256
+ peopleXml,
257
+ })
258
+ : {
259
+ threads: [],
260
+ definitions: [],
261
+ peopleAuthors: [],
262
+ };
263
+
264
+ return {
265
+ partPresence: {
266
+ commentsXml: commentsXml.length > 0,
267
+ commentsExtendedXml: commentsExtendedXml.length > 0,
268
+ commentsIdsXml: commentsIdsXml.length > 0,
269
+ peopleXml: peopleXml.length > 0,
270
+ },
271
+ serializedCommentIds: parsedComments.definitions.map((definition) => definition.commentId),
272
+ peopleAuthors: [...parsedComments.peopleAuthors],
273
+ threads: parsedComments.threads.map((thread) => ({
274
+ commentId: thread.commentId,
275
+ rootCommentId: thread.metadata?.rootOoxmlCommentId,
276
+ status: thread.status,
277
+ entryCount: thread.entries.length,
278
+ bodies: thread.entries.map((entry) => entry.body),
279
+ authorIds: [...new Set(thread.entries.map((entry) => entry.authorId))],
280
+ anchorKind: thread.anchor.kind,
281
+ })),
282
+ };
283
+ }
284
+
285
+ function hasOpaqueInlineLabelPrefix(
286
+ surface: RuntimeRenderSnapshot["surface"],
287
+ prefix: string,
288
+ ): boolean {
289
+ if (!surface) {
290
+ return false;
291
+ }
292
+
293
+ for (const block of surface.blocks) {
294
+ if (block.kind !== "paragraph") {
295
+ continue;
296
+ }
297
+
298
+ if (
299
+ block.segments.some(
300
+ (segment) =>
301
+ segment.kind === "opaque_inline" &&
302
+ typeof segment.label === "string" &&
303
+ segment.label.startsWith(prefix),
304
+ )
305
+ ) {
306
+ return true;
307
+ }
308
+ }
309
+
310
+ return false;
311
+ }
312
+
313
+ function matchesCommentThread(
314
+ thread: RuntimeRenderSnapshot["comments"]["threads"][number],
315
+ check: Extract<ClosureValidationCheck, { type: "commentThreadMatch" }>,
316
+ ): boolean {
317
+ if (check.commentId && thread.commentId !== check.commentId) {
318
+ return false;
319
+ }
320
+ if (check.status && thread.status !== check.status) {
321
+ return false;
322
+ }
323
+ if (typeof check.entryCount === "number" && thread.entries.length !== check.entryCount) {
324
+ return false;
325
+ }
326
+ if (check.anchorKind && thread.anchor.kind !== check.anchorKind) {
327
+ return false;
328
+ }
329
+ if (check.authorId && thread.createdBy !== check.authorId) {
330
+ return false;
331
+ }
332
+ if (check.bodyIncludes?.length) {
333
+ const bodies = thread.entries.map((entry) => entry.body);
334
+ for (const expectedBody of check.bodyIncludes) {
335
+ if (!bodies.some((body) => body.includes(expectedBody))) {
336
+ return false;
337
+ }
338
+ }
339
+ }
340
+ if (check.entryBodies?.length) {
341
+ if (thread.entries.length < check.entryBodies.length) {
342
+ return false;
343
+ }
344
+ for (let index = 0; index < check.entryBodies.length; index += 1) {
345
+ const expectedBody = check.entryBodies[index];
346
+ const actualBody = thread.entries[index]?.body;
347
+ if (typeof expectedBody !== "string" || actualBody !== expectedBody) {
348
+ return false;
349
+ }
350
+ }
351
+ }
352
+ if (check.entryAuthorIds?.length) {
353
+ if (thread.entries.length < check.entryAuthorIds.length) {
354
+ return false;
355
+ }
356
+ for (let index = 0; index < check.entryAuthorIds.length; index += 1) {
357
+ const expectedAuthorId = check.entryAuthorIds[index];
358
+ const actualAuthorId = thread.entries[index]?.authorId;
359
+ if (typeof expectedAuthorId !== "string" || actualAuthorId !== expectedAuthorId) {
360
+ return false;
361
+ }
362
+ }
363
+ }
364
+ return true;
365
+ }
366
+
367
+ function getLayoutsForCheck(
368
+ context: ClosureValidationContext,
369
+ check: Extract<ClosureValidationCheck, { type: "pageLayoutMatch" }>,
370
+ ) {
371
+ const navigationLayouts =
372
+ context.navigation?.pages
373
+ .filter((page) => check.sectionIndex === undefined || page.sectionIndex === check.sectionIndex)
374
+ .map((page) => page.layout) ?? [];
375
+ if (navigationLayouts.length > 0) {
376
+ return navigationLayouts;
377
+ }
378
+ const activeLayout = context.renderSnapshot.pageLayout;
379
+ if (
380
+ activeLayout &&
381
+ (check.sectionIndex === undefined || activeLayout.sectionIndex === check.sectionIndex)
382
+ ) {
383
+ return [activeLayout];
384
+ }
385
+ return [];
386
+ }
387
+
388
+ function countUniqueStoryVariants(
389
+ layouts: Array<{
390
+ headerVariants: Array<{ variant: string; relationshipId: string }>;
391
+ footerVariants: Array<{ variant: string; relationshipId: string }>;
392
+ }>,
393
+ key: "headerVariants" | "footerVariants",
394
+ ) {
395
+ const variants = new Set<string>();
396
+ for (const layout of layouts) {
397
+ for (const variant of layout[key]) {
398
+ variants.add(`${variant.variant}:${variant.relationshipId}`);
399
+ }
400
+ }
401
+ return variants.size;
402
+ }
403
+
404
+ function describeCommentThreadMatch(
405
+ check: Extract<ClosureValidationCheck, { type: "commentThreadMatch" }>,
406
+ ): string {
407
+ const parts: string[] = [];
408
+ if (check.commentId) {
409
+ parts.push(`commentId=${check.commentId}`);
410
+ }
411
+ if (check.status) {
412
+ parts.push(`status=${check.status}`);
413
+ }
414
+ if (typeof check.entryCount === "number") {
415
+ parts.push(`entryCount=${check.entryCount}`);
416
+ }
417
+ if (check.anchorKind) {
418
+ parts.push(`anchorKind=${check.anchorKind}`);
419
+ }
420
+ if (check.authorId) {
421
+ parts.push(`authorId=${check.authorId}`);
422
+ }
423
+ if (check.bodyIncludes?.length) {
424
+ parts.push(`bodyIncludes=${check.bodyIncludes.join(" | ")}`);
425
+ }
426
+ if (check.entryBodies?.length) {
427
+ parts.push(`entryBodies=${check.entryBodies.join(" | ")}`);
428
+ }
429
+ if (check.entryAuthorIds?.length) {
430
+ parts.push(`entryAuthorIds=${check.entryAuthorIds.join(" | ")}`);
431
+ }
432
+ return parts.length > 0 ? parts.join(", ") : "any thread";
433
+ }
434
+
435
+ function describeSecondaryStoryMatch(
436
+ check: Extract<ClosureValidationCheck, { type: "secondaryStoryMatch" }>,
437
+ ): string {
438
+ const parts = [`kind=${check.kind}`];
439
+ if (check.relationshipId) {
440
+ parts.push(`relationshipId=${check.relationshipId}`);
441
+ }
442
+ if (check.variant) {
443
+ parts.push(`variant=${check.variant}`);
444
+ }
445
+ if (check.noteId) {
446
+ parts.push(`noteId=${check.noteId}`);
447
+ }
448
+ if (typeof check.minBlockCount === "number") {
449
+ parts.push(`minBlockCount=${check.minBlockCount}`);
450
+ }
451
+ return parts.join(", ");
452
+ }
453
+
454
+ function describePageLayoutMatch(
455
+ check: Extract<ClosureValidationCheck, { type: "pageLayoutMatch" }>,
456
+ ): string {
457
+ const parts: string[] = [];
458
+ if (typeof check.sectionIndex === "number") {
459
+ parts.push(`sectionIndex=${check.sectionIndex}`);
460
+ }
461
+ if (check.orientation) {
462
+ parts.push(`orientation=${check.orientation}`);
463
+ }
464
+ if (typeof check.columns === "number") {
465
+ parts.push(`columns=${check.columns}`);
466
+ }
467
+ if (typeof check.differentFirstPage === "boolean") {
468
+ parts.push(`differentFirstPage=${check.differentFirstPage}`);
469
+ }
470
+ if (typeof check.differentOddEvenPages === "boolean") {
471
+ parts.push(`differentOddEvenPages=${check.differentOddEvenPages}`);
472
+ }
473
+ if (typeof check.headerVariantCountAtLeast === "number") {
474
+ parts.push(`headerVariantCountAtLeast=${check.headerVariantCountAtLeast}`);
475
+ }
476
+ if (typeof check.footerVariantCountAtLeast === "number") {
477
+ parts.push(`footerVariantCountAtLeast=${check.footerVariantCountAtLeast}`);
478
+ }
479
+ return parts.length > 0 ? parts.join(", ") : "present";
480
+ }
481
+
482
+ function decodePartText(bytes: Uint8Array | undefined): string {
483
+ if (!bytes) {
484
+ return "";
485
+ }
486
+ return new TextDecoder("utf-8").decode(bytes);
487
+ }