@peaske7/readit 0.1.5 → 0.1.7

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 (52) hide show
  1. package/biome.json +1 -1
  2. package/bun.lock +86 -72
  3. package/docs/plans/2026-03-13-client-mode-design.md +86 -0
  4. package/docs/plans/2026-03-13-client-mode-plan.md +605 -0
  5. package/package.json +12 -11
  6. package/src/App.tsx +23 -6
  7. package/src/cli/index.ts +312 -25
  8. package/src/components/ActionsMenu.tsx +12 -10
  9. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  10. package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
  11. package/src/components/DocumentViewer/InlineCode.tsx +60 -0
  12. package/src/components/FloatingTOC.tsx +4 -2
  13. package/src/components/Header.tsx +3 -1
  14. package/src/components/InlineEditor.tsx +4 -2
  15. package/src/components/MarginNote.tsx +17 -8
  16. package/src/components/RawModal.tsx +9 -7
  17. package/src/components/ReanchorConfirm.tsx +6 -3
  18. package/src/components/SettingsModal.tsx +112 -23
  19. package/src/components/ShortcutCapture.tsx +4 -1
  20. package/src/components/ShortcutList.tsx +50 -9
  21. package/src/components/comments/CommentBadge.tsx +7 -1
  22. package/src/components/comments/CommentInput.tsx +13 -18
  23. package/src/components/comments/CommentListItem.tsx +15 -5
  24. package/src/components/comments/CommentManager.tsx +14 -7
  25. package/src/components/comments/CommentNav.tsx +8 -3
  26. package/src/contexts/CommentContext.tsx +16 -9
  27. package/src/contexts/LayoutContext.tsx +17 -5
  28. package/src/contexts/LocaleContext.tsx +35 -0
  29. package/src/hooks/useClipboard.ts +11 -8
  30. package/src/hooks/useDocument.ts +35 -10
  31. package/src/hooks/useEditorScheme.ts +51 -0
  32. package/src/hooks/useFontPreference.ts +5 -22
  33. package/src/hooks/useKeybindings.ts +6 -18
  34. package/src/hooks/useLocalePreference.ts +42 -0
  35. package/src/index.css +87 -26
  36. package/src/lib/editor-links.ts +59 -0
  37. package/src/lib/highlight/dom.ts +126 -54
  38. package/src/lib/highlight/highlighter.ts +10 -10
  39. package/src/lib/i18n/completeness.test.ts +51 -0
  40. package/src/lib/i18n/en.ts +139 -0
  41. package/src/lib/i18n/index.ts +3 -0
  42. package/src/lib/i18n/ja.ts +141 -0
  43. package/src/lib/i18n/translations.test.ts +39 -0
  44. package/src/lib/i18n/translations.ts +27 -0
  45. package/src/lib/i18n/types.ts +145 -0
  46. package/src/lib/shortcut-registry.ts +1 -1
  47. package/src/lib/utils.ts +11 -0
  48. package/src/main.tsx +4 -1
  49. package/src/server/index.ts +263 -103
  50. package/src/store/index.test.ts +22 -0
  51. package/src/store/index.ts +24 -4
  52. package/src/types/index.ts +12 -0
