From 39b9a2dc9b91b005b11f3e99fd2aaae48c10b684 Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Mon, 13 Mar 2023 19:08:42 +0100 Subject: [PATCH] feature(format): support for external formatters --- packages/cli/src/api/catalog.ts | 18 +- .../cli/src/api/catalog/getCatalogs.test.ts | 183 ++++++++++-------- packages/cli/src/api/catalog/getCatalogs.ts | 5 +- packages/cli/src/api/formats/csv.test.ts | 14 +- packages/cli/src/api/formats/csv.ts | 46 ++--- packages/cli/src/api/formats/index.ts | 45 ++--- packages/cli/src/api/formats/lingui.test.ts | 21 +- packages/cli/src/api/formats/lingui.ts | 80 ++++---- packages/cli/src/api/formats/minimal.ts | 72 +++---- .../cli/src/api/formats/po-gettext.test.ts | 20 +- packages/cli/src/api/formats/po-gettext.ts | 102 +++++----- packages/cli/src/api/formats/po.test.ts | 39 ++-- packages/cli/src/api/formats/po.ts | 81 ++++---- packages/cli/src/lingui-compile.ts | 5 +- packages/conf/src/types.ts | 15 +- packages/loader/src/webpackLoader.ts | 4 +- packages/snowpack-plugin/src/index.ts | 4 +- 17 files changed, 428 insertions(+), 326 deletions(-) diff --git a/packages/cli/src/api/catalog.ts b/packages/cli/src/api/catalog.ts index 7a638e801..9070dc794 100644 --- a/packages/cli/src/api/catalog.ts +++ b/packages/cli/src/api/catalog.ts @@ -6,11 +6,8 @@ import normalize from "normalize-path" import { LinguiConfigNormalized, OrderBy } from "@lingui/conf" -import { - getFormat, - CatalogFormatOptionsInternal, - CatalogFormatter, -} from "./formats" +import { getFormat } from "./formats" +import { CatalogFormatter } from "@lingui/conf" import { CliExtractOptions } from "../lingui-extract" import { CliExtractTemplateOptions } from "../lingui-extract-template" import { CompiledCatalogNamespace } from "./compile" @@ -73,7 +70,7 @@ export class Catalog { this.path = normalizeRelativePath(path) this.include = include.map(normalizeRelativePath) this.exclude = [this.localeDir, ...exclude.map(normalizeRelativePath)] - this.format = getFormat(config.format) + this.format = getFormat(config.format, config.formatOptions) this.templateFile = templatePath || getTemplatePath(this.format, this.path) } @@ -182,9 +179,8 @@ export class Catalog { replacePlaceholders(this.path, { locale }) + this.format.catalogExtension const created = !fs.existsSync(filename) - const options = { ...this.config.formatOptions, locale } - this.format.write(filename, messages, options) + this.format.write(filename, messages, { locale }) return [created, filename] } @@ -194,11 +190,7 @@ export class Catalog { writeTemplate(messages: CatalogType) { const filename = this.templateFile - const options: CatalogFormatOptionsInternal = { - ...this.config.formatOptions, - locale: undefined, - } - this.format.write(filename, messages, options) + this.format.write(filename, messages, { locale: undefined }) } writeCompiled( diff --git a/packages/cli/src/api/catalog/getCatalogs.test.ts b/packages/cli/src/api/catalog/getCatalogs.test.ts index 0a2128559..2012706e6 100644 --- a/packages/cli/src/api/catalog/getCatalogs.test.ts +++ b/packages/cli/src/api/catalog/getCatalogs.test.ts @@ -33,17 +33,19 @@ describe("getCatalogs", () => { }, ], }) - expect(getCatalogs(config)).toEqual([ - new Catalog( - { - name: null, - path: "src/locales/{locale}", - include: ["src/"], - exclude: [], - }, - config - ), - ]) + expect(cleanCatalog(getCatalogs(config)[0])).toEqual( + cleanCatalog( + new Catalog( + { + name: null, + path: "src/locales/{locale}", + include: ["src/"], + exclude: [], + }, + config + ) + ) + ) }) it("should have catalog name and ignore patterns", () => { @@ -56,17 +58,19 @@ describe("getCatalogs", () => { }, ], }) - expect(getCatalogs(config)).toEqual([ - new Catalog( - { - name: "all", - path: "src/locales/{locale}/all", - include: ["src/", "/absolute/path/"], - exclude: ["node_modules/"], - }, - config - ), - ]) + expect(cleanCatalog(getCatalogs(config)[0])).toEqual( + cleanCatalog( + new Catalog( + { + name: "all", + path: "src/locales/{locale}/all", + include: ["src/", "/absolute/path/"], + exclude: ["node_modules/"], + }, + config + ) + ) + ) }) it("should expand {name} for matching directories", () => { @@ -87,24 +91,31 @@ describe("getCatalogs", () => { }, ], }) - expect(getCatalogs(config)).toEqual([ - new Catalog( - { - name: "componentA", - path: "componentA/locales/{locale}", - include: ["componentA/"], - exclude: [], - }, - config + expect([ + cleanCatalog(getCatalogs(config)[0]), + cleanCatalog(getCatalogs(config)[1]), + ]).toEqual([ + cleanCatalog( + new Catalog( + { + name: "componentA", + path: "componentA/locales/{locale}", + include: ["componentA/"], + exclude: [], + }, + config + ) ), - new Catalog( - { - name: "componentB", - path: "componentB/locales/{locale}", - include: ["componentB/"], - exclude: [], - }, - config + cleanCatalog( + new Catalog( + { + name: "componentB", + path: "componentB/locales/{locale}", + include: ["componentB/"], + exclude: [], + }, + config + ) ), ]) }) @@ -124,17 +135,19 @@ describe("getCatalogs", () => { }, ], }) - expect(getCatalogs(config)).toEqual([ - new Catalog( - { - name: "componentA", - path: "componentA/locales/{locale}/componentA_messages_{locale}", - include: ["componentA/"], - exclude: [], - }, - config - ), - ]) + expect(cleanCatalog(getCatalogs(config)[0])).toEqual( + cleanCatalog( + new Catalog( + { + name: "componentA", + path: "componentA/locales/{locale}/componentA_messages_{locale}", + include: ["componentA/"], + exclude: [], + }, + config + ) + ) + ) }) it("shouldn't expand {name} for ignored directories", () => { @@ -156,17 +169,19 @@ describe("getCatalogs", () => { }, ], }) - expect(getCatalogs(config)).toEqual([ - new Catalog( - { - name: "componentA", - path: "componentA/locales/{locale}", - include: ["componentA/"], - exclude: ["componentB/"], - }, - config - ), - ]) + expect(cleanCatalog(getCatalogs(config)[0])).toEqual( + cleanCatalog( + new Catalog( + { + name: "componentA", + path: "componentA/locales/{locale}", + include: ["componentA/"], + exclude: ["componentB/"], + }, + config + ) + ) + ) }) it("should warn if catalogPath is a directory", () => { @@ -216,6 +231,12 @@ describe("getCatalogs", () => { }) }) +// remove non-serializable properties, which are not subject of a test +function cleanCatalog(catalog: Catalog) { + delete catalog.config + delete catalog.format + return catalog +} describe("getCatalogForMerge", () => { afterEach(() => { mockFs.restore() @@ -225,15 +246,17 @@ describe("getCatalogForMerge", () => { const config = mockConfig({ catalogsMergePath: "locales/{locale}", }) - expect(getCatalogForMerge(config)).toEqual( - new Catalog( - { - name: null, - path: "locales/{locale}", - include: [], - exclude: [], - }, - config + expect(cleanCatalog(getCatalogForMerge(config))).toEqual( + cleanCatalog( + new Catalog( + { + name: null, + path: "locales/{locale}", + include: [], + exclude: [], + }, + config + ) ) ) }) @@ -242,15 +265,17 @@ describe("getCatalogForMerge", () => { const config = mockConfig({ catalogsMergePath: "locales/{locale}/my/dir", }) - expect(getCatalogForMerge(config)).toEqual( - new Catalog( - { - name: "dir", - path: "locales/{locale}/my/dir", - include: [], - exclude: [], - }, - config + expect(cleanCatalog(getCatalogForMerge(config))).toStrictEqual( + cleanCatalog( + new Catalog( + { + name: "dir", + path: "locales/{locale}/my/dir", + include: [], + exclude: [], + }, + config + ) ) ) }) diff --git a/packages/cli/src/api/catalog/getCatalogs.ts b/packages/cli/src/api/catalog/getCatalogs.ts index 80902fbb3..935a6d203 100644 --- a/packages/cli/src/api/catalog/getCatalogs.ts +++ b/packages/cli/src/api/catalog/getCatalogs.ts @@ -134,7 +134,10 @@ function validateCatalogPath(path: string, config: LinguiConfigNormalized) { return } - const extension = getFormat(config.format).catalogExtension + const extension = getFormat( + config.format, + config.formatOptions + ).catalogExtension const correctPath = path.slice(0, -1) const examplePath = replacePlaceholders(correctPath, { diff --git a/packages/cli/src/api/formats/csv.test.ts b/packages/cli/src/api/formats/csv.test.ts index 8483208ff..0905facc4 100644 --- a/packages/cli/src/api/formats/csv.test.ts +++ b/packages/cli/src/api/formats/csv.test.ts @@ -2,14 +2,16 @@ import fs from "fs" import path from "path" import mockFs from "mock-fs" -import format from "./csv" +import createFormatter from "./csv" + +describe("csv format", () => { + const format = createFormatter() -describe("csv format", function () { afterEach(() => { mockFs.restore() }) - it("should write catalog in csv format", function () { + it("should write catalog in csv format", () => { mockFs({ locale: { en: mockFs.directory(), @@ -34,7 +36,7 @@ describe("csv format", function () { expect(csv).toMatchSnapshot() }) - it("should not throw if directory not exists", function () { + it("should not throw if directory not exists", () => { mockFs({}) const filename = path.join("locale", "en", "messages.csv") const catalog = { @@ -49,7 +51,7 @@ describe("csv format", function () { expect(content).toBeTruthy() }) - it("should read catalog in csv format", function () { + it("should read catalog in csv format", () => { const csv = fs .readFileSync( path.join(path.resolve(__dirname), "fixtures", "messages.csv") @@ -79,7 +81,7 @@ describe("csv format", function () { expect(actual).toBeNull() }) - it("should write the same catalog as it was read", function () { + it("should write the same catalog as it was read", () => { const csv = fs .readFileSync( path.join(path.resolve(__dirname), "fixtures", "messages.csv") diff --git a/packages/cli/src/api/formats/csv.ts b/packages/cli/src/api/formats/csv.ts index a5d8dc263..057d816b1 100644 --- a/packages/cli/src/api/formats/csv.ts +++ b/packages/cli/src/api/formats/csv.ts @@ -1,7 +1,7 @@ import Papa from "papaparse" import { readFile, writeFileIfChanged } from "../utils" -import type { CatalogFormatter } from "." +import type { CatalogFormatter } from "@lingui/conf" import { CatalogType, MessageType } from "../types" const serialize = (catalog: CatalogType) => { @@ -31,31 +31,31 @@ const deserialize = (raw: string): { [key: string]: MessageType } => { return messages } -const csv: CatalogFormatter = { - catalogExtension: ".csv", +export default function (): CatalogFormatter { + return { + catalogExtension: ".csv", - write(filename, catalog) { - const messages = serialize(catalog) - writeFileIfChanged(filename, messages) - }, + write(filename: string, catalog: CatalogType) { + const messages = serialize(catalog) + writeFileIfChanged(filename, messages) + }, - read(filename) { - const raw = readFile(filename) + read(filename: string) { + const raw = readFile(filename) - if (!raw) { - return null - } + if (!raw) { + return null + } - try { - return deserialize(raw) - } catch (e) { - throw new Error(`Cannot read ${filename}: ${(e as Error).message}`) - } - }, + try { + return deserialize(raw) + } catch (e) { + throw new Error(`Cannot read ${filename}: ${(e as Error).message}`) + } + }, - parse(content: string) { - return deserialize(content) - }, + parse(content: string) { + return deserialize(content) + }, + } } - -export default csv diff --git a/packages/cli/src/api/formats/index.ts b/packages/cli/src/api/formats/index.ts index 50ba43f4f..720436de9 100644 --- a/packages/cli/src/api/formats/index.ts +++ b/packages/cli/src/api/formats/index.ts @@ -1,7 +1,10 @@ -import type { CatalogFormat, CatalogFormatOptions } from "@lingui/conf" -import type { CatalogType } from "../types" +import type { CatalogFormat, CatalogFormatter } from "@lingui/conf" +import { CatalogFormatOptions } from "@lingui/conf" -const formats: Record CatalogFormatter> = { +const formats: Record< + CatalogFormat, + () => (options: CatalogFormatOptions) => CatalogFormatter +> = { lingui: () => require("./lingui").default, minimal: () => require("./minimal").default, po: () => require("./po.ts").default, @@ -9,39 +12,23 @@ const formats: Record CatalogFormatter> = { "po-gettext": () => require("./po-gettext").default, } -/** - * @internal - */ -export type CatalogFormatOptionsInternal = { - locale?: string -} & CatalogFormatOptions - -export type CatalogFormatter = { - catalogExtension: string - /** - * Set extension used when extract to template - * Omit if the extension is the same as catalogExtension - */ - templateExtension?: string - write( - filename: string, - catalog: CatalogType, - options?: CatalogFormatOptionsInternal - ): void - read(filename: string): CatalogType | null - parse(content: unknown): CatalogType | null -} +export function getFormat( + _format: CatalogFormat | CatalogFormatter, + options: CatalogFormatOptions +): CatalogFormatter { + if (typeof _format !== "string") { + return _format + } -export function getFormat(name: CatalogFormat): CatalogFormatter { - const format = formats[name] + const format = formats[_format] if (!format) { throw new Error( - `Unknown format "${name}". Use one of following: ${Object.keys( + `Unknown format "${_format}". Use one of following: ${Object.keys( formats ).join(", ")}` ) } - return format() + return format()(options) } diff --git a/packages/cli/src/api/formats/lingui.test.ts b/packages/cli/src/api/formats/lingui.test.ts index 16b3bfb3d..68b99c494 100644 --- a/packages/cli/src/api/formats/lingui.test.ts +++ b/packages/cli/src/api/formats/lingui.test.ts @@ -3,7 +3,7 @@ import path from "path" import mockFs from "mock-fs" import mockDate from "mockdate" -import format from "./lingui" +import createFormat from "./lingui" import { CatalogType } from "../types" describe("lingui format", () => { @@ -13,6 +13,8 @@ describe("lingui format", () => { }) it("should write catalog in lingui format", () => { + const format = createFormat({ origins: true }) + mockFs({ locale: { en: mockFs.directory(), @@ -67,13 +69,15 @@ describe("lingui format", () => { }, } - format.write(filename, catalog, { origins: true, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const lingui = fs.readFileSync(filename).toString() mockFs.restore() expect(lingui).toMatchSnapshot() }) it("should not throw if directory not exists", () => { + const format = createFormat() + mockFs({}) const filename = path.join("locale", "en", "messages.json") const catalog = { @@ -82,13 +86,15 @@ describe("lingui format", () => { }, } - format.write(filename, catalog, {}) + format.write(filename, catalog) const content = fs.readFileSync(filename).toString() mockFs.restore() expect(content).toBeTruthy() }) it("should read catalog in lingui format", () => { + const format = createFormat() + const lingui = fs .readFileSync( path.join(path.resolve(__dirname), "fixtures", "messages.json") @@ -110,6 +116,7 @@ describe("lingui format", () => { }) it("should not throw if file not exists", () => { + const format = createFormat() mockFs({}) const filename = path.join("locale", "en", "messages.json") @@ -119,6 +126,8 @@ describe("lingui format", () => { }) it("should not include origins if origins option is false", () => { + const format = createFormat({ origins: false }) + mockFs({ locale: { en: mockFs.directory(), @@ -142,7 +151,7 @@ describe("lingui format", () => { ], }, } - format.write(filename, catalog, { origins: false, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const lingui = fs.readFileSync(filename).toString() mockFs.restore() const linguiOriginProperty = '"origin"' @@ -150,6 +159,8 @@ describe("lingui format", () => { }) it("should not include lineNumbers if lineNumbers option is false", () => { + const format = createFormat({ lineNumbers: false }) + mockFs({ locale: { en: mockFs.directory(), @@ -173,7 +184,7 @@ describe("lingui format", () => { ], }, } - format.write(filename, catalog, { lineNumbers: false, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const lingui = fs.readFileSync(filename).toString() mockFs.restore() expect(lingui).toMatchInlineSnapshot(` diff --git a/packages/cli/src/api/formats/lingui.ts b/packages/cli/src/api/formats/lingui.ts index e2cc429cd..b1044e2c4 100644 --- a/packages/cli/src/api/formats/lingui.ts +++ b/packages/cli/src/api/formats/lingui.ts @@ -2,7 +2,12 @@ import * as R from "ramda" import { readFile, writeFileIfChanged } from "../utils" import { CatalogType, ExtractedMessageType } from "../types" -import { CatalogFormatter } from "." +import { CatalogFormatter } from "@lingui/conf" + +export type LinguiFormatterOptions = { + origins?: boolean + lineNumbers?: boolean +} type NoOriginsCatalogType = { [P in keyof CatalogType]: Omit @@ -22,37 +27,44 @@ const removeLineNumbers = R.map((message: ExtractedMessageType) => { return message }) as unknown as (catalog: ExtractedMessageType) => NoOriginsCatalogType -const lingui: CatalogFormatter = { - catalogExtension: ".json", - - write(filename, catalog, options) { - let outputCatalog: CatalogType | NoOriginsCatalogType = catalog - if (options.origins === false) { - outputCatalog = removeOrigins(catalog) - } - if (options.origins !== false && options.lineNumbers === false) { - outputCatalog = removeLineNumbers(outputCatalog) - } - writeFileIfChanged(filename, JSON.stringify(outputCatalog, null, 2)) - }, - - read(filename) { - const raw = readFile(filename) - - if (!raw) { - return null - } - - try { - return JSON.parse(raw) - } catch (e) { - throw new Error(`Cannot read ${filename}: ${(e as Error).message}`) - } - }, - - parse(content) { - return content as CatalogType - }, -} +export default function ( + options: LinguiFormatterOptions = {} +): CatalogFormatter { + options = { + origins: true, + lineNumbers: true, + ...options, + } + return { + catalogExtension: ".json", + + write(filename, catalog) { + let outputCatalog: CatalogType | NoOriginsCatalogType = catalog + if (options.origins === false) { + outputCatalog = removeOrigins(catalog) + } + if (options.origins !== false && options.lineNumbers === false) { + outputCatalog = removeLineNumbers(outputCatalog) + } + writeFileIfChanged(filename, JSON.stringify(outputCatalog, null, 2)) + }, -export default lingui + read(filename) { + const raw = readFile(filename) + + if (!raw) { + return null + } + + try { + return JSON.parse(raw) + } catch (e) { + throw new Error(`Cannot read ${filename}: ${(e as Error).message}`) + } + }, + + parse(content) { + return content as CatalogType + }, + } +} diff --git a/packages/cli/src/api/formats/minimal.ts b/packages/cli/src/api/formats/minimal.ts index f640c69e5..4b75bfdae 100644 --- a/packages/cli/src/api/formats/minimal.ts +++ b/packages/cli/src/api/formats/minimal.ts @@ -1,6 +1,6 @@ import * as R from "ramda" -import { CatalogFormatter } from "." +import { CatalogFormatter } from "@lingui/conf" import type { CatalogType, MessageType } from "../types" import { readFile, writeFile } from "../utils" @@ -17,39 +17,39 @@ const deserialize = R.map((translation: string) => ({ origin: [], })) as unknown as (minimalCatalog: MinimalCatalogType) => CatalogType -const minimal: CatalogFormatter = { - catalogExtension: ".json", - - write(filename, catalog) { - const messages = serialize(catalog) - let file = readFile(filename) - - const shouldUseTrailingNewline = file === null || file?.endsWith("\n") - const trailingNewLine = shouldUseTrailingNewline ? "\n" : "" - writeFile( - filename, - `${JSON.stringify(messages, null, 2)}${trailingNewLine}` - ) - }, - - read(filename) { - const raw = readFile(filename) - - if (!raw) { - return null - } - - try { - const rawCatalog: Record = JSON.parse(raw) - return deserialize(rawCatalog) - } catch (e) { - throw new Error(`Cannot read ${filename}: ${(e as Error).message}`) - } - }, - - parse(content: Record) { - return deserialize(content) - }, +export default function (): CatalogFormatter { + return { + catalogExtension: ".json", + + write(filename: string, catalog: CatalogType) { + const messages = serialize(catalog) + let file = readFile(filename) + + const shouldUseTrailingNewline = file === null || file?.endsWith("\n") + const trailingNewLine = shouldUseTrailingNewline ? "\n" : "" + writeFile( + filename, + `${JSON.stringify(messages, null, 2)}${trailingNewLine}` + ) + }, + + read(filename: string) { + const raw = readFile(filename) + + if (!raw) { + return null + } + + try { + const rawCatalog: Record = JSON.parse(raw) + return deserialize(rawCatalog) + } catch (e) { + throw new Error(`Cannot read ${filename}: ${(e as Error).message}`) + } + }, + + parse(content: Record) { + return deserialize(content) + }, + } } - -export default minimal diff --git a/packages/cli/src/api/formats/po-gettext.test.ts b/packages/cli/src/api/formats/po-gettext.test.ts index 9d607e608..3289e75e9 100644 --- a/packages/cli/src/api/formats/po-gettext.test.ts +++ b/packages/cli/src/api/formats/po-gettext.test.ts @@ -5,7 +5,7 @@ import mockDate from "mockdate" import path from "path" import { CatalogType } from "../types" -import format, { serialize } from "./po-gettext" +import createFormat, { serialize } from "./po-gettext" describe("po-gettext format", () => { afterEach(() => { @@ -13,7 +13,9 @@ describe("po-gettext format", () => { mockDate.reset() }) - it("should not throw if directory not exists", function () { + it("should not throw if directory not exists", () => { + const format = createFormat() + mockFs({}) const filename = path.join("locale", "en", "messages.po") const catalog = { @@ -30,6 +32,8 @@ describe("po-gettext format", () => { }) it("should not throw if file not exists", () => { + const format = createFormat() + mockFs({}) const filename = path.join("locale", "en", "messages.po") @@ -38,7 +42,9 @@ describe("po-gettext format", () => { expect(actual).toBeNull() }) - it("should convert ICU plural messages to gettext plurals", function () { + it("should convert ICU plural messages to gettext plurals", () => { + const format = createFormat() + mockFs({ locale: { en: mockFs.directory(), @@ -91,7 +97,9 @@ describe("po-gettext format", () => { expect(pofile).toMatchSnapshot() }) - it("should convert gettext plurals to ICU plural messages", function () { + it("should convert gettext plurals to ICU plural messages", () => { + const format = createFormat() + const pofile = fs .readFileSync( path.join(path.resolve(__dirname), "fixtures", "messages_plural.po") @@ -139,6 +147,8 @@ describe("po-gettext format", () => { }) it("should use correct ICU plural cases for languages having an additional plural case for fractions", () => { + const format = createFormat() + // This tests the edge case described in https://github.com/lingui/js-lingui/pull/677#issuecomment-737152022 const po = ` msgid "" @@ -227,6 +237,8 @@ msgstr[2] "# dnĂ­" }) it("convertPluralsToIco handle correctly locales with 4-letter", () => { + const format = createFormat() + const pofile = fs .readFileSync( path.join( diff --git a/packages/cli/src/api/formats/po-gettext.ts b/packages/cli/src/api/formats/po-gettext.ts index 988b6e510..8989fc528 100644 --- a/packages/cli/src/api/formats/po-gettext.ts +++ b/packages/cli/src/api/formats/po-gettext.ts @@ -7,12 +7,18 @@ import gettextPlurals from "node-gettext/lib/plurals" import { CatalogType, MessageType } from "../types" import { readFile, writeFileIfChanged } from "../utils" -import type { CatalogFormatOptionsInternal, CatalogFormatter } from "./" +import type { CatalogFormatter } from "@lingui/conf" import { deserialize, serialize as serializePo } from "./po" // Workaround because pofile doesn't support es6 modules, see https://github.com/rubenv/pofile/pull/38#issuecomment-623119284 type POItem = InstanceType +export type PoGetTextFormatterOptions = { + origins?: boolean + lineNumbers?: boolean + disableSelectWarning?: boolean +} + function getCreateHeaders(language = "no"): PO["headers"] { return { "POT-Creation-Date": formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx"), @@ -56,7 +62,7 @@ function serializePlurals( message: MessageType, id: string, isGeneratedId: boolean, - options: CatalogFormatOptionsInternal + options: PoGetTextFormatterOptions ): POItem { // Depending on whether custom ids are used by the developer, the (potential plural) "original", untranslated ICU // message can be found in `message.message` or in the item's `key` itself. @@ -149,7 +155,7 @@ function serializePlurals( export const serialize = ( catalog: CatalogType, - options: CatalogFormatOptionsInternal + options: PoGetTextFormatterOptions ) => { return serializePo(catalog, options, (item, message, id, isGeneratedId) => serializePlurals(item, message, id, isGeneratedId, options) @@ -256,50 +262,58 @@ const convertPluralsToICU = ( item.msgstr = ["{" + pluralizeOn + ", plural, " + pluralClauses + "}"] } -const poGettext: CatalogFormatter = { - catalogExtension: ".po", - templateExtension: ".pot", - - write(filename, catalog: CatalogType, options) { - let po: PO - - const raw = readFile(filename) - if (raw) { - po = PO.parse(raw) - } else { - po = new PO() - po.headers = getCreateHeaders(options.locale) - if (options.locale === undefined) { - delete po.headers.Language +export default function ( + options: PoGetTextFormatterOptions = {} +): CatalogFormatter { + options = { + origins: true, + lineNumbers: true, + ...options, + } + + return { + catalogExtension: ".po", + templateExtension: ".pot", + + write(filename, catalog: CatalogType, ctx) { + let po: PO + + const raw = readFile(filename) + if (raw) { + po = PO.parse(raw) + } else { + po = new PO() + po.headers = getCreateHeaders(ctx.locale) + if (ctx.locale === undefined) { + delete po.headers.Language + } + // accessing private property + ;(po as any).headerOrder = Object.keys(po.headers) + } + po.items = serialize(catalog, options) + writeFileIfChanged(filename, po.toString()) + }, + + read(filename) { + const raw = readFile(filename) + if (!raw) { + return null } - // accessing private property - ;(po as any).headerOrder = Object.keys(po.headers) - } - po.items = serialize(catalog, options) - writeFileIfChanged(filename, po.toString()) - }, - - read(filename) { - const raw = readFile(filename) - if (!raw) { - return null - } - return this.parse(raw) - }, + return this.parse(raw) + }, - parse(raw: string) { - const po = PO.parse(raw) + parse(raw: string) { + const po = PO.parse(raw) - // .po plurals are numbered 0-N and need to be mapped to ICU plural classes ("one", "few", "many"...). Different - // languages can have different plural classes (some start with "zero", some with "one"), so read that data from CLDR. - // `pluralForms` may be `null` if lang is not found. As long as no plural is used, don't report an error. - let pluralForms = getPluralCases(po.headers.Language) + // .po plurals are numbered 0-N and need to be mapped to ICU plural classes ("one", "few", "many"...). Different + // languages can have different plural classes (some start with "zero", some with "one"), so read that data from CLDR. + // `pluralForms` may be `null` if lang is not found. As long as no plural is used, don't report an error. + let pluralForms = getPluralCases(po.headers.Language) - return deserialize(po.items, (item) => { - convertPluralsToICU(item, pluralForms, po.headers.Language) - }) - }, + return deserialize(po.items, (item) => { + convertPluralsToICU(item, pluralForms, po.headers.Language) + }) + }, + } } - -export default poGettext diff --git a/packages/cli/src/api/formats/po.test.ts b/packages/cli/src/api/formats/po.test.ts index 033f7d5b5..455bbd141 100644 --- a/packages/cli/src/api/formats/po.test.ts +++ b/packages/cli/src/api/formats/po.test.ts @@ -5,7 +5,7 @@ import mockDate from "mockdate" import PO from "pofile" import { mockConsole } from "@lingui/jest-mocks" -import format from "./po" +import createFormatter from "./po" import { CatalogType } from "../types" import { normalizeLineEndings } from "../../tests" @@ -16,6 +16,7 @@ describe("pofile format", () => { }) it("should write catalog in pofile format", () => { + const format = createFormatter({ origins: true }) mockFs({ locale: { en: mockFs.directory(), @@ -79,13 +80,15 @@ describe("pofile format", () => { }, } - format.write(filename, catalog, { origins: true, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const pofile = fs.readFileSync(filename).toString() mockFs.restore() expect(pofile).toMatchSnapshot() }) - it("should not throw if directory not exists", function () { + it("should not throw if directory not exists", () => { + const format = createFormatter() + mockFs({}) const filename = path.join("locale", "en", "messages.po") const catalog = { @@ -94,13 +97,15 @@ describe("pofile format", () => { }, } - format.write(filename, catalog, {}) + format.write(filename, catalog, { locale: "en" }) const content = fs.readFileSync(filename).toString() mockFs.restore() expect(content).toBeTruthy() }) it("should read catalog in pofile format", () => { + const format = createFormatter() + const pofile = fs .readFileSync( path.join(path.resolve(__dirname), "fixtures", "messages.po") @@ -122,6 +127,8 @@ describe("pofile format", () => { }) it("should not throw if file not exists", () => { + const format = createFormatter() + mockFs({}) const filename = path.join("locale", "en", "messages.po") @@ -131,6 +138,8 @@ describe("pofile format", () => { }) it("should serialize and deserialize messages with generated id", () => { + const format = createFormatter({ origins: true }) + mockFs({ locale: { en: mockFs.directory(), @@ -147,7 +156,7 @@ describe("pofile format", () => { } const filename = path.join("locale", "en", "messages.po") - format.write(filename, catalog, { origins: true, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const actual = format.read(filename) mockFs.restore() @@ -155,6 +164,8 @@ describe("pofile format", () => { }) it("should correct badly used comments", () => { + const format = createFormatter() + const po = PO.parse(` #. First description #. Second comment @@ -186,6 +197,8 @@ describe("pofile format", () => { }) it("should throw away additional msgstr if present", () => { + const format = createFormatter() + const po = PO.parse(` #, explicit-id msgid "withMultipleTranslation" @@ -215,6 +228,8 @@ describe("pofile format", () => { }) it("should write the same catalog as it was read", () => { + const format = createFormatter({ origins: true }) + const pofile = fs .readFileSync( path.join(path.resolve(__dirname), "fixtures", "messages.po") @@ -231,7 +246,7 @@ describe("pofile format", () => { const filename = path.join("locale", "en", "messages.po") const catalog = format.read(filename) - format.write(filename, catalog, { origins: true, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const actual = fs.readFileSync(filename).toString() mockFs.restore() @@ -239,6 +254,8 @@ describe("pofile format", () => { }) it("should not include origins if origins option is false", () => { + const format = createFormatter({ origins: false }) + mockFs({ locale: { en: mockFs.directory(), @@ -262,7 +279,7 @@ describe("pofile format", () => { ], }, } - format.write(filename, catalog, { origins: false, locale: "en" }) + format.write(filename, catalog, { locale: "en" }) const pofile = fs.readFileSync(filename).toString() mockFs.restore() const pofileOriginPrefix = "#:" @@ -270,6 +287,8 @@ describe("pofile format", () => { }) it("should not include lineNumbers if lineNumbers option is false", () => { + const format = createFormatter({ origins: true, lineNumbers: false }) + mockDate.set(new Date(2018, 7, 27, 10, 0, 0).toUTCString()) mockFs({ @@ -296,8 +315,6 @@ describe("pofile format", () => { }, } format.write(filename, catalog, { - origins: true, - lineNumbers: false, locale: "en", }) const pofile = fs.readFileSync(filename).toString() @@ -331,6 +348,8 @@ describe("pofile format", () => { }) it("should not include lineNumbers if lineNumbers option is false and already excluded", () => { + const format = createFormatter({ origins: true, lineNumbers: false }) + mockDate.set(new Date(2018, 7, 27, 10, 0, 0).toUTCString()) mockFs({ @@ -354,8 +373,6 @@ describe("pofile format", () => { }, } format.write(filename, catalog, { - origins: true, - lineNumbers: false, locale: "en", }) const pofile = fs.readFileSync(filename).toString() diff --git a/packages/cli/src/api/formats/po.ts b/packages/cli/src/api/formats/po.ts index 30d7a4467..1c50e4189 100644 --- a/packages/cli/src/api/formats/po.ts +++ b/packages/cli/src/api/formats/po.ts @@ -3,11 +3,16 @@ import PO from "pofile" import { joinOrigin, readFile, splitOrigin, writeFileIfChanged } from "../utils" import { CatalogType, MessageType } from "../types" -import { CatalogFormatOptionsInternal, CatalogFormatter } from "." +import { CatalogFormatter } from "@lingui/conf" import { generateMessageId } from "../generateMessageId" type POItem = InstanceType +export type PoFormatterOptions = { + origins?: boolean + lineNumbers?: boolean +} + function isGeneratedId(id: string, message: MessageType): boolean { return id === generateMessageId(message.message, message.context) } @@ -26,7 +31,7 @@ const EXPLICIT_ID_FLAG = "explicit-id" export const serialize = ( catalog: CatalogType, - options: CatalogFormatOptionsInternal, + options: PoFormatterOptions, postProcessItem?: ( item: POItem, message: MessageType, @@ -123,41 +128,47 @@ function validateItem(item: POItem): void { } } -const po: CatalogFormatter = { - catalogExtension: ".po", - templateExtension: ".pot", +export default function (options: PoFormatterOptions = {}): CatalogFormatter { + options = { + origins: true, + lineNumbers: true, + ...options, + } + + return { + catalogExtension: ".po", + templateExtension: ".pot", - write(filename, catalog, options) { - let po: PO + write(filename, catalog, ctx) { + let po: PO - const raw = readFile(filename) - if (raw) { - po = PO.parse(raw) - } else { - po = new PO() - po.headers = getCreateHeaders(options.locale) - if (options.locale === undefined) { - delete po.headers.Language + const raw = readFile(filename) + if (raw) { + po = PO.parse(raw) + } else { + po = new PO() + po.headers = getCreateHeaders(ctx.locale) + if (ctx.locale === undefined) { + delete po.headers.Language + } + // accessing private property + ;(po as any).headerOrder = Object.keys(po.headers) } - // accessing private property - ;(po as any).headerOrder = Object.keys(po.headers) - } - po.items = serialize(catalog, options) - writeFileIfChanged(filename, po.toString()) - }, - - read(filename) { - const raw = readFile(filename) - if (!raw) { - return null - } - return this.parse(raw) - }, + po.items = serialize(catalog, options) + writeFileIfChanged(filename, po.toString()) + }, + + read(filename) { + const raw = readFile(filename) + if (!raw) { + return null + } + return this.parse(raw) + }, - parse(raw: string) { - const po = PO.parse(raw) - return deserialize(po.items, validateItem) - }, + parse(raw: string) { + const po = PO.parse(raw) + return deserialize(po.items, validateItem) + }, + } } - -export default po diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts index 2598e244c..ef5884c2a 100644 --- a/packages/cli/src/lingui-compile.ts +++ b/packages/cli/src/lingui-compile.ts @@ -207,7 +207,10 @@ if (require.main === module) { const catalogs = getCatalogs(config) let paths: string[] = [] - const catalogExtension = getFormat(config.format).catalogExtension + const catalogExtension = getFormat( + config.format, + config.formatOptions + ).catalogExtension config.locales.forEach((locale) => { catalogs.forEach((catalog) => { diff --git a/packages/conf/src/types.ts b/packages/conf/src/types.ts index 456a144b4..8b110ef81 100644 --- a/packages/conf/src/types.ts +++ b/packages/conf/src/types.ts @@ -1,4 +1,5 @@ import { GeneratorOptions } from "@babel/core" +import { CatalogType } from "@lingui/cli/src/api" export type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext" @@ -21,6 +22,18 @@ export type ExtractorType = { ): Promise | void } +export type CatalogFormatter = { + catalogExtension: string + /** + * Set extension used when extract to template + * Omit if the extension is the same as catalogExtension + */ + templateExtension?: string + write(filename: string, catalog: CatalogType, ctx?: { locale: string }): void + read(filename: string): CatalogType | null + parse(content: unknown): CatalogType | null +} + export type ExtractedMessage = { id: string @@ -78,7 +91,7 @@ export type LinguiConfig = { extractors?: (string | ExtractorType)[] prevFormat?: CatalogFormat localeDir?: string - format?: CatalogFormat + format?: CatalogFormat | CatalogFormatter formatOptions?: CatalogFormatOptions locales: string[] catalogsMergePath?: string diff --git a/packages/loader/src/webpackLoader.ts b/packages/loader/src/webpackLoader.ts index 06bfc2ac2..83f9e0dbc 100644 --- a/packages/loader/src/webpackLoader.ts +++ b/packages/loader/src/webpackLoader.ts @@ -1,5 +1,5 @@ import path from "path" -import { getConfig } from "@lingui/conf" +import { CatalogFormat, getConfig } from "@lingui/conf" import { createCompiledCatalog, getCatalogs, @@ -56,7 +56,7 @@ export default function (source) { throw new Error( `File extension is mandatory, for ex: import("@lingui/loader!./${catalogRelativePath.replace( ".js", - formats[config.format] + formats[config.format as CatalogFormat] )}")` ) } diff --git a/packages/snowpack-plugin/src/index.ts b/packages/snowpack-plugin/src/index.ts index 231ce8873..3a637f273 100644 --- a/packages/snowpack-plugin/src/index.ts +++ b/packages/snowpack-plugin/src/index.ts @@ -1,5 +1,5 @@ import path from "path" -import { getConfig } from "@lingui/conf" +import { CatalogFormat, getConfig } from "@lingui/conf" import { createCompiledCatalog, getCatalogs, @@ -40,7 +40,7 @@ export default function compileLinguiMessages( } throw new Error( `@lingui/snowpack-plugin: File extension is mandatory, for ex: import('./locales/en/messages${ - formats[config.format] + formats[config.format as CatalogFormat] }')` ) }