@beyondwork/docx-react-component 1.0.10 → 1.0.12

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml)
4
4
 
5
- `@beyondwork/docx-react-component` is the shipped product in this repository: `WordReviewEditor`, a fidelity-first React editor for legal-review-safe `docx` workflows. Wave 21 lands the package-facing docs, npm metadata, publish workflow, and `pnpm pack` proof for this surface.
5
+ `@beyondwork/docx-react-component` is the shipped product in this repository: `WordReviewEditor`, a fidelity-first React editor for legal-review-safe `docx` workflows. Waves 20 through 23 land live table editing, package distribution, validator-backed CI, and legal workflow helpers. Wave 24 is the next production-hardening packet.
6
6
 
7
7
  The broader repository is still evolving toward a layered `react-ooxml-office` platform, but the source reality is unchanged:
8
8
 
@@ -43,6 +43,11 @@ import { WordReviewEditor } from "@beyondwork/docx-react-component";
43
43
  - runtime owns the live working session
44
44
  - host receives events, warnings, errors, snapshots, and exported artifacts
45
45
 
46
+ Snapshot/export note:
47
+
48
+ - when a session starts from a real `.docx`, persisted snapshots now carry embedded source-package provenance so later snapshot-origin `.docx` export can use the same package-backed exporter
49
+ - legacy snapshots without that provenance still load, but `.docx` export is intentionally blocked rather than falling back to a lossy minimal package
50
+
46
51
  The current public ESM exports are:
47
52
 
48
53
  - `@beyondwork/docx-react-component` -> `WordReviewEditor`
@@ -87,7 +92,7 @@ Start here:
87
92
  Current shipped docx contracts:
88
93
 
89
94
  - `docs/reference/public-api.md`
90
- This doc separates the shipped Wave 21 surface from the future Waves 25 through 27 ref expansion.
95
+ This doc separates the shipped Waves 20-23 surface from the future Waves 25 through 27 ref expansion.
91
96
  - `docs/reference/ooxml-compliance.md`
92
97
  - `docs/reference/word-review-editor-frontend-architecture.md`
93
98
  - `docs/reference/word-review-editor-ux-guide.md`
@@ -117,6 +122,7 @@ Shared platform and planned xlsx docs:
117
122
  - do not silently drop unknown OOXML
118
123
  - keep docs honest about shipped versus planned behavior
119
124
  - add or extend fixtures for compatibility-critical changes
125
+ - `bash scripts/validate-fixtures.sh` now uses the Railway validator service and requires `OPENXML_VALIDATOR_AUTH_TOKEN` when hitting the public domain
120
126
 
121
127
  ## Guiding Principle
122
128
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.10",
4
+ "version": "1.0.12",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -33,14 +33,6 @@
33
33
  "types": "./src/api/public-types.ts",
34
34
  "import": "./src/api/public-types.ts"
35
35
  },
36
- "./io/docx-session": {
37
- "types": "./src/io/docx-session.ts",
38
- "import": "./src/io/docx-session.ts"
39
- },
40
- "./runtime/document-runtime": {
41
- "types": "./src/runtime/document-runtime.ts",
42
- "import": "./src/runtime/document-runtime.ts"
43
- },
44
36
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css",
45
37
  "./package.json": "./package.json"
46
38
  },
@@ -90,7 +82,7 @@
90
82
  "tailwindcss": "^4.2.2"
91
83
  },