package/src/index.css CHANGED
@@ -56,17 +56,18 @@ html {
56
56
  --scrollbar-thumb-hover: #aaaaaa;
57
57
 
58
58
  /* Prose */
59
- --prose-body: #3f3f46;
60
- --prose-headings: #18181b;
59
+ --prose-body: #374151;
60
+ --prose-headings: #111827;
61
61
  --prose-links: #2563eb;
62
62
  --prose-links-hover: #1d4ed8;
63
- --prose-bold: #18181b;
64
- --prose-code-bg: #f4f4f5;
65
- --prose-border: #e4e4e7;
66
- --prose-table-header-bg: #fafafa;
67
- --prose-table-even-bg: #fafafa;
68
- --prose-blockquote: #71717a;
69
- --prose-summary-marker: #a1a1aa;
63
+ --prose-bold: #111827;
64
+ --prose-code-bg: #e8e8ec;
65
+ --prose-border: #e5e7eb;
66
+ --prose-table-header-bg: #f9fafb;
67
+ --prose-table-even-bg: #f9fafb;
68
+ --prose-blockquote: #6b7280;
69
+ --prose-blockquote-border: #d1d5db;
70
+ --prose-summary-marker: #9ca3af;
70
71
 
71
72
  /* TOC */
72
73
  --toc-text: rgba(26, 26, 26, 0.5);
@@ -113,15 +114,16 @@ html {
113
114
  --scrollbar-thumb-hover: #71717a;
114
115
 
115
116
  --prose-body: #d4d4d8;
116
- --prose-headings: #fafafa;
117
+ --prose-headings: #f4f4f5;
117
118
  --prose-links: #60a5fa;
118
119
  --prose-links-hover: #93c5fd;
119
- --prose-bold: #fafafa;
120
- --prose-code-bg: #27272a;
120
+ --prose-bold: #f4f4f5;
121
+ --prose-code-bg: #323238;
121
122
  --prose-border: #3f3f46;
122
123
  --prose-table-header-bg: #27272a;
123
124
  --prose-table-even-bg: #1f1f23;
124
125
  --prose-blockquote: #a1a1aa;
126
+ --prose-blockquote-border: #52525b;
125
127
  --prose-summary-marker: #71717a;
126
128
 
127
129
  --toc-text: rgba(228, 228, 231, 0.5);
@@ -467,6 +469,7 @@ mark[data-pending] {
467
469
  color: var(--prose-body);
468
470
  user-select: text;
469
471
  -webkit-user-select: text;
472
+ letter-spacing: -0.011em;
470
473
  }
471
474
 
472
475
  /* Headings */
@@ -477,27 +480,43 @@ mark[data-pending] {
477
480
  .prose h5,
478
481
  .prose h6 {
479
482
  font-weight: 600;
480
- line-height: 1.25;
483
+ line-height: 1.3;
481
484
  margin-top: 2em;
482
485
  margin-bottom: 0.75em;
483
486
  color: var(--prose-headings);
487
+ letter-spacing: -0.02em;
484
488
  }
485
489
 
486
490
  .prose h1 {
487
491
  font-size: 2.25em;
488
492
  margin-top: 0;
493
+ margin-bottom: 0.889em;
494
+ font-weight: 800;
495
+ line-height: 1.111;
489
496
  }
490
497
 
491
498
  .prose h2 {
492
499
  font-size: 1.5em;
500
+ margin-top: 2em;
501
+ margin-bottom: 1em;
502
+ font-weight: 700;
503
+ line-height: 1.333;
504
+ padding-bottom: 0.4em;
505
+ border-bottom: 1px solid var(--prose-border);
493
506
  }
494
507
 
495
508
  .prose h3 {
496
509
  font-size: 1.25em;
510
+ margin-top: 1.6em;
511
+ margin-bottom: 0.6em;
512
+ line-height: 1.6;
497
513
  }
498
514
 
499
515
  .prose h4 {
500
- font-size: 1.125em;
516
+ font-size: 1.1em;
517
+ margin-top: 1.5em;
518
+ margin-bottom: 0.5em;
519
+ line-height: 1.5;
501
520
  }
502
521
 
503
522
  /* Paragraphs and spacing */
@@ -516,12 +535,21 @@ mark[data-pending] {
516
535
  /* Lists */
517
536
  .prose ul,
518
537
  .prose ol {
519
- padding-left: 1.5em;
538
+ padding-left: 1.625em;
520
539
  margin: 1.25em 0;
521
540
  }
522
541
 
523
542
  .prose li {
524
543
  margin: 0.5em 0;
544
+ padding-left: 0.375em;
545
+ }
546
+
547
+ .prose li::marker {
548
+ color: #9ca3af;
549
+ }
550
+
551
+ .dark .prose li::marker {
552
+ color: #6b7280;
525
553
  }
526
554
 
527
555
  .prose ul {
@@ -535,46 +563,67 @@ mark[data-pending] {
535
563
  /* Nested lists */
536
564
  .prose li > ul,
537
565
  .prose li > ol {
538
- margin: 0.5em 0;
566
+ margin: 0.35em 0;
539
567
  }
540
568
 
541
569
  /* Code */
542
570
  .prose code {
543
571
  background: var(--prose-code-bg);
544
- padding: 0.2em 0.4em;
545
- border-radius: 0.25em;
546
- font-size: 0.875em;
572
+ padding: 0.15em 0.4em;
573
+ border-radius: 0.3em;
574
+ font-size: 0.85em;
575
+ font-weight: 500;
547
576
  }
548
577
 
549
578
  .prose pre {
550
- margin: 1.5em 0;
579
+ margin: 1.75em 0;
551
580
  }
552
581
 
553
582
  .prose pre code {
554
583
  background: transparent;
555
584
  padding: 0;
556
- font-size: 0.875em;
585
+ font-size: 0.85em;
586
+ font-weight: 400;
557
587
  color: inherit;
558
588
  }
559
589
 
590
+ /* Editor links — clickable file paths */
591
+ .prose a.editor-link {
592
+ text-decoration: none;
593
+ color: inherit;
594
+ border-bottom: 1px dashed var(--prose-links);
595
+ transition: border-color 0.15s;
596
+ }
597
+
598
+ .prose a.editor-link:hover {
599
+ border-bottom-style: solid;
600
+ }
601
+
602
+ .prose a.editor-link code {
603
+ cursor: pointer;
604
+ }
605
+
560
606
  /* Tables */
561
607
  .prose table {
562
608
  width: 100%;
563
609
  border-collapse: collapse;
564
- margin: 1.5em 0;
610
+ margin: 1.75em 0;
565
611
  font-size: 0.9em;
566
612
  }
567
613
 
568
614
  .prose th,
569
615
  .prose td {
570
616
  border: 1px solid var(--prose-border);
571
- padding: 0.75em 1em;
617
+ padding: 0.625em 1em;
572
618
  text-align: left;
619
+ line-height: 1.6;
573
620
  }
574
621
 
575
622
  .prose th {
576
623
  background: var(--prose-table-header-bg);
577
624
  font-weight: 600;
625
+ font-size: 0.875em;
626
+ letter-spacing: 0.01em;
578
627
  }
579
628
 
580
629
  .prose tr:nth-child(even) {
@@ -583,8 +632,8 @@ mark[data-pending] {
583
632
 
584
633
  /* Blockquotes */
585
634
  .prose blockquote {
586
- border-left: 4px solid var(--prose-border);
587
- padding-left: 1em;
635
+ border-left: 3px solid var(--prose-blockquote-border);
636
+ padding-left: 1.125em;
588
637
  margin: 1.5em 0;
589
638
  color: var(--prose-blockquote);
590
639
  font-style: italic;
@@ -597,7 +646,10 @@ mark[data-pending] {
597
646
  /* Links */
598
647
  .prose a {
599
648
  color: var(--prose-links);
649
+ font-weight: 500;
600
650
  text-decoration: underline;
651
+ text-underline-offset: 2px;
652
+ text-decoration-thickness: 1px;
601
653
  }
602
654
 
603
655
  .prose a:hover {
@@ -608,7 +660,7 @@ mark[data-pending] {
608
660
  .prose hr {
609
661
  border: none;
610
662
  border-top: 1px solid var(--prose-border);
611
- margin: 2em 0;
663
+ margin: 3em 0;
612
664
  }
613
665
 
614
666
  /* Task lists (checkboxes) */
@@ -628,12 +680,21 @@ mark[data-pending] {
628
680
  .prose strong {
629
681
  font-weight: 600;
630
682
  color: var(--prose-bold);
683
+ letter-spacing: -0.01em;
631
684
  }
632
685
 
633
686
  .prose em {
634
687
  font-style: italic;
635
688
  }
636
689
 
690
+ /* First paragraph after heading - tighter spacing */
691
+ .prose h1 + p,
692
+ .prose h2 + p,
693
+ .prose h3 + p,
694
+ .prose h4 + p {
695
+ margin-top: 0.75em;
696
+ }
697
+
637
698
  /* Fullscreen prose - left-aligned, full width */
638
699
  .prose-fullscreen {
639
700
  max-width: none;
@@ -0,0 +1,59 @@
1
+ import { type EditorScheme, EditorSchemes } from "../types";
2
+
3
+ // Known source file extensions — kept broad to cover common languages
4
+ const EXTENSIONS =
5
+ "ts|tsx|js|jsx|mjs|cjs|json|md|mdx|css|scss|html|htm|py|rs|go|rb|java|kt|yml|yaml|toml|sh|bash|zsh|sql|graphql|gql|vue|svelte|astro|c|cpp|h|hpp|cs|swift|zig|lua|ex|exs|erl|hrl|elm|clj|cljs|ml|mli|fs|fsx|r|jl|dart|tf|hcl|proto|xml|svg";
6
+
7
+ // Match file paths with known extensions and optional :line[:col] suffix
8
+ // Requires at least one `/` to avoid matching bare filenames like "README.md"
9
+ const FILE_PATH_RE = new RegExp(
10
+ `^(?:\\.\\.\\/|\\.\\/)?((?:[\\w@.-]+\\/)+[\\w.-]+\\.(?:${EXTENSIONS}))(?::(\\d+)(?::(\\d+))?)?$`,
11
+ );
12
+
13
+ export interface FilePathMatch {
14
+ path: string;
15
+ line?: number;
16
+ col?: number;
17
+ }
18
+
19
+ export function parseFilePath(text: string): FilePathMatch | undefined {
20
+ const match = FILE_PATH_RE.exec(text.trim());
21
+ if (!match) return undefined;
22
+
23
+ return {
24
+ path: match[1],
25
+ line: match[2] ? Number.parseInt(match[2], 10) : undefined,
26
+ col: match[3] ? Number.parseInt(match[3], 10) : undefined,
27
+ };
28
+ }
29
+
30
+ export function buildEditorUri(
31
+ scheme: EditorScheme,
32
+ absolutePath: string,
33
+ line?: number,
34
+ col?: number,
35
+ ): string {
36
+ if (scheme === EditorSchemes.NONE) return "";
37
+
38
+ // vscode://file/path:line:col
39
+ let uri = `${scheme}://file/${absolutePath}`;
40
+ if (line !== undefined) {
41
+ uri += `:${line}`;
42
+ if (col !== undefined) {
43
+ uri += `:${col}`;
44
+ }
45
+ }
46
+ return uri;
47
+ }
48
+
49
+ export function resolveAbsolutePath(
50
+ relativePath: string,
51
+ workingDirectory: string,
52
+ ): string {
53
+ if (relativePath.startsWith("/")) return relativePath;
54
+
55
+ const base = workingDirectory.endsWith("/")
56
+ ? workingDirectory
57
+ : `${workingDirectory}/`;
58
+ return `${base}${relativePath}`;
59
+ }
@@ -147,6 +147,21 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
147
147
  export interface ExtendedHighlightStyle extends HighlightStyle {
148
148
  colorIndex?: number;
149
149
  isBracketMode?: boolean;
150
+ isBracketStart?: boolean;
151
+ isBracketEnd?: boolean;
152
+ }
153
+
154
+ interface BatchedHighlightSegment {
155
+ startOffset: number;
156
+ endOffset: number;
157
+ style: ExtendedHighlightStyle;
158
+ }
159
+
160
+ interface NodeSegment {
161
+ start: number;
162
+ end: number;
163
+ style: ExtendedHighlightStyle;
164
+ order: number;
150
165
  }
151
166
 
152
167
  /**
@@ -217,78 +232,135 @@ export function applyHighlightToRange(
217
232
  }
218
233
  }
219
234
 
220
- /**
221
- * Apply highlight with extended styling (color index, bracket mode) for saved comments.
222
- */
223
- export function applyHighlightWithStyle(
224
- root: HTMLElement,
225
- textContent: string,
226
- startOffset: number,
227
- endOffset: number,
235
+ function createStyledMark(
236
+ text: string,
228
237
  style: ExtendedHighlightStyle,
229
- ): void {
230
- const textNodes = collectTextNodes(root);
231
- const overlappingNodes = textNodes.filter(
232
- (n) => n.end > startOffset && n.start < endOffset,
233
- );
238
+ ): HTMLElement {
239
+ const mark = document.createElement("mark");
240
+ mark.setAttribute(style.attribute, style.attributeValue);
234
241
 
235
- if (overlappingNodes.length === 0) {
236
- return;
242
+ if (style.colorIndex !== undefined) {
243
+ mark.setAttribute("data-color-index", String(style.colorIndex % 4));
237
244
  }
238
245
 
239
- const lineCount = countLinesInRange(textContent, startOffset, endOffset);
240
- const useBracketMode =
241
- style.isBracketMode ?? lineCount >= BRACKET_MODE_LINE_THRESHOLD;
246
+ if (style.isBracketMode) {
247
+ mark.setAttribute("data-bracket-mode", "true");
248
+ if (style.isBracketStart) {
249
+ mark.setAttribute("data-bracket-start", "true");
250
+ }
251
+ if (style.isBracketEnd) {
252
+ mark.setAttribute("data-bracket-end", "true");
253
+ }
254
+ }
242
255
 
243
- let isFirst = true;
256
+ mark.textContent = text;
257
+ return mark;
258
+ }
244
259
 
245
- for (let i = 0; i < overlappingNodes.length; i++) {
246
- const { node: textNode, start } = overlappingNodes[i];
247
- const isLast = i === overlappingNodes.length - 1;
260
+ function normalizeNodeSegments(segments: NodeSegment[]): NodeSegment[] {
261
+ const sorted = [...segments].sort((a, b) => {
262
+ if (a.start !== b.start) return a.start - b.start;
263
+ if (a.end !== b.end) return b.end - a.end;
264
+ return a.order - b.order;
265
+ });
248
266
 
249
- const nodeStart = Math.max(0, startOffset - start);
250
- const nodeEnd = Math.min(textNode.length, endOffset - start);
267
+ const normalized: NodeSegment[] = [];
268
+ let coveredUntil = 0;
251
269
 
252
- if (nodeStart >= nodeEnd) {
253
- continue;
254
- }
270
+ for (const segment of sorted) {
271
+ const start = Math.max(segment.start, coveredUntil);
272
+ if (start >= segment.end) continue;
255
273
 
256
- const range = document.createRange();
257
- range.setStart(textNode, nodeStart);
258
- range.setEnd(textNode, nodeEnd);
274
+ normalized.push({ ...segment, start });
275
+ coveredUntil = segment.end;
276
+ }
259
277
 
260
- const mark = document.createElement("mark");
261
- mark.setAttribute(style.attribute, style.attributeValue);
278
+ return normalized;
279
+ }
280
+
281
+ export function applyHighlightBatch(
282
+ root: HTMLElement,
283
+ textContent: string,
284
+ highlights: BatchedHighlightSegment[],
285
+ ): void {
286
+ if (highlights.length === 0) return;
262
287
 
263
- if (style.colorIndex !== undefined) {
264
- mark.setAttribute("data-color-index", String(style.colorIndex % 4));
288
+ const textNodes = collectTextNodes(root);
289
+ const segmentsByNode = new Map<Text, NodeSegment[]>();
290
+
291
+ for (
292
+ let highlightIndex = 0;
293
+ highlightIndex < highlights.length;
294
+ highlightIndex++
295
+ ) {
296
+ const highlight = highlights[highlightIndex];
297
+ const overlappingNodes = textNodes.filter(
298
+ (n) => n.end > highlight.startOffset && n.start < highlight.endOffset,
299
+ );
300
+
301
+ if (overlappingNodes.length === 0) continue;
302
+
303
+ const lineCount = countLinesInRange(
304
+ textContent,
305
+ highlight.startOffset,
306
+ highlight.endOffset,
307
+ );
308
+ const useBracketMode =
309
+ highlight.style.isBracketMode ?? lineCount >= BRACKET_MODE_LINE_THRESHOLD;
310
+
311
+ for (let nodeIndex = 0; nodeIndex < overlappingNodes.length; nodeIndex++) {
312
+ const { node, start } = overlappingNodes[nodeIndex];
313
+ const localStart = Math.max(0, highlight.startOffset - start);
314
+ const localEnd = Math.min(node.length, highlight.endOffset - start);
315
+
316
+ if (localStart >= localEnd) continue;
317
+
318
+ const nodeSegments = segmentsByNode.get(node) ?? [];
319
+ nodeSegments.push({
320
+ start: localStart,
321
+ end: localEnd,
322
+ order: highlightIndex,
323
+ style: {
324
+ ...highlight.style,
325
+ isBracketMode: useBracketMode,
326
+ isBracketStart: useBracketMode && nodeIndex === 0,
327
+ isBracketEnd:
328
+ useBracketMode && nodeIndex === overlappingNodes.length - 1,
329
+ },
330
+ });
331
+ segmentsByNode.set(node, nodeSegments);
265
332
  }
333
+ }
266
334
 
267
- if (useBracketMode) {
268
- mark.setAttribute("data-bracket-mode", "true");
269
- if (isFirst) {
270
- mark.setAttribute("data-bracket-start", "true");
271
- }
272
- if (isLast) {
273
- mark.setAttribute("data-bracket-end", "true");
335
+ for (const { node } of textNodes) {
336
+ const segments = segmentsByNode.get(node);
337
+ if (!segments || segments.length === 0) continue;
338
+
339
+ const normalized = normalizeNodeSegments(segments);
340
+ if (normalized.length === 0) continue;
341
+
342
+ const text = node.textContent ?? "";
343
+ const fragment = document.createDocumentFragment();
344
+ let cursor = 0;
345
+
346
+ for (const segment of normalized) {
347
+ if (cursor < segment.start) {
348
+ fragment.appendChild(
349
+ document.createTextNode(text.slice(cursor, segment.start)),
350
+ );
274
351
  }
352
+
353
+ fragment.appendChild(
354
+ createStyledMark(text.slice(segment.start, segment.end), segment.style),
355
+ );
356
+ cursor = segment.end;
275
357
  }
276
358
 
277
- try {
278
- range.surroundContents(mark);
279
- } catch {
280
- // Range crosses element boundaries - use extractContents fallback
281
- try {
282
- const fragment = range.extractContents();
283
- mark.appendChild(fragment);
284
- range.insertNode(mark);
285
- } catch (err) {
286
- // Skip if fallback also fails, but log for debugging
287
- console.warn("[highlight] Failed to apply styled highlight:", err);
288
- }
359
+ if (cursor < text.length) {
360
+ fragment.appendChild(document.createTextNode(text.slice(cursor)));
289
361
  }
290
362
 
291
- isFirst = false;
363
+ node.replaceWith(fragment);
292
364
  }
293
365
  }
294
366
 
@@ -1,7 +1,7 @@
1
1
  import { findTextPosition } from "./core";
2
2
  import {
3
+ applyHighlightBatch,
3
4
  applyHighlightToRange,
4
- applyHighlightWithStyle,
5
5
  clearHighlights,
6
6
  collectHighlightPositions,
7
7
  getDOMTextContent,
@@ -195,19 +195,19 @@ function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
195
195
  .filter((c): c is HighlightComment => c !== null)
196
196
  .sort((a, b) => a.startOffset - b.startOffset);
197
197
 
198
- for (const comment of resolved) {
199
- applyHighlightWithStyle(
200
- root,
201
- textContent,
202
- comment.startOffset,
203
- comment.endOffset,
204
- {
198
+ applyHighlightBatch(
199
+ root,
200
+ textContent,
201
+ resolved.map((comment) => ({
202
+ startOffset: comment.startOffset,
203
+ endOffset: comment.endOffset,
204
+ style: {
205
205
  attribute: "data-comment-id",
206
206
  attributeValue: comment.id,
207
207
  colorIndex: 0,
208
208
  },
209
- );
210
- }
209
+ })),
210
+ );
211
211
 
212
212
  // Defer position update to next frame to ensure browser has completed layout
213
213
  // after DOM changes from highlight application
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { en } from "./en";
3
+ import { ja } from "./ja";
4
+
5
+ describe("translation completeness", () => {
6
+ const enKeys = Object.keys(en).sort();
7
+ const jaKeys = Object.keys(ja).sort();
8
+
9
+ it("en and ja have the same keys", () => {
10
+ expect(enKeys).toEqual(jaKeys);
11
+ });
12
+
13
+ // Prefix/suffix keys may be intentionally empty in some locales
14
+ // (e.g., Japanese has no prefix before the command)
15
+ const ALLOW_EMPTY = new Set(["app.noDocumentsHintPrefix"]);
16
+
17
+ it("no empty string values in en", () => {
18
+ for (const [key, value] of Object.entries(en)) {
19
+ if (ALLOW_EMPTY.has(key)) continue;
20
+ expect(value, `en.${key} is empty`).not.toBe("");
21
+ }
22
+ });
23
+
24
+ it("no empty string values in ja", () => {
25
+ for (const [key, value] of Object.entries(ja)) {
26
+ if (ALLOW_EMPTY.has(key)) continue;
27
+ expect(value, `ja.${key} is empty`).not.toBe("");
28
+ }
29
+ });
30
+
31
+ it("interpolation placeholders match between locales", () => {
32
+ const placeholderPattern = /\{\{(\w+)\}\}/g;
33
+
34
+ for (const key of enKeys) {
35
+ const enValue = en[key as keyof typeof en];
36
+ const jaValue = ja[key as keyof typeof ja];
37
+
38
+ const enPlaceholders = [...enValue.matchAll(placeholderPattern)]
39
+ .map((m) => m[1])
40
+ .sort();
41
+ const jaPlaceholders = [...jaValue.matchAll(placeholderPattern)]
42
+ .map((m) => m[1])
43
+ .sort();
44
+
45
+ expect(
46
+ enPlaceholders,
47
+ `Placeholder mismatch for key "${key}": en has ${JSON.stringify(enPlaceholders)}, ja has ${JSON.stringify(jaPlaceholders)}`,
48
+ ).toEqual(jaPlaceholders);
49
+ }
50
+ });
51
+ });