diff --git a/bun.lock b/bun.lock index 92ce5064..a15c53d2 100644 --- a/bun.lock +++ b/bun.lock @@ -220,6 +220,7 @@ "open": "^10.1.0", "php-parser": "^3.1.5", "plist": "^3.1.0", + "pofile": "^1.1.4", "preferred-pm": "^4.1.1", "properties": "^1.2.1", "rambda": "^9.4.2", @@ -2916,6 +2917,8 @@ "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "pofile": ["pofile@1.1.4", "", {}, "sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g=="], + "possible-typed-array-names": ["possible-typed-array-names@1.0.0", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="], "postcss": ["postcss@8.5.1", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 5a090142..b08c4a71 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,7 @@ "open": "^10.1.0", "php-parser": "^3.1.5", "plist": "^3.1.0", + "pofile": "^1.1.4", "preferred-pm": "^4.1.1", "properties": "^1.2.1", "rambda": "^9.4.2", diff --git a/packages/cli/src/parsers/__tests__/po.test.ts b/packages/cli/src/parsers/__tests__/po.test.ts index d4282bb4..f0a7335d 100644 --- a/packages/cli/src/parsers/__tests__/po.test.ts +++ b/packages/cli/src/parsers/__tests__/po.test.ts @@ -58,10 +58,14 @@ msgstr "" const input = ` msgid "with_quotes" msgstr "text with "quotes" inside" + +msgid "with_escaped_quotes" +msgstr "text with escaped \\"quotes\\" inside" `; const result = await parser.parse(input); expect(result).toEqual({ with_quotes: 'text with "quotes" inside', + with_escaped_quotes: 'text with escaped "quotes" inside', }); }); @@ -70,6 +74,37 @@ msgstr "text with "quotes" inside" const result = await parser.parse(input); expect(result).toEqual({}); }); + + test("handles headers in input", async () => { + const input = ` +"POT-Creation-Date: 2025-07-11 19:57+0900\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: ko\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +# This is a comment +#: another comment +msgid "key" +msgstr "value" +`; + const result = await parser.parse(input); + expect(result).toEqual({ + key: "value", + }); + + const serialized = await parser.serialize("ko", result); + expect(serialized).toBe( + 'msgid ""\nmsgstr ""\n"POT-Creation-Date: 2025-07-11 19:57+0900\\n"\n": \\n"\n"MIME-Version: 1.0\\n"\n": \\n"\n"Content-Type: text/plain; charset=utf-8\\n"\n": \\n"\n"Content-Transfer-Encoding: 8bit\\n"\n": \\n"\n"X-Generator: @lingui/cli\\n"\n": \\n"\n"Language: ko\\n"\n": \\n"\n"Project-Id-Version: \\n"\n": \\n"\n"Report-Msgid-Bugs-To: \\n"\n": \\n"\n"PO-Revision-Date: \\n"\n": \\n"\n"Last-Translator: \\n"\n": \\n"\n"Language-Team: \\n"\n": \\n"\n"Plural-Forms: \\n"\n": \\n"\n\n# This is a comment\n#: another comment\nmsgid "key"\nmsgstr "value"\n', + ); + }); }); describe("serialize", () => { @@ -80,7 +115,7 @@ msgstr "text with "quotes" inside" }; const result = await parser.serialize("en", input); expect(result).toBe( - 'msgid "hello"\nmsgstr "world"\n\nmsgid "test"\nmsgstr "value"\n', + 'msgid ""\nmsgstr ""\n\nmsgid "hello"\nmsgstr "world"\n\nmsgid "test"\nmsgstr "value"\n', ); }); @@ -89,7 +124,7 @@ msgstr "text with "quotes" inside" empty: "", }; const result = await parser.serialize("en", input); - expect(result).toBe('msgid "empty"\nmsgstr ""\n'); + expect(result).toBe('msgid ""\nmsgstr ""\n\nmsgid "empty"\nmsgstr ""\n'); }); test("serializes translations with quotes", async () => { @@ -98,14 +133,14 @@ msgstr "text with "quotes" inside" }; const result = await parser.serialize("en", input); expect(result).toBe( - 'msgid "with_quotes"\nmsgstr "text with "quotes" inside"\n', + 'msgid ""\nmsgstr ""\n\nmsgid "with_quotes"\nmsgstr "text with \\"quotes\\" inside"\n', ); }); test("handles empty object", async () => { const input = {}; const result = await parser.serialize("en", input); - expect(result).toBe(""); + expect(result).toBe('msgid ""\nmsgstr ""\n'); }); test("adds newline at end of file", async () => { @@ -121,14 +156,14 @@ msgstr "text with "quotes" inside" }; const result = await parser.serialize("en", translations); expect(result).toBe( - 'msgid "hello"\nmsgstr "world"\n\nmsgid "keep"\nmsgstr "value"\n', + 'msgid ""\nmsgstr ""\n\nmsgid "hello"\nmsgstr "world"\n\nmsgid "keep"\nmsgstr "value"\n', ); }); test("handles object with no translations", async () => { const translations = {}; const result = await parser.serialize("en", translations); - expect(result).toBe(""); + expect(result).toBe('msgid ""\nmsgstr ""\n'); }); }); }); diff --git a/packages/cli/src/parsers/formats/po.ts b/packages/cli/src/parsers/formats/po.ts index 34df82b4..10991e44 100644 --- a/packages/cli/src/parsers/formats/po.ts +++ b/packages/cli/src/parsers/formats/po.ts @@ -1,33 +1,22 @@ +import PO from "pofile"; import { BaseParser } from "../core/base-parser.js"; export class PoParser extends BaseParser { + #po: PO = null!; + async parse(input: string) { try { + this.#po = PO.parse(input); const result: Record = {}; - const lines = input.split("\n"); - let currentKey = ""; - let currentValue = ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (isSkippableLine(trimmed)) { + for (const item of this.#po.items) { + if (!item.msgid) { continue; } - if (trimmed.startsWith("msgid")) { - if (currentKey) { - result[currentKey] = currentValue; - } - currentKey = parseMsgId(trimmed); - currentValue = ""; - } else if (trimmed.startsWith("msgstr")) { - currentValue = parseMsgStr(trimmed); - } - } + const value = item.msgstr.at(0) || ""; - if (currentKey) { - result[currentKey] = currentValue; + result[item.msgid] = value; } return result; @@ -44,17 +33,34 @@ export class PoParser extends BaseParser { _originalData?: Record, ): Promise { try { + if (!this.#po) { + this.#po = new PO(); + } + if (Object.keys(data).length === 0) { - return ""; + return this.#po.toString(); } - const result = Object.entries(data) - .map(([key, value]) => { - return `msgid "${key}"\nmsgstr "${value}"`; - }) - .join("\n\n"); + const originalPoItems = Object.fromEntries( + this.#po.items.map((item) => [item.msgid, item]), + ); + + this.#po.items = Object.entries(data).map(([key, value]) => { + let item = originalPoItems[key]; - return `${result}\n`; + if (!item) { + item = new PO.Item(); + item.msgid = key; + } + + if (value) { + item.msgstr = [value]; + } + + return item; + }); + + return this.#po.toString(); } catch (error) { throw new Error( `Failed to serialize PO: ${error instanceof Error ? error.message : String(error)}`, @@ -62,21 +68,3 @@ export class PoParser extends BaseParser { } } } - -function isSkippableLine(line: string): boolean { - return !line || line.startsWith("#"); -} - -function parseMsgId(line: string): string { - const match = line.match(/msgid "(.*)"/); - return match ? unescapeQuotes(match[1]) : ""; -} - -function parseMsgStr(line: string): string { - const match = line.match(/msgstr "(.*)"/); - return match ? unescapeQuotes(match[1]) : ""; -} - -function unescapeQuotes(str: string): string { - return str.replace(/\\"/g, '"'); -}