From 0b6247b455f063b9777efaea3be143407a3ef8ba Mon Sep 17 00:00:00 2001 From: AndreyHirsa Date: Thu, 2 Apr 2026 16:57:08 +0300 Subject: [PATCH] fix(cli): po loader push performance, multi-entry sections, pseudo mode crash --- .changeset/light-shrimps-hear.md | 5 ++ packages/cli/src/cli/loaders/po/index.spec.ts | 29 +++++++ packages/cli/src/cli/loaders/po/index.ts | 79 +++++++++++-------- packages/cli/src/cli/localizer/pseudo.ts | 3 + 4 files changed, 82 insertions(+), 34 deletions(-) create mode 100644 .changeset/light-shrimps-hear.md diff --git a/.changeset/light-shrimps-hear.md b/.changeset/light-shrimps-hear.md new file mode 100644 index 000000000..85c7214a7 --- /dev/null +++ b/.changeset/light-shrimps-hear.md @@ -0,0 +1,5 @@ +--- +"lingo.dev": patch +--- + +fix PO loader push performance, multi-entry section handling, and pseudo mode crash diff --git a/packages/cli/src/cli/loaders/po/index.spec.ts b/packages/cli/src/cli/loaders/po/index.spec.ts index febfc351e..bf222bcb0 100644 --- a/packages/cli/src/cli/loaders/po/index.spec.ts +++ b/packages/cli/src/cli/loaders/po/index.spec.ts @@ -464,6 +464,35 @@ msgstr ""`; expect(portugueseResult).not.toContain('"Language: en\\n"'); expect(portugueseResult).toContain('msgstr "Olá"'); }); + it("push should translate all entries in multi-entry sections (no blank line between entries)", async () => { + const loader = createLoader(); + const input = ` +msgid "" +msgstr "" +"Content-Type: text/plain; charset=utf-8\\n" + +msgid "Entry A" +msgstr "Entry A" +msgid "Entry B" +msgstr "Entry B" +msgid "Entry C" +msgstr "Entry C" + `.trim(); + + await loader.pull("en", input); + + const updatedData = { + "Entry A": { singular: "Entrada A", plural: null }, + "Entry B": { singular: "Entrada B", plural: null }, + "Entry C": { singular: "Entrada C", plural: null }, + }; + + const result = await loader.push("en-upd", updatedData); + + expect(result).toContain('msgstr "Entrada A"'); + expect(result).toContain('msgstr "Entrada B"'); + expect(result).toContain('msgstr "Entrada C"'); + }); }); function createLoader(params: PoLoaderParams = { multiline: false }) { diff --git a/packages/cli/src/cli/loaders/po/index.ts b/packages/cli/src/cli/loaders/po/index.ts index 834d42e97..86869e4c3 100644 --- a/packages/cli/src/cli/loaders/po/index.ts +++ b/packages/cli/src/cli/loaders/po/index.ts @@ -49,7 +49,6 @@ export function createPoDataLoader( async push(locale, data, originalInput, originalLocale, pullInput) { // Parse each section to maintain structure - const currentSections = pullInput?.split("\n\n").filter(Boolean) || []; const originalSections = originalInput?.split("\n\n").filter(Boolean) || []; const result = originalSections @@ -63,45 +62,40 @@ export function createPoDataLoader( const contextKey = _.keys(sectionPo.translations)[0]; const entries = sectionPo.translations[contextKey]; const msgid = Object.keys(entries).find((key) => entries[key].msgid); - - // If the section is empty, try to find it in the current sections - const currentSection = currentSections.find((cs) => { - const csPo = gettextParser.po.parse(cs); - if (Object.keys(csPo.translations).length === 0) { - return false; - } - const csContextKey = _.keys(csPo.translations)[0]; - const csEntries = csPo.translations[csContextKey]; - if (!csEntries) { - return false; - } - const csMsgid = Object.keys(csEntries).find( - (key) => csEntries[key].msgid, - ); - return csMsgid === msgid; - }); if (!msgid) { - if (currentSection) { - return currentSection; + // If the section is empty, try to find it in the current sections + const currentSections = + pullInput?.split("\n\n").filter(Boolean) || []; + const currentSection = currentSections.find((cs) => { + const csPo = gettextParser.po.parse(cs); + if (Object.keys(csPo.translations).length === 0) { + return false; + } + const csContextKey = _.keys(csPo.translations)[0]; + const csEntries = csPo.translations[csContextKey]; + if (!csEntries) { + return false; + } + const csMsgid = Object.keys(csEntries).find( + (key) => csEntries[key].msgid, + ); + return csMsgid === msgid; + }); + return currentSection || section; + } + + const entriesToMerge: Record = {}; + for (const [id, entry] of Object.entries(entries)) { + if (entry.msgid && data[id]) { + entriesToMerge[id] = { msgstr: data[id].msgstr }; } - return section; } - if (data[msgid]) { - // Preserve headers from the target file - const headers = currentSection - ? gettextParser.po.parse(currentSection).headers - : sectionPo.headers; + if (Object.keys(entriesToMerge).length > 0) { const updatedPo = _.merge({}, sectionPo, { - headers, - translations: { - [contextKey]: { - [msgid]: { - msgstr: data[msgid].msgstr, - }, - }, - }, + headers: resolveTargetHeaders(pullInput, sectionPo), + translations: { [contextKey]: entriesToMerge }, }); const updatedSection = gettextParser.po .compile(updatedPo, { foldLength: params.multiline ? 76 : false }) @@ -174,6 +168,23 @@ export function createPoContentLoader(): ILoader< }); } +function resolveTargetHeaders( + pullInput: string | null | undefined, + sectionPo: GetTextTranslations, +): Record | undefined { + // Only needed for embedded headers (header entry + regular entries in the same section) + if (!sectionPo.translations[""]?.[""] || !pullInput) { + return undefined; + } + const headerSection = pullInput + .split("\n\n") + .find((s) => s.includes('msgid ""')); + if (!headerSection) { + return undefined; + } + return gettextParser.po.parse(headerSection).headers; +} + function preserveCommentOrder(section: string, originalSection: string) { // Split both sections into lines const sectionLines = section.split(/\r?\n/); diff --git a/packages/cli/src/cli/localizer/pseudo.ts b/packages/cli/src/cli/localizer/pseudo.ts index d20a3e20d..4083f528f 100644 --- a/packages/cli/src/cli/localizer/pseudo.ts +++ b/packages/cli/src/cli/localizer/pseudo.ts @@ -14,6 +14,9 @@ export default function createPseudoLocalizer(): ILocalizer { authenticated: true, }; }, + validateSettings: async () => { + return { valid: true }; + }, localize: async (input: LocalizerData, onProgress) => { // Nothing to translate – return the input as-is. if (!Object.keys(input.processableData).length) {