92
84
  "devDependencies": {
93
- "@chllming/wave-orchestration": "^0.9.12",
85
+ "@chllming/wave-orchestration": "^0.9.13",
94
86
  "@types/react": "19.2.14",
95
87
  "@types/react-dom": "19.2.3",
96
88
  "@typescript/native-preview": "7.0.0-dev.20260409.1",
@@ -110,7 +102,10 @@
110
102
  "scripts": {
111
103
  "build": "tsup",
112
104
  "test": "bash scripts/run-workspace-tests.sh",
113
- "test:repo": "pnpm exec tsx --test $(find test -type f \\( -name '*.test.ts' -o -name '*.test.tsx' \\) | sort)",
105
+ "test:repo": "node scripts/run-repo-tests.mjs core",
106
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
107
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
108
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
114
109
  "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
115
110
  "lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
116
111
  "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
@@ -127,7 +127,16 @@ export type SurfaceTextMark =
127
127
  | "bold"
128
128
  | "italic"
129
129
  | "underline"
130
- | "strikethrough";
130
+ | "strikethrough"
131
+ | "doubleStrikethrough"
132
+ | "superscript"
133
+ | "subscript"
134
+ | "vanish"
135
+ | "emboss"
136
+ | "imprint"
137
+ | "shadow"
138
+ | "smallCaps"
139
+ | "allCaps";
131
140
 
132
141
  export type SurfaceInlineSegment =
133
142
  | {
@@ -137,6 +146,15 @@ export type SurfaceInlineSegment =
137
146
  to: number;
138
147
  text: string;
139
148
  marks?: SurfaceTextMark[];
149
+ markAttrs?: {
150
+ backgroundColor?: string;
151
+ charSpacing?: number;
152
+ kerning?: number;
153
+ textFill?: string;
154
+ fontFamily?: string;
155
+ fontSize?: number;
156
+ textColor?: string;
157
+ };
140
158
  hyperlinkHref?: string;
141
159
  }
142
160
  | {
@@ -192,6 +210,17 @@ export type SurfaceBlockSnapshot =
192
210
  numberingInstanceId: string;
193
211
  level: number;
194
212
  };
213
+ alignment?: "left" | "center" | "right" | "both" | "distribute";
214
+ spacing?: { before?: number; after?: number; line?: number; lineRule?: string };
215
+ indentation?: { left?: number; right?: number; firstLine?: number; hanging?: number };
216
+ borders?: { top?: unknown; left?: unknown; bottom?: unknown; right?: unknown; bar?: unknown; between?: unknown };
217
+ shading?: { fill?: string; color?: string; val?: string };
218
+ tabStops?: Array<{ pos: number; val?: string; leader?: string }>;
219
+ keepNext?: boolean;
220
+ keepLines?: boolean;
221
+ pageBreakBefore?: boolean;
222
+ outlineLevel?: number;
223
+ bidi?: boolean;
195
224
  segments: SurfaceInlineSegment[];
196
225
  }
197
226
  | {
@@ -346,6 +375,7 @@ export interface PersistedEditorSnapshot {
346
375
  canonicalDocument: RuntimePersistedEditorSnapshot["canonicalDocument"];
347
376
  compatibility: CompatibilityReport;
348
377
  warningLog: EditorWarning[];
378
+ sourcePackage?: RuntimePersistedEditorSnapshot["sourcePackage"];
349
379
  }
350
380
 
351
381
  export interface AddCommentParams {
@@ -509,6 +539,7 @@ export interface WordReviewEditorRef {
509
539
  getComments(): CommentSidebarSnapshot;
510
540
  getTrackedChanges(): TrackedChangesSnapshot;
511
541
  scrollToRevision(revisionId: string): void;
542
+ scrollToComment(commentId: string): void;
512
543
  }
513
544
 
514
545
  export interface WordReviewEditorProps {
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  DEFAULT_BOUNDARY_ASSOC,
3
3
  createDetachedAnchor,
4
+ createNodeAnchor,
4
5
  createRangeAnchor,
5
6
  type EditorAnchorProjection,
6
7
  } from "../selection/mapping.ts";
8
+ import { isUuid } from "../../model/cds-1.0.0.ts";
7
9
  import {
8
10
  assertPersistedEditorSnapshot as assertModelPersistedEditorSnapshot,
9
11
  createPersistedEditorSnapshot as createModelPersistedEditorSnapshot,
12
+ validatePersistedEditorSnapshot as validateModelPersistedEditorSnapshot,
10
13
  type PersistedEditorSnapshot as ModelPersistedEditorSnapshot,
11
14
  } from "../../model/snapshot.ts";
12
15
  import {
@@ -175,6 +178,7 @@ export interface EditorState {
175
178
  documentId: string;
176
179
  sessionId: string;
177
180
  sourceLabel?: string;
181
+ sourcePackage?: ModelPersistedEditorSnapshot["sourcePackage"];
178
182
  revision: number;
179
183
  revisionToken: string;
180
184
  isDirty: boolean;
@@ -268,29 +272,32 @@ export function createEmptyCompatibilityReport(generatedAt: string): Compatibili
268
272
 
269
273
  export function createEditorState(options: CreateEditorStateOptions): EditorState {
270
274
  const timestamp = new Date(0).toISOString();
271
- if (options.persistedSnapshot) {
272
- assertModelPersistedEditorSnapshot(options.persistedSnapshot as unknown);
273
- }
275
+ const normalizedSnapshot = options.persistedSnapshot
276
+ ? normalizePersistedSnapshotForRuntime(options.persistedSnapshot, options.documentId, timestamp)
277
+ : undefined;
274
278
  if (options.canonicalDocument) {
275
279
  assertCanonicalDocument(options.canonicalDocument as unknown);
276
280
  }
277
281
 
278
- const normalizedDocument = options.persistedSnapshot
279
- ? structuredClone(options.persistedSnapshot.canonicalDocument)
282
+ const normalizedDocument = normalizedSnapshot
283
+ ? structuredClone(normalizedSnapshot.canonicalDocument)
280
284
  : options.canonicalDocument
281
285
  ? structuredClone(options.canonicalDocument)
282
286
  : createDefaultCanonicalDocument(options.documentId, timestamp);
283
- const warnings = options.persistedSnapshot?.warningLog ?? options.warnings ?? [];
287
+ const warnings = normalizedSnapshot
288
+ ? normalizeWarningLog(normalizedSnapshot.warningLog)
289
+ : options.warnings ?? [];
284
290
  const compatibility =
285
- options.persistedSnapshot?.compatibility ??
286
- options.compatibility ??
287
- createEmptyCompatibilityReport(normalizedDocument.updatedAt);
291
+ normalizedSnapshot
292
+ ? normalizeCompatibilityReport(normalizedSnapshot.compatibility)
293
+ : options.compatibility ?? createEmptyCompatibilityReport(normalizedDocument.updatedAt);
288
294
 
289
295
  return {
290
296
  phase: options.fatalError ? "error" : "ready",
291
297
  documentId: options.documentId,
292
298
  sessionId: options.sessionId,
293
299
  sourceLabel: options.sourceLabel,
300
+ sourcePackage: normalizedSnapshot?.sourcePackage,
294
301
  revision: 0,
295
302
  revisionToken: `${options.sessionId}:0`,
296
303
  isDirty: false,
@@ -306,6 +313,265 @@ export function createEditorState(options: CreateEditorStateOptions): EditorStat
306
313
  };
307
314
  }
308
315
 
316
+ function normalizePersistedSnapshotForRuntime(
317
+ snapshot: PersistedEditorSnapshot,
318
+ documentId: string,
319
+ timestamp: string,
320
+ ): PersistedEditorSnapshot {
321
+ const issues = validateModelPersistedEditorSnapshot(snapshot as unknown);
322
+ if (issues.length === 0) {
323
+ return snapshot;
324
+ }
325
+
326
+ if (!isRecord(snapshot) || !isRecord(snapshot.canonicalDocument)) {
327
+ assertModelPersistedEditorSnapshot(snapshot as unknown);
328
+ }
329
+
330
+ const normalizedDocument = normalizeCanonicalDocumentEnvelope(
331
+ snapshot.canonicalDocument,
332
+ documentId,
333
+ timestamp,
334
+ );
335
+ try {
336
+ assertCanonicalDocument(normalizedDocument as unknown);
337
+ } catch {
338
+ assertModelPersistedEditorSnapshot(snapshot as unknown);
339
+ }
340
+ const repairWarning = createSnapshotRepairWarning(issues.length);
341
+ const compatibility = coerceCompatibilityReport(
342
+ snapshot.compatibility,
343
+ normalizedDocument.updatedAt,
344
+ repairWarning,
345
+ );
346
+
347
+ return {
348
+ snapshotVersion:
349
+ snapshot.snapshotVersion === "persisted-editor-snapshot/1" ||
350
+ snapshot.snapshotVersion === "persisted-editor-snapshot/2"
351
+ ? snapshot.snapshotVersion
352
+ : "persisted-editor-snapshot/2",
353
+ schemaVersion: "cds/1.0.0",
354
+ documentId:
355
+ typeof snapshot.documentId === "string" && snapshot.documentId.length > 0
356
+ ? snapshot.documentId
357
+ : documentId,
358
+ docId: normalizedDocument.docId,
359
+ createdAt: normalizedDocument.createdAt,
360
+ updatedAt: normalizedDocument.updatedAt,
361
+ savedAt:
362
+ typeof snapshot.savedAt === "string" && snapshot.savedAt.length > 0
363
+ ? snapshot.savedAt
364
+ : normalizedDocument.updatedAt,
365
+ editorBuild:
366
+ typeof snapshot.editorBuild === "string" && snapshot.editorBuild.length > 0
367
+ ? snapshot.editorBuild
368
+ : "dev",
369
+ canonicalDocument: normalizedDocument,
370
+ compatibility,
371
+ warningLog: dedupeWarnings([
372
+ ...coerceWarnings(snapshot.warningLog),
373
+ repairWarning,
374
+ ]),
375
+ sourcePackage: snapshot.sourcePackage,
376
+ };
377
+ }
378
+
379
+ function normalizeCanonicalDocumentEnvelope(
380
+ value: unknown,
381
+ documentId: string,
382
+ timestamp: string,
383
+ ): CanonicalDocumentEnvelope {
384
+ const base = createDefaultCanonicalDocument(documentId, timestamp);
385
+ const record = isRecord(value) ? value : {};
386
+ const metadata = isRecord(record.metadata) ? record.metadata : {};
387
+
388
+ return {
389
+ ...base,
390
+ ...record,
391
+ schemaVersion: "cds/1.0.0",
392
+ docId: isUuid(record.docId) ? record.docId : createCanonicalDocumentId(documentId),
393
+ createdAt:
394
+ typeof record.createdAt === "string" && record.createdAt.length > 0
395
+ ? record.createdAt
396
+ : base.createdAt,
397
+ updatedAt:
398
+ typeof record.updatedAt === "string" && record.updatedAt.length > 0
399
+ ? record.updatedAt
400
+ : base.updatedAt,
401
+ metadata: {
402
+ ...base.metadata,
403
+ ...metadata,
404
+ customProperties: isRecord(metadata.customProperties)
405
+ ? (Object.fromEntries(
406
+ Object.entries(metadata.customProperties).filter(([, entry]) => typeof entry === "string"),
407
+ ) as Record<string, string>)
408
+ : {},
409
+ },
410
+ styles:
411
+ record.styles === undefined
412
+ ? base.styles
413
+ : (record.styles as CanonicalDocumentEnvelope["styles"]),
414
+ numbering:
415
+ record.numbering === undefined
416
+ ? base.numbering
417
+ : (record.numbering as CanonicalDocumentEnvelope["numbering"]),
418
+ media:
419
+ record.media === undefined
420
+ ? base.media
421
+ : (record.media as CanonicalDocumentEnvelope["media"]),
422
+ content: isRecord(record.content)
423
+ ? (record.content as unknown as CanonicalDocumentEnvelope["content"])
424
+ : base.content,
425
+ review:
426
+ record.review === undefined
427
+ ? base.review
428
+ : (record.review as CanonicalDocumentEnvelope["review"]),
429
+ preservation:
430
+ record.preservation === undefined
431
+ ? base.preservation
432
+ : (record.preservation as CanonicalDocumentEnvelope["preservation"]),
433
+ diagnostics:
434
+ record.diagnostics === undefined
435
+ ? base.diagnostics
436
+ : (record.diagnostics as CanonicalDocumentEnvelope["diagnostics"]),
437
+ ...(isRecord(record.subParts)
438
+ ? { subParts: record.subParts as unknown as CanonicalDocumentEnvelope["subParts"] }
439
+ : {}),
440
+ };
441
+ }
442
+
443
+ function coerceCompatibilityReport(
444
+ value: unknown,
445
+ generatedAt: string,
446
+ repairWarning: EditorWarning,
447
+ ): CompatibilityReport {
448
+ const record = isRecord(value) ? value : {};
449
+ const warnings = dedupeWarnings([
450
+ ...coerceWarnings(record.warnings),
451
+ repairWarning,
452
+ ]);
453
+
454
+ return {
455
+ reportVersion: "compatibility-report/1",
456
+ generatedAt:
457
+ typeof record.generatedAt === "string" && record.generatedAt.length > 0
458
+ ? record.generatedAt
459
+ : generatedAt,
460
+ blockExport: typeof record.blockExport === "boolean" ? record.blockExport : false,
461
+ featureEntries: Array.isArray(record.featureEntries)
462
+ ? record.featureEntries.flatMap((entry) => {
463
+ if (!isRecord(entry)) {
464
+ return [];
465
+ }
466
+ if (
467
+ typeof entry.featureEntryId !== "string" ||
468
+ typeof entry.featureKey !== "string" ||
469
+ typeof entry.featureClass !== "string" ||
470
+ typeof entry.message !== "string"
471
+ ) {
472
+ return [];
473
+ }
474
+ return [{
475
+ featureEntryId: entry.featureEntryId,
476
+ featureKey: entry.featureKey,
477
+ featureClass: entry.featureClass as CompatibilityFeatureEntry["featureClass"],
478
+ message: entry.message,
479
+ ...(entry.affectedAnchor && isRecord(entry.affectedAnchor)
480
+ ? { affectedAnchor: entry.affectedAnchor as unknown as EditorAnchorProjection }
481
+ : {}),
482
+ ...(isRecord(entry.details) ? { details: entry.details } : {}),
483
+ }];
484
+ })
485
+ : [],
486
+ warnings,
487
+ errors: Array.isArray(record.errors)
488
+ ? record.errors.flatMap((entry) => {
489
+ if (!isRecord(entry)) {
490
+ return [];
491
+ }
492
+ if (
493
+ typeof entry.errorId !== "string" ||
494
+ typeof entry.code !== "string" ||
495
+ typeof entry.message !== "string" ||
496
+ typeof entry.source !== "string" ||
497
+ typeof entry.isFatal !== "boolean"
498
+ ) {
499
+ return [];
500
+ }
501
+ return [{
502
+ errorId: entry.errorId,
503
+ code: entry.code as EditorError["code"],
504
+ message: entry.message,
505
+ source: entry.source as EditorError["source"],
506
+ isFatal: entry.isFatal,
507
+ ...(isRecord(entry.details) ? { details: entry.details } : {}),
508
+ }];
509
+ })
510
+ : [],
511
+ };
512
+ }
513
+
514
+ function coerceWarnings(value: unknown): EditorWarning[] {
515
+ if (!Array.isArray(value)) {
516
+ return [];
517
+ }
518
+
519
+ return value.flatMap((warning) => {
520
+ if (!isRecord(warning)) {
521
+ return [];
522
+ }
523
+ if (
524
+ typeof warning.warningId !== "string" ||
525
+ typeof warning.code !== "string" ||
526
+ typeof warning.severity !== "string" ||
527
+ typeof warning.message !== "string" ||
528
+ typeof warning.source !== "string"
529
+ ) {
530
+ return [];
531
+ }
532
+
533
+ return [{
534
+ warningId: warning.warningId,
535
+ code: warning.code as EditorWarning["code"],
536
+ severity: warning.severity as EditorWarning["severity"],
537
+ message: warning.message,
538
+ source: warning.source as EditorWarning["source"],
539
+ ...(warning.affectedAnchor && isRecord(warning.affectedAnchor)
540
+ ? { affectedAnchor: warning.affectedAnchor as unknown as EditorAnchorProjection }
541
+ : {}),
542
+ ...(typeof warning.featureEntryId === "string"
543
+ ? { featureEntryId: warning.featureEntryId }
544
+ : {}),
545
+ ...(isRecord(warning.details) ? { details: warning.details } : {}),
546
+ }];
547
+ });
548
+ }
549
+
550
+ function createSnapshotRepairWarning(issueCount: number): EditorWarning {
551
+ return {
552
+ warningId: `warning:snapshot-repair:${issueCount}`,
553
+ code: "import_normalized",
554
+ severity: "warning",
555
+ message: `Loaded a persisted snapshot after repairing ${issueCount} validation issue${issueCount === 1 ? "" : "s"}.`,
556
+ source: "validation",
557
+ };
558
+ }
559
+
560
+ function dedupeWarnings(warnings: EditorWarning[]): EditorWarning[] {
561
+ const seen = new Set<string>();
562
+ const result: EditorWarning[] = [];
563
+
564
+ for (const warning of warnings) {
565
+ if (!warning.warningId || seen.has(warning.warningId)) {
566
+ continue;
567
+ }
568
+ seen.add(warning.warningId);
569
+ result.push(warning);
570
+ }
571
+
572
+ return result;
573
+ }
574
+
309
575
  export function deriveDocumentStats(state: Pick<EditorState, "document">): DocumentStats {
310
576
  const serializedContent = extractText(state.document.content);
311
577
 
@@ -381,6 +647,7 @@ export function createPersistedEditorSnapshot(
381
647
  canonicalDocument: state.document,
382
648
  compatibility: (options.compatibility ?? state.compatibility) as never,
383
649
  warningLog: state.warnings as never,
650
+ sourcePackage: state.sourcePackage,
384
651
  });
385
652
  return snapshot as PersistedEditorSnapshot;
386
653
  }
@@ -397,6 +664,48 @@ function estimateParagraphCount(content: unknown): number {
397
664
  return extractText(content).length > 0 ? 1 : 0;
398
665
  }
399
666
 
667
+ function normalizeWarningLog(warnings: EditorWarning[]): EditorWarning[] {
668
+ return warnings.map((warning) => ({
669
+ ...warning,
670
+ affectedAnchor: warning.affectedAnchor
671
+ ? normalizeAnchorProjection(warning.affectedAnchor)
672
+ : undefined,
673
+ }));
674
+ }
675
+
676
+ function normalizeCompatibilityReport(report: CompatibilityReport): CompatibilityReport {
677
+ return {
678
+ ...report,
679
+ featureEntries: report.featureEntries.map((entry) => ({
680
+ ...entry,
681
+ affectedAnchor: entry.affectedAnchor
682
+ ? normalizeAnchorProjection(entry.affectedAnchor)
683
+ : undefined,
684
+ })),
685
+ warnings: normalizeWarningLog(report.warnings),
686
+ };
687
+ }
688
+
689
+ function normalizeAnchorProjection(anchor: EditorAnchorProjection): EditorAnchorProjection {
690
+ switch (anchor.kind) {
691
+ case "range": {
692
+ const rangeAnchor = anchor as EditorAnchorProjection & {
693
+ range?: { from: number; to: number };
694
+ from?: number;
695
+ to?: number;
696
+ assoc: { start: -1 | 1; end: -1 | 1 };
697
+ };
698
+ return rangeAnchor.range
699
+ ? createRangeAnchor(rangeAnchor.range.from, rangeAnchor.range.to, rangeAnchor.assoc)
700
+ : createRangeAnchor(rangeAnchor.from ?? 0, rangeAnchor.to ?? 0, rangeAnchor.assoc);
701
+ }
702
+ case "node":
703
+ return createNodeAnchor(anchor.at, anchor.assoc);
704
+ case "detached":
705
+ return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
706
+ }
707
+ }
708
+
400
709
  function extractText(value: unknown): string {
401
710
  if (typeof value === "string") {
402
711
  return value;