Skip to content
Merged
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
4 changes: 4 additions & 0 deletions gottext/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 82 additions & 21 deletions gottext/src/pot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof JsExtractors.callExpression>

/** Piece is one part of a parsed source the parser hands an extractor. */
type Piece = Parameters<Extractor>[0]

/** Parsed is the parsed source a piece sits in. */
type Parsed = Parameters<Extractor>[1]

/** Call is the part of a call the text domain check reads. */
interface Call {
/** arguments are the values the call passes. */
arguments: ArrayLike<unknown>
}

/** 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. */
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions gottext/test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
compileCatalog,
goMessages,
goString,
messages,
mismatched,
orphaned,
pot,
Expand Down Expand Up @@ -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",
Expand Down
Loading