Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2916,6 +2917,8 @@

"pngjs": ["[email protected]", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],

"pofile": ["[email protected]", "", {}, "sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g=="],

"possible-typed-array-names": ["[email protected]", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="],

"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ=="],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 41 additions & 6 deletions packages/cli/src/parsers/__tests__/po.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});

Expand All @@ -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", () => {
Expand All @@ -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',
);
});

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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');
});
});
});
76 changes: 32 additions & 44 deletions packages/cli/src/parsers/formats/po.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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;
Expand All @@ -44,39 +33,38 @@ export class PoParser extends BaseParser {
_originalData?: Record<string, string>,
): Promise<string> {
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)}`,
);
}
}
}

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, '"');
}