diff --git a/jest.config.js b/jest.config.js index 4299de529..9ac4a0991 100644 --- a/jest.config.js +++ b/jest.config.js @@ -70,6 +70,7 @@ module.exports = { "/packages/format-csv", "/packages/message-utils", "/packages/extractor-vue", + "/packages/format-xliff", ], }, ], diff --git a/packages/format-xliff/README.md b/packages/format-xliff/README.md new file mode 100644 index 000000000..739c7c41d --- /dev/null +++ b/packages/format-xliff/README.md @@ -0,0 +1,77 @@ +[![License][badge-license]][license] +[![Version][badge-version]][package] +[![Downloads][badge-downloads]][package] + +# @lingui/format-po + +> Read and write message catalogs in Gettext PO format with ICU plurals + +`@lingui/format-po` is part of [LinguiJS][linguijs]. See the +[documentation][documentation] for all information, tutorials and examples. + +## Catalog example + +```po +#, Comment for translators +#: src/App.js:4, src/Component.js:2 +msgid "MessageID" +msgstr "Translated Message" +``` + +## Installation + +```sh +npm install --save-dev @lingui/format-po +# yarn add --dev @lingui/format-po +``` + +## Usage + +```js +// lingui.config.{js,ts} +import {formatter} from "@lingui/format-po" + +export default { + [...] + format: formatter({lineNumbers: false}), +} +``` + +Possible options: + +```ts +export type PoFormatterOptions = { + /** + * Print places where message is used + * + * @default true + */ + origins?: boolean + + /** + * Print line numbers in origins + * + * @default true + */ + lineNumbers?: boolean + + /** + * Print `js-lingui-id: Xs4as` statement in extracted comments section + * + * @default false + */ + printLinguiId?: boolean +} +``` + +## License + +This package is licensed under [MIT][license] license. + +[license]: https://github.com/lingui/js-lingui/blob/main/LICENSE +[linguijs]: https://github.com/lingui/js-lingui +[documentation]: https://lingui.dev +[package]: https://www.npmjs.com/package/@lingui/format-po +[badge-downloads]: https://img.shields.io/npm/dw/@lingui/format-po.svg +[badge-version]: https://img.shields.io/npm/v/@lingui/format-po.svg +[badge-license]: https://img.shields.io/npm/l/@lingui/format-po.svg diff --git a/packages/format-xliff/package.json b/packages/format-xliff/package.json new file mode 100644 index 000000000..1d2a70995 --- /dev/null +++ b/packages/format-xliff/package.json @@ -0,0 +1,48 @@ +{ + "name": "@lingui/format-xliff", + "version": "4.0.0", + "description": "Gettext PO format for Lingui Catalogs", + "main": "./dist/xliff.cjs", + "module": "./dist/xliff.mjs", + "types": "./dist/xliff.d.ts", + "license": "MIT", + "keywords": [ + "i18n", + "lingui-format", + "lingui-formatter", + "xliff", + "internationalization", + "i10n", + "localization", + "i9n", + "translation" + ], + "scripts": { + "build": "rimraf ./dist && unbuild", + "stub": "unbuild --stub" + }, + "repository": { + "type": "git", + "url": "https://github.com/lingui/js-lingui.git" + }, + "bugs": { + "url": "https://github.com/lingui/js-lingui/issues" + }, + "engines": { + "node": ">=16.0.0" + }, + "files": [ + "LICENSE", + "README.md", + "dist/" + ], + "dependencies": { + "@lingui/cli": "4.1.2", + "@lingui/conf": "4.1.2", + "xml-js": "^1.6.11" + }, + "devDependencies": { + "@lingui/jest-mocks": "workspace:^", + "unbuild": "^1.1.2" + } +} diff --git a/packages/format-xliff/src/__snapshots__/xliff.test.ts.snap b/packages/format-xliff/src/__snapshots__/xliff.test.ts.snap new file mode 100644 index 000000000..95bc7940c --- /dev/null +++ b/packages/format-xliff/src/__snapshots__/xliff.test.ts.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`xliff format should write catalog in pofile format 1`] = ` + + + + + + + + + + + src/App.js + 4 + + + + + my context + + + + my context + + + + + src/App.js + 4 + + + src/Component.js + 2 + + + + + Description is comment from developers to translators + + + + + + + + + String]]> + + + + +`; diff --git a/packages/format-xliff/src/xliff.test.ts b/packages/format-xliff/src/xliff.test.ts new file mode 100644 index 000000000..53f5f4c0a --- /dev/null +++ b/packages/format-xliff/src/xliff.test.ts @@ -0,0 +1,93 @@ +import { formatter as createFormatter } from "./xliff" +import { CatalogType } from "@lingui/conf" + +describe("xliff format", () => { + it("should write catalog in pofile format", async () => { + const format = createFormatter({ origins: true }) + + const catalog: CatalogType = { + static: { + translation: "Static message", + }, + withOrigin: { + translation: "Message with origin", + origin: [["src/App.js", 4]], + }, + withContext: { + translation: "Message with context", + context: "my context", + }, + Dgzql1: { + message: "with generated id", + translation: "", + context: "my context", + }, + withMultipleOrigins: { + translation: "Message with multiple origin", + origin: [ + ["src/App.js", 4], + ["src/Component.js", 2], + ], + }, + withDescription: { + translation: "Message with description", + comments: ["Description is comment from developers to translators"], + }, + // obsolete: { + // translation: "Obsolete message", + // obsolete: true, + // }, + // withFlags: { + // flags: ["fuzzy", "otherFlag"], + // translation: "Keeps any flags that are defined", + // }, + veryLongString: { + translation: + "One morning, when Gregor Samsa woke from troubled dreams, he found himself" + + " transformed in his bed into a horrible vermin. He lay on his armour-like" + + " back, and if he lifted his head a little he could see his brown belly," + + " slightly domed and divided by arches into stiff sections. The bedding was" + + " hardly able to cover it and seemed ready to slide off any moment. His many" + + " legs, pitifully thin compared with the size of the rest of him, waved about" + + " helplessly as he looked. \"What's happened to me?\" he thought. It wasn't" + + " a dream. His room, a proper human", + }, + + stringWithPlaceholders: { + message: "Hello {world}", + translation: null, + }, + + stringWithJsxPlaceholders: { + message: "Hello <0>String", + translation: null, + }, + } + + const actual = format.serialize(catalog, { + locale: "en", + existing: null, + }) + expect(actual).toMatchSnapshot() + }) + + it.todo( + "should read catalog in xliff format" /*, () => { + const format = createFormatter() + + const pofile = fs + .readFileSync(path.join(__dirname, "fixtures/messages.po")) + .toString() + + const actual = format.parse(pofile) + expect(actual).toMatchSnapshot() + }*/ + ) + + it.todo("should preserve attributes stored on trans unit") + it.todo("should preserve children in trans unit") + + it.todo("should not include origins if origins option is false") + + it.todo("should not include lineNumbers if lineNumbers option is false") +}) diff --git a/packages/format-xliff/src/xliff.ts b/packages/format-xliff/src/xliff.ts new file mode 100644 index 000000000..3949d0b5d --- /dev/null +++ b/packages/format-xliff/src/xliff.ts @@ -0,0 +1,195 @@ +import xmlJs from "xml-js" + +import { + CatalogFormatter, + CatalogType, + MessageOrigin, + MessageType, +} from "@lingui/conf" + +// const splitOrigin = (origin: string) => { +// const [file, line] = origin.split(":") +// return [file, line ? Number(line) : null] as [file: string, line: number] +// } +// +// const joinOrigin = (origin: [file: string, line?: number]): string => +// origin.join(":") + +export type PoFormatterOptions = { + /** + * Print places where message is used + * + * @default true + */ + origins?: boolean + + /** + * Print line numbers in origins + * + * @default true + */ + lineNumbers?: boolean +} + +// todo +// function deserialize(items: POItem[]): CatalogType { +// return items.reduce((catalog, item) => { +// const message: MessageType = { +// translation: item.msgstr[0], +// extractedComments: item.extractedComments || [], +// comments: item.comments || [], +// context: item.msgctxt ?? null, +// obsolete: item.flags.obsolete || item.obsolete, +// origin: (item.references || []).map((ref) => splitOrigin(ref)), +// flags: Object.keys(item.flags).map((flag) => flag.trim()), +// } +// +// let id = item.msgid +// +// // if generated id, recreate it +// if (!item.flags[EXPLICIT_ID_FLAG]) { +// id = generateMessageId(item.msgid, item.msgctxt) +// message.message = item.msgid +// } +// +// catalog[id] = message +// return catalog +// }, {}) +// } + +export function formatter(options: PoFormatterOptions = {}) { + options = { + origins: true, + lineNumbers: true, + ...options, + } + + return { + catalogExtension: ".po", + templateExtension: ".pot", + + parse(content: string): CatalogType { + // todo + return {} + // const po = PO.parse(content) + // return deserialize(po.items) + }, + + serialize( + catalog: CatalogType, + ctx: { locale: string; existing: string } + ): string { + const units = Object.keys(catalog).map((id) => { + return createTransUnit(id, catalog[id]) + }) + + const data = createBody("en", ctx.locale, units) + return xmlJs.js2xml(data, { + spaces: 2, + }) + }, + } satisfies CatalogFormatter +} + +function createNote(meaning: string, from: string) { + return { + type: "element", + name: "note", + attributes: { priority: "1", from }, + elements: [{ type: "text", text: meaning }], + } +} + +function createBody( + sourceLang: string, + targetLang: string, + transUnits: any[] +): xmlJs.Element { + return { + declaration: { attributes: { version: "1.0", encoding: "UTF-8" } }, + elements: [ + { + type: "element", + name: "xliff", + attributes: { + xmlns: "urn:oasis:names:tc:xliff:document:1.2", + version: "1.2", + }, + elements: [ + { + type: "element", + name: "file", + attributes: { + "source-language": sourceLang, + datatype: "plaintext", + original: "js-lingui", + "target-language": targetLang, + }, + elements: [ + { + type: "element", + name: "body", + elements: transUnits, + }, + ], + }, + ], + }, + ], + } +} + +function createOrigin([filename, line]: MessageOrigin) { + return { + type: "element", + name: "context-group", + attributes: { purpose: "location" }, + elements: [ + { + type: "element", + name: "context", + attributes: { "context-type": "sourcefile" }, + elements: [ + { + type: "text", + text: filename, + }, + ], + }, + { + type: "element", + name: "context", + attributes: { "context-type": "linenumber" }, + elements: [{ type: "text", text: line.toString() }], + }, + ], + } +} + +function createElement(name: string, content: string): xmlJs.Element { + return { + type: "element", + name: name, + elements: [{ type: "cdata", cdata: content }], + } +} + +function createTransUnit(id: string, message: MessageType) { + return { + type: "element", + name: "trans-unit", + attributes: { + id: id, + datatype: "html", + }, + elements: [ + ...(message.message ? [createElement("source", message.message)] : []), + ...(message.translation + ? [createElement("target", message.translation)] + : []), + ...(message.context ? [createNote(message.context, "context")] : []), + ...(message.comments || []).map((c) => createNote(c, "comment")), + ...(message.origin || []).map((o) => createOrigin(o)), + ], + } +} diff --git a/yarn.lock b/yarn.lock index f8bcfae74..466b920a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2818,6 +2818,18 @@ __metadata: languageName: unknown linkType: soft +"@lingui/format-xliff@workspace:packages/format-xliff": + version: 0.0.0-use.local + resolution: "@lingui/format-xliff@workspace:packages/format-xliff" + dependencies: + "@lingui/cli": 4.1.2 + "@lingui/conf": 4.1.2 + "@lingui/jest-mocks": "workspace:^" + unbuild: ^1.1.2 + xml-js: ^1.6.11 + languageName: unknown + linkType: soft + "@lingui/jest-mocks@*, @lingui/jest-mocks@workspace:^, @lingui/jest-mocks@workspace:packages/jest-mocks": version: 0.0.0-use.local resolution: "@lingui/jest-mocks@workspace:packages/jest-mocks" @@ -13000,6 +13012,13 @@ __metadata: languageName: node linkType: hard +"sax@npm:^1.2.4": + version: 1.2.4 + resolution: "sax@npm:1.2.4" + checksum: d3df7d32b897a2c2f28e941f732c71ba90e27c24f62ee918bd4d9a8cfb3553f2f81e5493c7f0be94a11c1911b643a9108f231dd6f60df3fa9586b5d2e3e9e1fe + languageName: node + linkType: hard + "saxes@npm:^5.0.1": version: 5.0.1 resolution: "saxes@npm:5.0.1" @@ -14926,6 +14945,17 @@ __metadata: languageName: node linkType: hard +"xml-js@npm:^1.6.11": + version: 1.6.11 + resolution: "xml-js@npm:1.6.11" + dependencies: + sax: ^1.2.4 + bin: + xml-js: ./bin/cli.js + checksum: 24a55479919413687105fc2d8ab05e613ebedb1c1bc12258a108e07cff5ef793779297db854800a4edf0281303ebd1f177bc4a588442f5344e62b3dddda26c2b + languageName: node + linkType: hard + "xml-name-validator@npm:^3.0.0": version: 3.0.0 resolution: "xml-name-validator@npm:3.0.0"