From 0da4dcf65b99ff6fb4f952a0539347f88d81caca Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 18 Oct 2026 16:20:04 +0200 Subject: [PATCH 1/2] fix(gottext): keep only the calls naming the template's text domain --- gottext/src/pot.ts | 103 +++++++++++++++++++++++++++++-------- gottext/test/build.test.ts | 98 +++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 21 deletions(-) diff --git a/gottext/src/pot.ts b/gottext/src/pot.ts index d51c2e9..0bfbaf1 100644 --- a/gottext/src/pot.ts +++ b/gottext/src/pot.ts @@ -20,6 +20,42 @@ const TEMPLATE_CALL = /\bT\.Get "((?:[^"\\]|\\.)*)"/g /** MARKER_CALL is the shape Go source marks a message with. */ const MARKER_CALL = /\bMsgid\("((?:[^"\\]|\\.)*)"\)/g +/** DEFAULT_DOMAIN is the text domain a call naming none belongs to. */ +const DEFAULT_DOMAIN = 'default' + +/** Extractor is what the parser runs over every piece of a source. */ +type Extractor = ReturnType + +/** Piece is one part of a parsed source the parser hands an extractor. */ +type Piece = Parameters[0] + +/** Parsed is the parsed source a piece sits in. */ +type Parsed = Parameters[1] + +/** Call is the part of a call the text domain check reads. */ +interface Call { + /** arguments are the values the call passes. */ + arguments: ArrayLike +} + +/** Shape is one gettext call shape and where each of its arguments stands. */ +interface Shape { + /** callees are the names the call goes by. */ + callees: string[] + /** positions are where the text, the plural and the context stand. */ + positions: { text: number, textPlural?: number, context?: number } + /** domain is where the text domain stands. */ + domain: number +} + +/** SHAPES is the four gettext call shapes, placed as the WordPress packages place them. */ +const SHAPES: Shape[] = [ + { callees: ['__', 'i18n.__'], positions: { text: 0 }, domain: 1 }, + { callees: ['_x', 'i18n._x'], positions: { text: 0, context: 1 }, domain: 2 }, + { callees: ['_n', 'i18n._n'], positions: { text: 0, textPlural: 1 }, domain: 3 }, + { callees: ['_nx', 'i18n._nx'], positions: { text: 0, textPlural: 1, context: 3 }, domain: 4 }, +] + /** Found is a message one of the four gettext calls declared. */ export interface Found { /** text is the message as the sources write it. */ @@ -32,7 +68,7 @@ export interface Found { /** PotOptions is what building a template needs. */ export interface PotOptions { - /** domain is the text domain the template carries. */ + /** domain is the text domain the template carries, and the only one whose calls it keeps from the sources. */ domain: string /** root is the path the source globs resolve against. */ root: string @@ -133,30 +169,55 @@ function byKey(left: GetTextTranslation, right: GetTextTranslation): number { } /** - * Returns every translatable message the given sources declare. - * @param options - Where to read the sources from. + * Returns the check telling whether a call of one shape names the given text domain. + * @param shape - The call shape to read. + * @param domain - The text domain the template is built for. + * @returns The check, which keeps a call whose text domain is not written as a string. + */ +function namesDomain(shape: Shape, domain: string): (call: Piece, file: Parsed, start: number) => boolean { + const named = JsExtractors.callExpression(shape.callees, { + arguments: { text: shape.domain }, + comments: NO_COMMENTS, + }) + return (call, file, start) => { + if ((call as unknown as Call).arguments.length <= shape.domain) { + return domain === DEFAULT_DOMAIN + } + const names: string[] = [] + named(call, file, (found) => { names.push(found.text) }, start) + return names.length === 0 || names[0] === domain + } +} + +/** + * Returns the extractor keeping the calls of one shape that name the given text domain. + * @param shape - The call shape to read. + * @param domain - The text domain the template is built for. + * @returns The extractor the parser runs over every piece of a source. + */ +function extractorFor(shape: Shape, domain: string): Extractor { + const message = JsExtractors.callExpression(shape.callees, { + arguments: shape.positions, + comments: NO_COMMENTS, + }) + const keeps = namesDomain(shape, domain) + return (piece, file, add, start) => { + message(piece, file, (found) => { + if (keeps(piece, file, start)) { + add(found) + } + }, start) + } +} + +/** + * Returns every translatable message the given sources declare under the given text domain. + * @param options - Where to read the sources from and the text domain to keep. * @returns The messages the extractor found. */ export function messages(options: PotOptions): Found[] { const extractor = new GettextExtractor() - const parser = extractor.createJsParser([ - JsExtractors.callExpression(['__', 'i18n.__'], { - arguments: { text: 0 }, - comments: NO_COMMENTS, - }), - JsExtractors.callExpression(['_x', 'i18n._x'], { - arguments: { text: 0, context: 1 }, - comments: NO_COMMENTS, - }), - JsExtractors.callExpression(['_n', 'i18n._n'], { - arguments: { text: 0, textPlural: 1 }, - comments: NO_COMMENTS, - }), - JsExtractors.callExpression(['_nx', 'i18n._nx'], { - arguments: { text: 0, textPlural: 1, context: 3 }, - comments: NO_COMMENTS, - }), - ]) + const parser = extractor.createJsParser(SHAPES.map((shape) => extractorFor(shape, options.domain))) for (const pattern of options.sources) { parser.parseFilesGlob(pattern, { cwd: options.root, diff --git a/gottext/test/build.test.ts b/gottext/test/build.test.ts index 3d809c2..2c4551f 100644 --- a/gottext/test/build.test.ts +++ b/gottext/test/build.test.ts @@ -9,6 +9,7 @@ import { compileCatalog, goMessages, goString, + messages, mismatched, orphaned, pot, @@ -70,6 +71,103 @@ test('carries the domain it was given in its header', () => { expect(held).toContain('Project-Id-Version: probe') }) +test('leaves out a message another text domain names', () => { + const root = rootWith({ 'src/a.ts': "__('Own words', 'probe')\n__('Their words', 'other')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Own words"') + expect(held).not.toContain('Their words') +}) + +test('leaves out a message naming no text domain', () => { + const root = rootWith({ 'src/a.ts': "__('Own words', 'probe')\n__('Move up')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Own words"') + expect(held).not.toContain('Move up') +}) + +test('keeps the messages naming no text domain in a template for the default domain', () => { + const root = rootWith({ 'src/a.ts': "__('Own words', 'probe')\n__('Move up')\n" }) + + const held = pot({ domain: 'default', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Move up"') + expect(held).not.toContain('Own words') +}) + +test.each([ + { + shape: '_x', + kept: 'Draft', + calls: ["_x('Draft', 'status', 'probe')", "_x('Pending', 'status', 'other')", "_x('Scheduled', 'probe')"], + }, + { + shape: '_n', + kept: '%d post', + calls: [ + "_n('%d post', '%d posts', 2, 'probe')", + "_n('%d page', '%d pages', 2, 'other')", + "_n('%d item', '%d items', 2)", + ], + }, + { + shape: '_nx', + kept: '%d draft', + calls: [ + "_nx('%d draft', '%d drafts', 2, 'sidebar', 'probe')", + "_nx('%d note', '%d notes', 2, 'sidebar', 'other')", + "_nx('%d task', '%d tasks', 2, 'probe')", + ], + }, + { + shape: 'i18n.__', + kept: 'Own words', + calls: ["i18n.__('Own words', 'probe')", "i18n.__('Their words', 'other')", "i18n.__('Move up')"], + }, +])('reads the text domain where $shape names it', ({ kept, calls }) => { + const root = rootWith({ 'src/a.ts': `${calls.join('\n')}\n` }) + + const texts = messages({ domain: 'probe', root, sources: SOURCES }).map((found) => found.text) + + expect(texts).toEqual([kept]) +}) + +test('answers only the messages the given text domain names', () => { + const root = rootWith({ 'src/a.ts': "__('Own words', 'probe')\n__('Move up')\n__('Their words', 'other')\n" }) + + const texts = messages({ domain: 'probe', root, sources: SOURCES }).map((found) => found.text) + + expect(texts).toEqual(['Own words']) +}) + +test('reads a text domain joined from string pieces', () => { + const root = rootWith({ 'src/a.ts': "__('Joined own', 'pro' + 'be')\n__('Joined other', 'oth' + 'er')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Joined own"') + expect(held).not.toContain('Joined other') +}) + +test('keeps a message whose text domain is not written as a string', () => { + const root = rootWith({ 'src/a.ts': "const DOMAIN = 'other'\n__('Kept words', DOMAIN)\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Kept words"') +}) + +test('keeps a message its own domain names when another domain names it too', () => { + const root = rootWith({ 'src/a.ts': "__('Options')\n__('Options', 'probe')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Options"') +}) + test('names its messages in a settled order however the sources are read', () => { const root = rootWith({ 'src/b.ts': "__('Zebra', 'probe')\n__('Apple', 'probe')\n", From 5b556a8f43435cbe7cb6ca23c28796bc7513c74c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 18 Oct 2026 16:20:05 +0200 Subject: [PATCH 2/2] docs(gottext): note the text domain filter under Unreleased --- gottext/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index 1ed7b99..3b2cf95 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -7,6 +7,10 @@ from CI. ## [Unreleased] +### Changed + +- Breaking: `messages` keeps only the calls naming the given `domain`, and so does `pot` for the calls it reads from `sources`. A call naming no domain belongs to `default`, and a call whose domain is not a string literal is kept. Go markers and templates are read as before. An app whose catalogue loads under the default domain builds its template with `domain: 'default'`. + ### Fixed - `untranslated` reports plural messages missing forms required by the catalogue's