taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 3f0c08da0046b252f2da7e1b0891c58f5c8b04f5
parent 52eb98d4e71971d5f6b4fb82d90f0ea19d18322d
Author: Florian Dold <dold@taler.net>
Date:   Sun, 13 Sep 2026 17:10:18 +0200

pogen: preserve translator comments in extracted messages

Extract translator notes from parsed token boundaries so JSX comments
survive template interpolations. Preserve multiline notes and comments
on plural forms, and avoid attaching a note to the following message.

Diffstat:
Mpackages/pogen/src/potextract.test.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/pogen/src/potextract.ts | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
2 files changed, 132 insertions(+), 29 deletions(-)

diff --git a/packages/pogen/src/potextract.test.ts b/packages/pogen/src/potextract.test.ts @@ -428,6 +428,41 @@ test("should not reuse a JSX-expression translator comment", () => { ); }); +test("should extract translator comments after template interpolations", () => { + const out = process(` + <section> + <p>{i18n.str\`Amount \${amount}\`}</p> + {/* Translators: Reference for a bank transfer. */} + <span>{i18n.str\`Payment reference\`}</span> + </section>`); + assert.match( + out, + /#\. Translators: Reference for a bank transfer\.\n[^]*msgid "Payment reference"/, + ); +}); + +test("should not treat literal translator markers as source comments", () => { + const out = process(` + <section> + <p>{\`\${amount} /* Translators: This is template text. */\`}</p> + <span>{i18n.str\`Payment reference\`}</span> + </section>`); + assert.doesNotMatch(out, /Translators:/); + assert.match(out, /msgid "Payment reference"/); +}); + +test("should not reuse an inline translator comment on the following message", () => { + for (const tag of ["i18n.str", 'i18n.ctx("status")']) { + const out = process(`{ + label: /* Translators: Describes only the status label. */ ${tag}\`First\`, + detail: i18n.str\`Second\`, + }`); + assert.equal(out.match(/Translators:/g)?.length, 1); + assert.match(out, /msgid "First"/); + assert.match(out, /msgid "Second"/); + } +}); + test("should attach a translator comment between JSX attributes", () => { const out = process(` <SettingsToggle @@ -452,6 +487,41 @@ test("should attach a translator comment in a ternary arm", () => { ); }); +test("should keep all lines of a translator comment inside an expression", () => { + const out = process(` + const name = suppliedName || + // Translators: Fallback when the merchant supplied neither + // a product name nor a description. + i18n.str\`Product\`;`); + assert.match( + out, + /#\. Translators: Fallback when the merchant supplied neither\n#\. a product name nor a description\.\n/, + ); +}); + +test("should prefer an inline translator note to a preceding ordinary comment", () => { + const out = process(` + // The button starts an operation. + const label = /* Translators: Adds digital cash to the wallet. */ i18n.str\`Withdraw\`;`); + assert.match(out, /#\. Translators: Adds digital cash to the wallet\.\n/); + assert.doesNotMatch(out, /The button starts an operation/); +}); + +test("should extract translator notes on both plural forms", () => { + const out = process(` + i18n.plural( + count, + /* Translators: Token is a merchant-issued discount or pass. */ i18n.lazy\`\${count} token\`, + /* Translators: %1$s is the number of tokens. */ i18n.lazy\`\${count} tokens\`, + );`); + assert.match( + out, + /#\. Translators: Token is a merchant-issued discount or pass\.\n/, + ); + assert.match(out, /#\. Translators: %1\$s is the number of tokens\.\n/); + assert.match(out, /msgid "%1\$s token"\nmsgid_plural "%1\$s tokens"/); +}); + // // Duplicates. // diff --git a/packages/pogen/src/potextract.ts b/packages/pogen/src/potextract.ts @@ -72,6 +72,10 @@ function getComment( lastTokLine: number, node: ts.Node, ): string { + // Explicit translator notes take precedence over other preceding comments, + // including when the note is inline on the same line as the message. + const translatorComment = getNearbyTranslatorComment(sourceFile, node); + if (translatorComment) return translatorComment; let lc = ts.getLineAndCharacterOfPosition( sourceFile, node.getStart(sourceFile), @@ -102,6 +106,13 @@ function getComment( if (endLineOf(found[first]) != lc.line - 1) { return getNearbyTranslatorComment(sourceFile, node); } + if ( + containsTranslatedMessage( + sourceFile.text.slice(found[first].end, node.getStart(sourceFile)), + ) + ) { + return getNearbyTranslatorComment(sourceFile, node); + } // A run of consecutive "//" lines is one comment, just like in xgettext. if (found[first].kind === ts.SyntaxKind.SingleLineCommentTrivia) { while ( @@ -128,11 +139,17 @@ function getComment( .replace(/(\n[ \t]*?)?[*][/]$/, ""); break; } - lines.push(text); + lines.push(text.trimEnd()); } return lines.join("\n"); } +function containsTranslatedMessage(text: string): boolean { + return /(?:<\s*i18n\.(?:Translate|TranslateSwitch)\b|(?:\bi18n\.[A-Za-z]+(?:\([^)]*\))?|\bt)\s*`)/.test( + text, + ); +} + const translatorCommentCache = new WeakMap< ts.SourceFile, Array<{ pos: number; end: number; text: string }> @@ -144,34 +161,52 @@ function translatorComments( const cached = translatorCommentCache.get(sourceFile); if (cached) return cached; - const comments: Array<{ pos: number; end: number; text: string }> = []; - const scanner = ts.createScanner( - ts.ScriptTarget.Latest, - false, - ts.LanguageVariant.JSX, - sourceFile.text, - ); - for ( - let token = scanner.scan(); - token !== ts.SyntaxKind.EndOfFileToken; - token = scanner.scan() - ) { - if ( - token !== ts.SyntaxKind.SingleLineCommentTrivia && - token !== ts.SyntaxKind.MultiLineCommentTrivia - ) { - continue; + const byPosition = new Map<number, ts.CommentRange>(); + const collectAt = (position: number): void => { + const ranges = [ + ...(ts.getLeadingCommentRanges(sourceFile.text, position) ?? []), + ...(ts.getTrailingCommentRanges(sourceFile.text, position) ?? []), + ]; + for (const range of ranges) { + byPosition.set(range.pos, range); } - const pos = scanner.getTokenPos(); - const end = scanner.getTextPos(); - const raw = sourceFile.text.slice(pos, end); + }; + // Use parsed token boundaries: a standalone scanner needs parser-directed + // rescanning to distinguish template tails, JSX text, and regular expressions. + // getChildren also visits the braces around comment-only JSX expressions. + const visit = (node: ts.Node): void => { + collectAt(node.pos); + collectAt(node.end); + for (const child of node.getChildren(sourceFile)) visit(child); + }; + visit(sourceFile); + const ranges = [...byPosition.values()].sort((a, b) => a.pos - b.pos); + const comments: Array<{ pos: number; end: number; text: string }> = []; + for (let index = 0; index < ranges.length; index++) { + const range = ranges[index]; + let end = range.end; + let raw = sourceFile.text.slice(range.pos, end); if (!/\bTranslators?:/i.test(raw)) continue; + // A marked // comment may continue on subsequent unmarked // lines. + if (range.kind === ts.SyntaxKind.SingleLineCommentTrivia) { + while (index + 1 < ranges.length) { + const next = ranges[index + 1]; + if ( + next.kind !== ts.SyntaxKind.SingleLineCommentTrivia || + !/^[ \t]*\r?\n[ \t]*$/.test(sourceFile.text.slice(end, next.pos)) + ) + break; + raw += "\n" + sourceFile.text.slice(next.pos, next.end); + end = next.end; + index++; + } + } const text = raw - .replace(/^[/][/]\s*/, "") + .replace(/^[/][/]\s*/gm, "") .replace(/^[/][*]\s*/, "") .replace(/\s*[*][/]$/, "") .trim(); - comments.push({ pos, end, text }); + comments.push({ pos: range.pos, end, text }); } translatorCommentCache.set(sourceFile, comments); return comments; @@ -205,11 +240,7 @@ function getNearbyTranslatorComment( const between = sourceFile.text.slice(comment.end, start); // Do not reuse one comment for a later message in the same JSX block. - if ( - /(?:<\s*i18n\.(?:Translate|TranslateSwitch)\b|(?:\bi18n\.[A-Za-z]+|\bt)\s*`)/.test( - between, - ) - ) { + if (containsTranslatedMessage(between)) { return ""; } return comment.text; @@ -926,7 +957,9 @@ function processNode( sourceFile, searchScreenId(parents, sourceFile), ), - comments: comment ? [comment] : [], + comments: [ + ...new Set([comment, t1.comment, t2.comment].filter(Boolean)), + ], refs: [sourceRef(projectPrefix, sourceFile, line)], context: path.ctx, msgid: content,