@lingo.dev/spec 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@lingo.dev/spec",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
- "main": "./dist/index.js",
6
5
  "exports": {
7
6
  ".": {
7
+ "typescript": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ },
8
11
  "import": {
9
12
  "types": "./dist/index.d.ts",
10
13
  "default": "./dist/index.js"
@@ -16,7 +19,8 @@
16
19
  }
17
20
  },
18
21
  "files": [
19
- "dist"
22
+ "dist",
23
+ "src"
20
24
  ],
21
25
  "sideEffects": false,
22
26
  "dependencies": {
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeKey } from "./hash";
3
+
4
+ describe("computeKey", () => {
5
+ it("returns an 8-character string", () => {
6
+ expect(computeKey("Hello")).toHaveLength(8);
7
+ });
8
+
9
+ it("is deterministic - same input produces same output", () => {
10
+ expect(computeKey("Save")).toBe(computeKey("Save"));
11
+ expect(computeKey("Save", "Form button")).toBe(computeKey("Save", "Form button"));
12
+ });
13
+
14
+ it("produces different keys for different source text", () => {
15
+ expect(computeKey("Save")).not.toBe(computeKey("Cancel"));
16
+ });
17
+
18
+ it("produces different keys for same text with different context", () => {
19
+ const withoutContext = computeKey("Save");
20
+ const withContext = computeKey("Save", "Form button");
21
+ const withOtherContext = computeKey("Save", "Toolbar action");
22
+
23
+ expect(withoutContext).not.toBe(withContext);
24
+ expect(withContext).not.toBe(withOtherContext);
25
+ expect(withoutContext).not.toBe(withOtherContext);
26
+ });
27
+
28
+ it("uses only base62 characters", () => {
29
+ const key = computeKey("Hello, world!");
30
+ expect(key).toMatch(/^[0-9A-Za-z]+$/);
31
+ });
32
+
33
+ it("handles empty string", () => {
34
+ const key = computeKey("");
35
+ expect(key).toHaveLength(8);
36
+ expect(key).toMatch(/^[0-9A-Za-z]+$/);
37
+ });
38
+
39
+ it("handles unicode text", () => {
40
+ const key = computeKey("こんにちは");
41
+ expect(key).toHaveLength(8);
42
+ expect(key).toMatch(/^[0-9A-Za-z]+$/);
43
+ });
44
+
45
+ it("handles long strings", () => {
46
+ const key = computeKey("a".repeat(10_000));
47
+ expect(key).toHaveLength(8);
48
+ expect(key).toMatch(/^[0-9A-Za-z]+$/);
49
+ });
50
+
51
+ it("treats empty context same as no context", () => {
52
+ expect(computeKey("Save", "")).toBe(computeKey("Save"));
53
+ expect(computeKey("Save", undefined)).toBe(computeKey("Save"));
54
+ });
55
+
56
+ it("NFC-normalizes input - composed and decomposed forms produce the same key", () => {
57
+ // e + combining acute accent (U+0065 U+0301) vs precomposed e-acute (U+00E9)
58
+ const decomposed = "re\u0301sume\u0301"; // r + e + ́ + s + u + m + e + ́
59
+ const composed = "r\u00E9sum\u00E9"; // résumé (precomposed)
60
+ expect(computeKey(decomposed)).toBe(computeKey(composed));
61
+ });
62
+
63
+ it("NFC-normalizes context too", () => {
64
+ const decomposed = "cafe\u0301"; // cafe + combining acute
65
+ const composed = "caf\u00E9"; // café (precomposed)
66
+ expect(computeKey("Save", decomposed)).toBe(computeKey("Save", composed));
67
+ });
68
+
69
+ it("handles emoji and surrogate pairs", () => {
70
+ const key = computeKey("Hello 👋 World 🌍");
71
+ expect(key).toHaveLength(8);
72
+ expect(key).toMatch(/^[0-9A-Za-z]+$/);
73
+ });
74
+
75
+ it("handles strings with null bytes", () => {
76
+ const key = computeKey("hello\0world");
77
+ expect(key).toHaveLength(8);
78
+ expect(key).not.toBe(computeKey("hello"));
79
+ expect(key).not.toBe(computeKey("world"));
80
+ });
81
+
82
+ it("produces stable reference vectors for cross-platform verification", () => {
83
+ // These values anchor the algorithm. Any change to hashing, normalization,
84
+ // or encoding MUST update these vectors across all platform implementations.
85
+ const vectors: Record<string, string> = {
86
+ "": computeKey(""),
87
+ Hello: computeKey("Hello"),
88
+ "Hello, world!": computeKey("Hello, world!"),
89
+ };
90
+
91
+ // Snapshot: values must never change once published
92
+ expect(vectors[""]).toMatchInlineSnapshot(`"00000000"`);
93
+ expect(vectors["Hello"]).toMatchInlineSnapshot(`"TT6OR9Mc"`);
94
+ expect(vectors["Hello, world!"]).toMatchInlineSnapshot(`"D3IiJAcZ"`);
95
+ });
96
+ });
package/src/hash.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Deterministic key generation from (source text, context) pairs.
3
+ * Shared between runtime (message lookup) and build tools (extraction, JSONC generation).
4
+ *
5
+ * Uses MurmurHash3 x86_128 on NFC-normalized UTF-8 bytes.
6
+ * Outputs an 8-character base62 string (e.g., "aB3dEf9x").
7
+ *
8
+ * Cross-platform deterministic: JS, Rust, Go, Python produce identical keys
9
+ * when given the same (source, context) pair.
10
+ *
11
+ * 1% collision probability at ~2.4 million messages (48-bit effective entropy).
12
+ */
13
+
14
+ const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
15
+ const KEY_LENGTH = 8;
16
+ const BASE62_SPACE = 62 ** KEY_LENGTH;
17
+
18
+ const encoder = new TextEncoder();
19
+
20
+ // --- MurmurHash3 x86_128 (reference: github.com/aappleby/smhasher) ---
21
+
22
+ const C1 = 0x239b961b;
23
+ const C2 = 0xab0e9789;
24
+ const C3 = 0x38b34ae5;
25
+ const C4 = 0xa1e38b93;
26
+
27
+ function rotl32(x: number, r: number): number {
28
+ return ((x << r) | (x >>> (32 - r))) >>> 0;
29
+ }
30
+
31
+ function fmix32(h: number): number {
32
+ h ^= h >>> 16;
33
+ h = Math.imul(h, 0x85ebca6b) >>> 0;
34
+ h ^= h >>> 13;
35
+ h = Math.imul(h, 0xc2b2ae35) >>> 0;
36
+ h ^= h >>> 16;
37
+ return h >>> 0;
38
+ }
39
+
40
+ function readU32LE(bytes: Uint8Array, i: number): number {
41
+ return (bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24)) >>> 0;
42
+ }
43
+
44
+ function murmurhash3_x86_128(bytes: Uint8Array): [number, number, number, number] {
45
+ const len = bytes.length;
46
+ const nblocks = len >>> 4;
47
+
48
+ let h1 = 0;
49
+ let h2 = 0;
50
+ let h3 = 0;
51
+ let h4 = 0;
52
+
53
+ for (let i = 0; i < nblocks; i++) {
54
+ const off = i << 4;
55
+ let k1 = readU32LE(bytes, off);
56
+ let k2 = readU32LE(bytes, off + 4);
57
+ let k3 = readU32LE(bytes, off + 8);
58
+ let k4 = readU32LE(bytes, off + 12);
59
+
60
+ k1 = Math.imul(k1, C1) >>> 0;
61
+ k1 = rotl32(k1, 15);
62
+ k1 = Math.imul(k1, C2) >>> 0;
63
+ h1 ^= k1;
64
+ h1 = rotl32(h1, 19);
65
+ h1 = (h1 + h2) >>> 0;
66
+ h1 = (Math.imul(h1, 5) + 0x561ccd1b) >>> 0;
67
+
68
+ k2 = Math.imul(k2, C2) >>> 0;
69
+ k2 = rotl32(k2, 16);
70
+ k2 = Math.imul(k2, C3) >>> 0;
71
+ h2 ^= k2;
72
+ h2 = rotl32(h2, 17);
73
+ h2 = (h2 + h3) >>> 0;
74
+ h2 = (Math.imul(h2, 5) + 0x0bcaa747) >>> 0;
75
+
76
+ k3 = Math.imul(k3, C3) >>> 0;
77
+ k3 = rotl32(k3, 17);
78
+ k3 = Math.imul(k3, C4) >>> 0;
79
+ h3 ^= k3;
80
+ h3 = rotl32(h3, 15);
81
+ h3 = (h3 + h4) >>> 0;
82
+ h3 = (Math.imul(h3, 5) + 0x96cd1c35) >>> 0;
83
+
84
+ k4 = Math.imul(k4, C4) >>> 0;
85
+ k4 = rotl32(k4, 18);
86
+ k4 = Math.imul(k4, C1) >>> 0;
87
+ h4 ^= k4;
88
+ h4 = rotl32(h4, 13);
89
+ h4 = (h4 + h1) >>> 0;
90
+ h4 = (Math.imul(h4, 5) + 0x32ac3b17) >>> 0;
91
+ }
92
+
93
+ const tail = nblocks << 4;
94
+ let k1 = 0;
95
+ let k2 = 0;
96
+ let k3 = 0;
97
+ let k4 = 0;
98
+ const rem = len & 15;
99
+
100
+ if (rem >= 15) k4 ^= bytes[tail + 14] << 16;
101
+ if (rem >= 14) k4 ^= bytes[tail + 13] << 8;
102
+ if (rem >= 13) {
103
+ k4 ^= bytes[tail + 12];
104
+ k4 = Math.imul(k4, C4) >>> 0;
105
+ k4 = rotl32(k4, 18);
106
+ k4 = Math.imul(k4, C1) >>> 0;
107
+ h4 ^= k4;
108
+ }
109
+ if (rem >= 12) k3 ^= bytes[tail + 11] << 24;
110
+ if (rem >= 11) k3 ^= bytes[tail + 10] << 16;
111
+ if (rem >= 10) k3 ^= bytes[tail + 9] << 8;
112
+ if (rem >= 9) {
113
+ k3 ^= bytes[tail + 8];
114
+ k3 = Math.imul(k3, C3) >>> 0;
115
+ k3 = rotl32(k3, 17);
116
+ k3 = Math.imul(k3, C4) >>> 0;
117
+ h3 ^= k3;
118
+ }
119
+ if (rem >= 8) k2 ^= bytes[tail + 7] << 24;
120
+ if (rem >= 7) k2 ^= bytes[tail + 6] << 16;
121
+ if (rem >= 6) k2 ^= bytes[tail + 5] << 8;
122
+ if (rem >= 5) {
123
+ k2 ^= bytes[tail + 4];
124
+ k2 = Math.imul(k2, C2) >>> 0;
125
+ k2 = rotl32(k2, 16);
126
+ k2 = Math.imul(k2, C3) >>> 0;
127
+ h2 ^= k2;
128
+ }
129
+ if (rem >= 4) k1 ^= bytes[tail + 3] << 24;
130
+ if (rem >= 3) k1 ^= bytes[tail + 2] << 16;
131
+ if (rem >= 2) k1 ^= bytes[tail + 1] << 8;
132
+ if (rem >= 1) {
133
+ k1 ^= bytes[tail];
134
+ k1 = Math.imul(k1, C1) >>> 0;
135
+ k1 = rotl32(k1, 15);
136
+ k1 = Math.imul(k1, C2) >>> 0;
137
+ h1 ^= k1;
138
+ }
139
+
140
+ h1 ^= len;
141
+ h2 ^= len;
142
+ h3 ^= len;
143
+ h4 ^= len;
144
+
145
+ h1 = (h1 + h2) >>> 0;
146
+ h1 = (h1 + h3) >>> 0;
147
+ h1 = (h1 + h4) >>> 0;
148
+ h2 = (h2 + h1) >>> 0;
149
+ h3 = (h3 + h1) >>> 0;
150
+ h4 = (h4 + h1) >>> 0;
151
+
152
+ h1 = fmix32(h1);
153
+ h2 = fmix32(h2);
154
+ h3 = fmix32(h3);
155
+ h4 = fmix32(h4);
156
+
157
+ h1 = (h1 + h2) >>> 0;
158
+ h1 = (h1 + h3) >>> 0;
159
+ h1 = (h1 + h4) >>> 0;
160
+ h2 = (h2 + h1) >>> 0;
161
+ h3 = (h3 + h1) >>> 0;
162
+ h4 = (h4 + h1) >>> 0;
163
+
164
+ return [h1, h2, h3, h4];
165
+ }
166
+
167
+ // --- Key generation ---
168
+
169
+ function toBase62(num: number, length: number): string {
170
+ let result = "";
171
+ let remaining = num;
172
+ for (let i = 0; i < length; i++) {
173
+ result = BASE62_CHARS[remaining % 62] + result;
174
+ remaining = Math.floor(remaining / 62);
175
+ }
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * Computes a deterministic short key from source text and optional context.
181
+ *
182
+ * Same source + same context = same key (deterministic).
183
+ * Same source + different context = different key (disambiguation).
184
+ */
185
+ export function computeKey(source: string, context?: string): string {
186
+ const input = context != null && context !== "" ? `${source}\0${context}` : source;
187
+ const bytes = encoder.encode(input.normalize("NFC"));
188
+ const [h1, h2] = murmurhash3_x86_128(bytes);
189
+
190
+ // 48-bit value: h1 (32 bits) + upper 16 bits of h2
191
+ // Safe for JS Number arithmetic (well within 2^53)
192
+ const value = (h1 >>> 0) + (h2 >>> 16) * 0x100000000;
193
+
194
+ return toBase62(value % BASE62_SPACE, KEY_LENGTH);
195
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildIcuPlural, buildIcuSelect } from "./icu";
3
+
4
+ describe("buildIcuPlural", () => {
5
+ it("builds ICU expression with one/other", () => {
6
+ expect(buildIcuPlural({ one: "# item", other: "# items" })).toBe("{count, plural, one {# item} other {# items}}");
7
+ });
8
+
9
+ it("orders categories by CLDR convention", () => {
10
+ // Even if passed out of order, output follows CLDR: zero, one, two, few, many, other
11
+ const result = buildIcuPlural({ other: "# things", zero: "no things", one: "# thing" });
12
+ expect(result).toBe("{count, plural, zero {no things} one {# thing} other {# things}}");
13
+ });
14
+
15
+ it("handles other-only", () => {
16
+ expect(buildIcuPlural({ other: "# items" })).toBe("{count, plural, other {# items}}");
17
+ });
18
+
19
+ it("handles all six categories", () => {
20
+ const forms = { zero: "0", one: "1", two: "2", few: "few", many: "many", other: "other" };
21
+ expect(buildIcuPlural(forms)).toBe("{count, plural, zero {0} one {1} two {2} few {few} many {many} other {other}}");
22
+ });
23
+ });
24
+
25
+ describe("buildIcuSelect", () => {
26
+ it("builds ICU expression sorted alphabetically", () => {
27
+ expect(buildIcuSelect({ admin: "Admin", other: "Home", user: "Dashboard" })).toBe(
28
+ "{value, select, admin {Admin} other {Home} user {Dashboard}}",
29
+ );
30
+ });
31
+
32
+ it("sorts keys regardless of insertion order", () => {
33
+ const a = buildIcuSelect({ user: "U", admin: "A", other: "O" });
34
+ const b = buildIcuSelect({ admin: "A", other: "O", user: "U" });
35
+ expect(a).toBe(b);
36
+ });
37
+
38
+ it("handles other-only", () => {
39
+ expect(buildIcuSelect({ other: "Default" })).toBe("{value, select, other {Default}}");
40
+ });
41
+ });
package/src/icu.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Canonical ICU MessageFormat expression builders.
3
+ * Shared between runtime (l.plural/l.select) and build tools (extraction).
4
+ *
5
+ * These must produce identical output everywhere so that computeKey()
6
+ * generates matching hash keys at build time and runtime.
7
+ */
8
+
9
+ const CLDR_PLURAL_ORDER = ["zero", "one", "two", "few", "many", "other"];
10
+
11
+ export function buildIcuPlural(forms: Record<string, string>): string {
12
+ const parts = CLDR_PLURAL_ORDER.filter((cat) => forms[cat] !== undefined)
13
+ .map((cat) => `${cat} {${forms[cat]!}}`)
14
+ .join(" ");
15
+ return `{count, plural, ${parts}}`;
16
+ }
17
+
18
+ export function buildIcuSelect(forms: Record<string, string>): string {
19
+ const parts = Object.keys(forms)
20
+ .sort()
21
+ .map((key) => `${key} {${forms[key]}}`)
22
+ .join(" ");
23
+ return `{value, select, ${parts}}`;
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { computeKey } from "./hash";
2
+ export { buildIcuPlural, buildIcuSelect } from "./icu";
3
+ export { getActiveEntries, readLocaleFile, type EntryMetadata, type LocaleEntry, type LocaleFile } from "./jsonc";
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readLocaleFile } from "./jsonc";
3
+
4
+ describe("readLocaleFile", () => {
5
+ it("reads empty object", () => {
6
+ expect(readLocaleFile("{}")).toEqual({ entries: [] });
7
+ });
8
+
9
+ it("reads simple key-value pairs", () => {
10
+ const result = readLocaleFile('{ "abc": "Hello" }');
11
+ expect(result.entries).toHaveLength(1);
12
+ expect(result.entries[0]).toEqual({ key: "abc", value: "Hello", metadata: {} });
13
+ });
14
+
15
+ it("extracts @context and @src from block comments", () => {
16
+ const jsonc = `{
17
+ /*
18
+ * @context Hero heading
19
+ * @src app/hero.tsx:12
20
+ */
21
+ "mK9xqZ": "Welcome"
22
+ }`;
23
+ const result = readLocaleFile(jsonc);
24
+ expect(result.entries[0].metadata).toEqual({
25
+ context: "Hero heading",
26
+ src: "app/hero.tsx:12",
27
+ });
28
+ });
29
+
30
+ it("detects @orphan marker", () => {
31
+ const jsonc = `{
32
+ /*
33
+ * @orphan
34
+ */
35
+ "old": "Removed"
36
+ }`;
37
+ const result = readLocaleFile(jsonc);
38
+ expect(result.entries[0].metadata.orphan).toBe(true);
39
+ });
40
+
41
+ it("handles trailing commas", () => {
42
+ const jsonc = '{ "a": "one", "b": "two", }';
43
+ const result = readLocaleFile(jsonc);
44
+ expect(result.entries).toHaveLength(2);
45
+ });
46
+
47
+ it("throws on invalid JSONC", () => {
48
+ expect(() => readLocaleFile("{ broken }")).toThrow(/Failed to parse JSONC/);
49
+ });
50
+
51
+ it("reads multiple entries with metadata", () => {
52
+ const jsonc = `{
53
+ /*
54
+ * @context Form button
55
+ * @src checkout.tsx:42
56
+ */
57
+ "aB3dEf": "Save",
58
+
59
+ /*
60
+ * @src hero.tsx:12
61
+ */
62
+ "mK9xqZ": "Welcome"
63
+ }`;
64
+ const result = readLocaleFile(jsonc);
65
+ expect(result.entries).toHaveLength(2);
66
+ expect(result.entries[0].metadata.context).toBe("Form button");
67
+ expect(result.entries[1].metadata.src).toBe("hero.tsx:12");
68
+ });
69
+ });
package/src/jsonc.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * JSONC locale file reader with structured metadata comments.
3
+ * Lives in @lingo.dev/spec so framework adapters can parse locale files
4
+ * without depending on @lingo.dev/cli.
5
+ *
6
+ * File format:
7
+ * ```jsonc
8
+ * {
9
+ * /*
10
+ * * @context Hero heading
11
+ * * @src app/hero.tsx:12
12
+ * *​/
13
+ * "mK9xqZ": "Welcome to Acme"
14
+ * }
15
+ * ```
16
+ */
17
+
18
+ import { parse as parseJsonc, type ParseError, printParseErrorCode } from "jsonc-parser";
19
+
20
+ // --- Types (shared between reader in spec and writer in cli) ---
21
+
22
+ export type EntryMetadata = {
23
+ context?: string;
24
+ src?: string;
25
+ orphan?: boolean;
26
+ };
27
+
28
+ export type LocaleEntry = {
29
+ key: string;
30
+ value: string;
31
+ metadata: EntryMetadata;
32
+ };
33
+
34
+ export type LocaleFile = {
35
+ entries: LocaleEntry[];
36
+ };
37
+
38
+ // --- Helpers ---
39
+
40
+ /** Filters out orphaned entries, returning only active (non-orphaned) ones. */
41
+ export function getActiveEntries(entries: LocaleEntry[]): LocaleEntry[] {
42
+ return entries.filter((e) => !e.metadata.orphan);
43
+ }
44
+
45
+ // --- Reader ---
46
+
47
+ const METADATA_PATTERN = /@(\w+)(?:\s+(.*))?/;
48
+
49
+ function parseMetadataBlock(comment: string): EntryMetadata {
50
+ const metadata: EntryMetadata = {};
51
+ for (const line of comment.split("\n")) {
52
+ const match = line.match(METADATA_PATTERN);
53
+ if (!match) continue;
54
+ const [, tag, value] = match;
55
+ if (tag === "context" && value) metadata.context = value.trim();
56
+ else if (tag === "src" && value) metadata.src = value.trim();
57
+ else if (tag === "orphan") metadata.orphan = true;
58
+ }
59
+ return metadata;
60
+ }
61
+
62
+ function escapeRegex(str: string): string {
63
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ }
65
+
66
+ /**
67
+ * Scans JSONC source to associate block comments with the key that follows them.
68
+ */
69
+ function extractCommentsForKeys(content: string, keys: string[]): Map<string, EntryMetadata> {
70
+ const result = new Map<string, EntryMetadata>();
71
+ const blockCommentPattern = /\/\*[\s\S]*?\*\//g;
72
+
73
+ const comments: { end: number; text: string }[] = [];
74
+ let match: RegExpExecArray | null;
75
+ while ((match = blockCommentPattern.exec(content)) !== null) {
76
+ comments.push({ end: match.index + match[0].length, text: match[0] });
77
+ }
78
+
79
+ for (const key of keys) {
80
+ const keyPattern = new RegExp(`"${escapeRegex(key)}"\\s*:`);
81
+ const keyMatch = keyPattern.exec(content);
82
+ if (!keyMatch) continue;
83
+
84
+ const keyPos = keyMatch.index;
85
+ const precedingComment = comments.filter((c) => c.end <= keyPos).sort((a, b) => b.end - a.end)[0];
86
+
87
+ if (precedingComment) {
88
+ const between = content.slice(precedingComment.end, keyPos);
89
+ if (/^\s*$/.test(between)) {
90
+ result.set(key, parseMetadataBlock(precedingComment.text));
91
+ }
92
+ }
93
+ }
94
+
95
+ return result;
96
+ }
97
+
98
+ /**
99
+ * Parses a JSONC string into structured locale entries with metadata.
100
+ * Extracts @context, @src, and @orphan from block comments preceding each key.
101
+ * Pure function - no filesystem access. Works in Node.js and edge runtimes.
102
+ */
103
+ export function readLocaleFile(content: string): LocaleFile {
104
+ const errors: ParseError[] = [];
105
+ const data = parseJsonc(content, errors, { allowTrailingComma: true }) as Record<string, string> | undefined;
106
+
107
+ if (errors.length > 0) {
108
+ const msg = errors.map((e) => printParseErrorCode(e.error)).join(", ");
109
+ throw new Error(`Failed to parse JSONC: ${msg}`);
110
+ }
111
+
112
+ if (!data || typeof data !== "object") {
113
+ return { entries: [] };
114
+ }
115
+
116
+ const commentsByKey = extractCommentsForKeys(content, Object.keys(data));
117
+
118
+ const entries: LocaleEntry[] = Object.entries(data).map(([key, value]) => ({
119
+ key,
120
+ value,
121
+ metadata: commentsByKey.get(key) ?? {},
122
+ }));
123
+
124
+ return { entries };
125
+ }