From ed0de3ee806a54dab3f347ca5f41fc42b9910083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20STEUNOU?= Date: Mon, 29 Apr 2024 17:45:22 +0200 Subject: [PATCH] chore: add @lingui/vue add Vue.js support with components, plugins, extractor and compiler --- jest.config.js | 1 + packages/vue/README.md | 34 +++ packages/vue/package.json | 91 ++++++ packages/vue/src/common/Trans.test.ts | 219 ++++++++++++++ packages/vue/src/common/Trans.ts | 166 +++++++++++ packages/vue/src/common/predicates.test.ts | 113 +++++++ packages/vue/src/common/predicates.ts | 76 +++++ packages/vue/src/common/types.ts | 16 + packages/vue/src/common/vt.test.ts | 96 ++++++ packages/vue/src/common/vt.ts | 55 ++++ packages/vue/src/compiler/babel-macros.ts | 31 ++ packages/vue/src/compiler/index.ts | 2 + packages/vue/src/compiler/transformer.test.ts | 278 ++++++++++++++++++ packages/vue/src/compiler/transformer.ts | 92 ++++++ packages/vue/src/components/Trans.ts | 34 +++ packages/vue/src/components/format.ts | 86 ++++++ packages/vue/src/components/vt.ts | 33 +++ packages/vue/src/extractor/extractor.ts | 104 +++++++ packages/vue/src/extractor/index.ts | 1 + .../vue/src/extractor/transformer.test.ts | 275 +++++++++++++++++ packages/vue/src/extractor/transformer.ts | 82 ++++++ packages/vue/src/index.ts | 3 + packages/vue/src/plugins/lingui.ts | 27 ++ packages/vue/src/test/utils.ts | 43 +++ packages/vue/tsconfig.json | 12 + tsconfig.json | 3 +- yarn.lock | 204 +++++++++++++ 27 files changed, 2176 insertions(+), 1 deletion(-) create mode 100644 packages/vue/README.md create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/common/Trans.test.ts create mode 100644 packages/vue/src/common/Trans.ts create mode 100644 packages/vue/src/common/predicates.test.ts create mode 100644 packages/vue/src/common/predicates.ts create mode 100644 packages/vue/src/common/types.ts create mode 100644 packages/vue/src/common/vt.test.ts create mode 100644 packages/vue/src/common/vt.ts create mode 100644 packages/vue/src/compiler/babel-macros.ts create mode 100644 packages/vue/src/compiler/index.ts create mode 100644 packages/vue/src/compiler/transformer.test.ts create mode 100644 packages/vue/src/compiler/transformer.ts create mode 100644 packages/vue/src/components/Trans.ts create mode 100644 packages/vue/src/components/format.ts create mode 100644 packages/vue/src/components/vt.ts create mode 100644 packages/vue/src/extractor/extractor.ts create mode 100644 packages/vue/src/extractor/index.ts create mode 100644 packages/vue/src/extractor/transformer.test.ts create mode 100644 packages/vue/src/extractor/transformer.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/src/plugins/lingui.ts create mode 100644 packages/vue/src/test/utils.ts create mode 100644 packages/vue/tsconfig.json diff --git a/jest.config.js b/jest.config.js index 19e6dc374..cbc6b350b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -71,6 +71,7 @@ module.exports = { "/packages/format-csv", "/packages/message-utils", "/packages/extractor-vue", + "/packages/vue", ], }, ], diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 000000000..dcc28f7dc --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,34 @@ +[![License][badge-license]][license] +[![Version][badge-version]][package] +[![Downloads][badge-downloads]][package] + +# @lingui/vue + +> vue components for internationalization + +`@lingui/vue` is part of [LinguiJS][linguijs]. See the [documentation][documentation] for all information, tutorials and examples. + +## Installation + +```sh +npm install --save @lingui/vue +# yarn add @lingui/vue +``` + +## Usage + +See the [tutorial][tutorial] or [reference][reference] documentation. + +## License + +[MIT][license] + +[license]: https://github.com/lingui/js-lingui/blob/main/LICENSE +[linguijs]: https://github.com/lingui/js-lingui +[documentation]: https://lingui.dev +[tutorial]: https://lingui.dev/tutorials/vue +[reference]: https://lingui.dev/ref/vue +[package]: https://www.npmjs.com/package/@lingui/vue +[badge-downloads]: https://img.shields.io/npm/dw/@lingui/vue.svg +[badge-version]: https://img.shields.io/npm/v/@lingui/vue.svg +[badge-license]: https://img.shields.io/npm/l/@lingui/vue.svg diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 000000000..709bae8b4 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,91 @@ +{ + "name": "@lingui/vue", + "version": "4.8.0-next.1", + "sideEffects": false, + "description": "Vue components & tools for translations", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "author": { + "name": "Jérôme Steunou", + "email": "jerome.steunou@gmail.com" + }, + "license": "MIT", + "keywords": [ + "vue", + "component", + "i18n", + "internationalization", + "i9n", + "translation", + "icu", + "messageformat", + "multilingual", + "localization", + "l10n" + ], + "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" + }, + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "./compiler": { + "require": { + "types": "./dist/compiler/index.d.cts", + "default": "./dist/compiler/index.cjs" + }, + "import": { + "types": "./dist/compiler/index.d.mts", + "default": "./dist/compiler/index.mjs" + } + }, + "./extractor": { + "require": { + "types": "./dist/extractor/index.d.cts", + "default": "./dist/extractor/index.cjs" + }, + "import": { + "types": "./dist/extractor/index.d.mts", + "default": "./dist/extractor/index.mjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "LICENSE", + "README.md", + "dist/" + ], + "dependencies": { + "@lingui/cli": "4.8.0-next.1", + "@lingui/core": "4.8.0-next.1", + "@lingui/message-utils": "4.8.0-next.1", + "@vue/compiler-core": "^3.3.4", + "@vue/compiler-sfc": "^3.3.4", + "vue": "^3.3.4" + }, + "devDependencies": { + "@types/babel__core": "^7.20.5", + "unbuild": "2.0.0" + } +} diff --git a/packages/vue/src/common/Trans.test.ts b/packages/vue/src/common/Trans.test.ts new file mode 100644 index 000000000..f13617a40 --- /dev/null +++ b/packages/vue/src/common/Trans.test.ts @@ -0,0 +1,219 @@ +import { getContent, getContext, getId } from "./Trans" +import { run } from "../test/utils" +import { generateMessageId } from "@lingui/message-utils/generateMessageId" + +// + +describe("getContext", () => { + it("should get the context", () => { + run( + ` + right + `, + (node) => { + expect(getContext(node)).toEqual("direction") + } + ) + }) + + it("should return undefined when context is empty", () => { + run( + ` + right + `, + (node) => { + expect(getContext(node)).toEqual(undefined) + } + ) + }) + + it("should return undefined when context is a directive", () => { + run( + ` + right + `, + (node) => { + expect(getContext(node)).toEqual(undefined) + } + ) + }) + + it("should return undefined when no context", () => { + run( + ` + right + `, + (node) => { + expect(getContext(node)).toEqual(undefined) + } + ) + }) +}) + +describe("getId", () => { + it("should return the given id when set", () => { + run( + ` + right + `, + (node) => { + expect(getId(node, "right")).toEqual("direction.right") + } + ) + }) + + it("should return the generated id when not set", () => { + run( + ` + right + `, + (node) => { + expect(getId(node, "right")).toEqual(generateMessageId("right")) + } + ) + }) + + it("should return the generated id when not set but with context", () => { + run( + ` + right + `, + (node) => { + expect(getId(node, "right")).toEqual( + generateMessageId("right", "direction") + ) + } + ) + }) + + it("should return the generated id when id is empty but with context", () => { + run( + ` + right + `, + (node) => { + expect(getId(node, "right")).toEqual( + generateMessageId("right", "direction") + ) + } + ) + }) + + it("should return the generated id when id is empty and context empty", () => { + run( + ` + right + `, + (node) => { + expect(getId(node, "right")).toEqual(generateMessageId("right")) + } + ) + }) + + it("should return the generated id when id is a directive", () => { + run( + ` + right + `, + (node) => { + expect(getId(node, "right")).toEqual(generateMessageId("right")) + } + ) + }) +}) + +describe("getContent", () => { + it("should return the content of a Trans component", () => { + run( + ` + This is some random content + `, + (node) => { + expect(getContent(node).content).toEqual("This is some random content") + } + ) + }) + + it("should return the content without blank", () => { + run( + ` + This is some random content + `, + (node) => { + expect(getContent(node).content).toEqual("This is some random content") + } + ) + }) + + it("should return the content without line break", () => { + run( + ` + + This is some random content + + `, + (node) => { + expect(getContent(node).content).toEqual("This is some random content") + } + ) + }) + + it("should return the content with named placeholder when var", () => { + run( + ` + Hello {{ name }} + `, + (node) => { + expect(getContent(node).content).toEqual("Hello {name}") + } + ) + }) + + it("should return the content with all named placeholder when var", () => { + run( + ` + Hello {{ name }} welcome to {{ town }} you are now a {{ persona }}! + `, + (node) => { + expect(getContent(node).content).toEqual( + "Hello {name} welcome to {town} you are now a {persona}!" + ) + } + ) + }) + + it("should return the content with placeholder when inner tag", () => { + run( + ` + Hello {{ name }} welcome to {{ town }}
you are now a {{ persona }}!
+ `, + (node) => { + expect(getContent(node).content).toEqual( + "Hello <0>{name} welcome to {town} <1/> <2>you are now <3><4>a {persona}!" + ) + } + ) + }) + + it("should return the content with placeholder when contains complex interpolation", () => { + run( + ` + Hello {{ user.name }} + `, + (node) => { + expect(getContent(node).content).toEqual("Hello {0}") + } + ) + }) + + it("should return the content with placeholders sequentially when contains multiple complex interpolation", () => { + run( + ` + Hello {{ user ? user : "John" }} and {{ guest.name }} + `, + (node) => { + expect(getContent(node).content).toEqual("Hello {0} and {1}") + } + ) + }) +}) diff --git a/packages/vue/src/common/Trans.ts b/packages/vue/src/common/Trans.ts new file mode 100644 index 000000000..d398e2c73 --- /dev/null +++ b/packages/vue/src/common/Trans.ts @@ -0,0 +1,166 @@ +import { generateMessageId } from "@lingui/message-utils/generateMessageId" +import { + createSimpleExpression, + type ElementNode, + findProp, + type TemplateNode, +} from "@vue/compiler-core" + +import { + isAttributeNode, + isElementNode, + isInterpolationNode, + isSimpleExpressionNode, + isTextNode, +} from "./predicates" + +// + +function wrapInTemplateSlotNode( + index: number, + child: ElementNode +): TemplateNode { + const loc = child.loc + return { + type: /* NodeTypes.ELEMENT */ 1, + ns: 0, + tag: "template", + tagType: /* ElementTypes.TEMPLATE */ 3, + props: [ + { + type: /* NodeTypes.DIRECTIVE */ 7, + name: "slot", + exp: createSimpleExpression("{children}", false, loc, 0), + arg: createSimpleExpression(String(index), false, loc, 3), + modifiers: [], + loc, + }, + ], + isSelfClosing: false, + children: [child], + codegenNode: undefined, + loc, + } +} + +function createInnerSlotNode(sourceChild: ElementNode): ElementNode { + if (sourceChild.isSelfClosing) return sourceChild + const loc = sourceChild.loc + // no need for a deep copy + return { + ...sourceChild, + children: [ + { + type: /* NodeTypes.ELEMENT */ 1, + ns: 0, + tag: "component", + tagType: /* ElementTypes.COMPONENT */ 1, + props: [ + { + type: /* NodeTypes.DIRECTIVE */ 7, + name: "bind", + exp: createSimpleExpression("children", false, loc, 0), + arg: createSimpleExpression("is", true, loc, 3), + modifiers: [], + loc, + }, + ], + isSelfClosing: false, + children: [], + loc, + codegenNode: undefined, + }, + ], + } +} + +function getTemplateSlot(index: number, node: ElementNode) { + return wrapInTemplateSlotNode(index, createInnerSlotNode(node)) +} + +const valuesIndex = Symbol("index") + +export function getContent( + node: ElementNode, + { + onlyContent = false, + content = "", + templateSlots = [], + values = { + [valuesIndex]: 0, + }, + }: { + // optimize when some parts are not needed when only content matters + onlyContent?: boolean + content?: string + templateSlots?: ElementNode[] + values?: Record & { [valuesIndex]: number } + } = {} +) { + const newContent: string = node.children + .reduce((previousContent: string, child) => { + if (isTextNode(child)) { + return previousContent + child.content + } else if (isElementNode(child)) { + const index = templateSlots.length + if (onlyContent) { + templateSlots.length++ + } else { + templateSlots[index] = getTemplateSlot(index, child) + } + if (child.isSelfClosing) { + return `${previousContent}<${index}/>` + } else { + return `${previousContent}<${index}>${ + getContent(child, { onlyContent, templateSlots, values }).content + }` + } + } else if ( + isInterpolationNode(child) && + isSimpleExpressionNode(child.content) + ) { + let value: string | number = "" + + // simple interpolation without member expression + if (!child.content.ast) { + value = child.content.content + values[value] = value + } else { + value = values[valuesIndex] + values[value] = child.content.content + values[valuesIndex]++ + } + + return `${previousContent}{${value}}` + } else { + return previousContent + } + }, content) + .trim() + + return { + content: newContent, + templateSlots, + values, + } +} + +export function getContext(node: ElementNode): string | undefined { + const contextProp = findProp(node, "context", undefined, false) + // be strict about non empty string versus undefined + if (isAttributeNode(contextProp) && contextProp.value?.content) { + return contextProp.value.content + } + return undefined +} + +export function getId(node: ElementNode, content: string): string { + const idProp = findProp(node, "id", undefined, false) + // with findProp allowEmpty set to false this check is redondant + // but it is for typing + if (isAttributeNode(idProp) && idProp.value?.content) { + return idProp.value.content + } else { + return generateMessageId(content, getContext(node)) + } +} diff --git a/packages/vue/src/common/predicates.test.ts b/packages/vue/src/common/predicates.test.ts new file mode 100644 index 000000000..ec83d42ec --- /dev/null +++ b/packages/vue/src/common/predicates.test.ts @@ -0,0 +1,113 @@ +import { isTrans, isVtDirectiveNode } from "./predicates" +import { run } from "../test/utils" + +// + +describe("isTrans", () => { + it("should return true when valid with children", () => { + run( + ` + Hello world! + `, + (node) => { + expect(isTrans(node)).toEqual(true) + } + ) + }) + + it("should return false when with no children", () => { + run( + ` + + `, + (node) => { + expect(isTrans(node)).toEqual(false) + } + ) + }) + + it("should return false when not a ", () => { + run( + ` + + `, + (node) => { + expect(isTrans(node)).toEqual(false) + } + ) + }) +}) + +describe("isVtDirectiveNode", () => { + it("should return true when node is a prop directive node containing vt call", () => { + run( + ` + + `, + (node) => { + const prop = node.props[0] + expect(isVtDirectiveNode(prop)).toEqual(true) + } + ) + }) + + it("should return true when node is a prop directive node containing vt call with variable", () => { + run( + ` + + `, + (node) => { + const prop = node.props[0] + expect(isVtDirectiveNode(prop)).toEqual(true) + } + ) + }) + + it("should return false when node is a prop directive node containing a ternary expression with some valid vt call in it (for now)", () => { + run( + ` + + `, + (node) => { + const prop = node.props[0] + expect(isVtDirectiveNode(prop)).toEqual(false) + } + ) + }) + + it("should return false when node is a prop attribute (not a directive) node containing vt call", () => { + run( + ` + vt\`John's avatar\` + `, + (node) => { + const prop = node.props[0] + expect(isVtDirectiveNode(prop)).toEqual(false) + } + ) + }) + + it("should return false when node is a prop directive node containing a wrong vt call", () => { + run( + ` + + `, + (node) => { + const prop = node.props[0] + expect(isVtDirectiveNode(prop)).toEqual(false) + } + ) + }) + + it("should return false when node is a prop directive node containing somthing similar to vt call", () => { + run( + ` + + `, + (node) => { + const prop = node.props[0] + expect(isVtDirectiveNode(prop)).toEqual(false) + } + ) + }) +}) diff --git a/packages/vue/src/common/predicates.ts b/packages/vue/src/common/predicates.ts new file mode 100644 index 000000000..ac717f15d --- /dev/null +++ b/packages/vue/src/common/predicates.ts @@ -0,0 +1,76 @@ +import { + type AttributeNode, + type Node as BaseNode, + type CompoundExpressionNode, + type DirectiveNode, + type ElementNode, + type InterpolationNode, + type SimpleExpressionNode, + type TextNode, +} from "@vue/compiler-core" + +import { type Node, TAGS, type VtDirectiveNode } from "./types" + +// + +export function isBaseNode(node: unknown): node is BaseNode { + return Boolean(node && typeof node === "object" && "type" in node) +} + +export function isElementNode(node: unknown): node is ElementNode { + return isBaseNode(node) && node.type === /* NodeTypes.ELEMENT */ 1 +} + +export function isTextNode(node: unknown): node is TextNode { + return isBaseNode(node) && node.type === /* NodeTypes.TEXT */ 2 +} + +export function isSimpleExpressionNode( + node: unknown +): node is SimpleExpressionNode { + return isBaseNode(node) && node.type === /* NodeTypes.SIMPLE_EXPRESSION */ 4 +} + +export function isInterpolationNode(node: unknown): node is InterpolationNode { + return isBaseNode(node) && node.type === /* NodeTypes.INTERPOLATION */ 5 +} + +export function isAttributeNode(node: unknown): node is AttributeNode { + return isBaseNode(node) && node.type === /* NodeTypes.ATTRIBUTE */ 6 +} + +export function isDirectiveNode( + node: BaseNode | undefined +): node is DirectiveNode { + return node?.type === /* NodeTypes.DIRECTIVE */ 7 +} + +export function isCompoundExpressionNode( + node: BaseNode | undefined +): node is CompoundExpressionNode { + return node?.type === /* NodeTypes.COMPOUND_EXPRESSION */ 8 +} + +export function isVtDirectiveNode( + prop: BaseNode | undefined +): prop is VtDirectiveNode { + return Boolean( + isDirectiveNode(prop) && + prop.exp && + isCompoundExpressionNode(prop.exp) && + // is this redondant with ast tests? + isSimpleExpressionNode(prop.exp.children[0]) && + prop.exp.ast && + prop.exp.ast.type === "TaggedTemplateExpression" && + prop.exp.ast.tag.type === "Identifier" && + prop.exp.ast.tag.name === "_ctx.vt" + ) +} + +export function isTrans(node: Node): node is ElementNode { + return Boolean( + node.type === /* NodeTypes.ELEMENT */ 1 && + node.tag === TAGS.Trans && + node.children?.length + ) +} diff --git a/packages/vue/src/common/types.ts b/packages/vue/src/common/types.ts new file mode 100644 index 000000000..65c879a64 --- /dev/null +++ b/packages/vue/src/common/types.ts @@ -0,0 +1,16 @@ +import { + type CompoundExpressionNode, + type DirectiveNode, + type NodeTransform, +} from "@vue/compiler-core" + +// + +export type VtDirectiveNode = { + exp: CompoundExpressionNode +} & DirectiveNode + +export type Node = Parameters[0] +export const TAGS = { + Trans: "Trans", +} as const diff --git a/packages/vue/src/common/vt.test.ts b/packages/vue/src/common/vt.test.ts new file mode 100644 index 000000000..369ffcf88 --- /dev/null +++ b/packages/vue/src/common/vt.test.ts @@ -0,0 +1,96 @@ +import { run } from "../test/utils" +import { getContent, getVtDirectiveProps } from "./vt" + +// + +describe("getVtDirectiveProps", () => { + it("should get all props", () => { + run( + '', + (node) => { + expect(getVtDirectiveProps(node).length).toEqual(2) + } + ) + }) + + it("should get no props", () => { + run( + 'vt`some alt`', + (node) => { + expect(getVtDirectiveProps(node).length).toEqual(0) + } + ) + }) +}) + +describe("getContent", () => { + it("should return the content of a vt call", () => { + run('', (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual("some random title") + }) + }) + + it("should return the content without blank", () => { + run('', (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual("some random title") + }) + }) + + it("should return the content without blank", () => { + run( + ` + + `, + (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual("some random title") + } + ) + }) + + it("should return the content even with emojis", () => { + run('', (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual("some random ❤️ title 😁") + }) + }) + + it("should return the content with named placeholder when var", () => { + run('', (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual("Hello {0}") + }) + }) + + it("should return the content with all named placeholder when var", () => { + run( + '', + (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual( + "Hello {0} welcome to {1} you are now a {2}!" + ) + } + ) + }) + + it("should handle complex interpolation with placeholder", () => { + run( + '', + (node) => { + const prop = getVtDirectiveProps(node)[0] + if (!prop) throw new Error() + expect(getContent(prop)).toEqual("Hello {0} welcome to {1}") + } + ) + }) +}) diff --git a/packages/vue/src/common/vt.ts b/packages/vue/src/common/vt.ts new file mode 100644 index 000000000..8e2efab57 --- /dev/null +++ b/packages/vue/src/common/vt.ts @@ -0,0 +1,55 @@ +import { generateMessageId } from "@lingui/message-utils/generateMessageId" + +import { isElementNode, isVtDirectiveNode } from "./predicates" +import { type Node, type VtDirectiveNode } from "./types" + +// + +export function getVtDirectiveProps(node: Node): VtDirectiveNode[] { + return isElementNode(node) ? node.props.filter(isVtDirectiveNode) : [] +} + +export function getContent(prop: VtDirectiveNode): string { + const [, ...templateLiterals] = prop.exp.children + let index = 0 + let inInterpolation = false + // get raw as "`hello {0}, it is {1}!`" + // from "`hello ${", {type: 4, content: "_ctx.where"}, "} it is ${", {type: 4, content: "_ctx.when"}, "}!`" + const raw = templateLiterals.reduce((previous, current) => { + if (typeof current === "string") { + if (inInterpolation && current.includes("}")) { + inInterpolation = false + current = current.replace(/([\s\S]*?)\}/u, "}") + } + if (!inInterpolation) { + return previous + current.replace(/\$\{$/u, "{") + } + } + + // return index only once + // wait for a switch back before returning index again + if (!inInterpolation) { + inInterpolation = true + // values will be index based so we do not care about real name + return previous + index++ + } + + // use case: still in interpolation but index already + // returned previously + return previous + }, "") + + const matchTL = raw.match(/^`([\s\S.]*)`$/u) + // if vt`hello world!` + if (matchTL && matchTL[1]) { + // string without ` ` + return matchTL[1].trim() + } else { + throw new Error(`unexpected content: ${raw}`) + } +} + +// TODO: add context / extract it +export function getId(content: string): string { + return generateMessageId(content) +} diff --git a/packages/vue/src/compiler/babel-macros.ts b/packages/vue/src/compiler/babel-macros.ts new file mode 100644 index 000000000..66a3f0bef --- /dev/null +++ b/packages/vue/src/compiler/babel-macros.ts @@ -0,0 +1,31 @@ +// TODO: need to go full ESM to be able to use those types +// import { type Plugin, type TransformResult } from 'vite' +import * as babel from "@babel/core" + +const sourceRegex = /\.(:?[j|t]sx?|vue)$/u + +// make babel macros works in vite +export function babelMacros() { + return { + name: "vite-plugin-babel-macros", + async transform(source: string, filename: string) { + if (filename.includes("node_modules")) { + return undefined + } + + if (!sourceRegex.test(filename)) { + return undefined + } + + const result = await babel.transformAsync(source, { + filename, + plugins: ["macros"], + babelrc: false, + configFile: false, + sourceMaps: true, + }) + + return result + }, + } as const +} diff --git a/packages/vue/src/compiler/index.ts b/packages/vue/src/compiler/index.ts new file mode 100644 index 000000000..c68e1aa95 --- /dev/null +++ b/packages/vue/src/compiler/index.ts @@ -0,0 +1,2 @@ +export { babelMacros } from "./babel-macros" +export { transformer } from "./transformer" diff --git a/packages/vue/src/compiler/transformer.test.ts b/packages/vue/src/compiler/transformer.test.ts new file mode 100644 index 000000000..4f7b2bc0f --- /dev/null +++ b/packages/vue/src/compiler/transformer.test.ts @@ -0,0 +1,278 @@ +import { run } from "../test/utils" +import { transformTrans } from "./transformer" + +// + +describe("transformTrans", () => { + it("should transform a Trans component to runtime Trans call with id & message", () => { + const { code } = run( + ` + This is some random content + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "cr8mms", + message: "This is some random content" + }) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should transform a Trans component to runtime Trans call with id & message & context", () => { + const { code } = run( + ` + right + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "d1wX4r", + message: "right", + context: "direction" + }) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should transform with given id", () => { + const { code } = run( + ` + This is some random content + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "random.content", + message: "This is some random content" + }) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should transform with values when present", () => { + const { code } = run( + ` + Hello {{ name }} welcome to {{ town }} you are now a {{ persona }}! + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "cc6wEV", + message: "Hello {name} welcome to {town} you are now a {persona}!", + values: {name: (_ctx.name), town: (_ctx.town), persona: (_ctx.persona)} + }, null, 8 /* PROPS */, ["values"]) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should transform with placeholders when inner tags", () => { + const { code } = run( + ` + Hello {{ name }} welcome to {{ town }}
you are now a {{ persona }}!
+ `, + transformTrans + ) + + const result = `import { resolveDynamicComponent as _resolveDynamicComponent, openBlock as _openBlock, createBlock as _createBlock, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode, createElementBlock as _createElementBlock } from "vue" + +const _hoisted_1 = /*#__PURE__*/_createElementVNode("br", null, null, -1 /* HOISTED */) + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "r0tHqI", + message: "Hello <0>{name} welcome to {town} <1/> <2>you are now <3><4>a {persona}!", + values: {name: (_ctx.name), town: (_ctx.town), persona: (_ctx.persona)} + }, { + [0]: _withCtx(({children}) => [ + _createElementVNode("em", null, [ + (_openBlock(), _createBlock(_resolveDynamicComponent(children))) + ]) + ]), + [1]: _withCtx(({children}) => [ + _hoisted_1 + ]), + [2]: _withCtx(({children}) => [ + _createElementVNode("span", null, [ + (_openBlock(), _createBlock(_resolveDynamicComponent(children))) + ]) + ]), + [3]: _withCtx(({children}) => [ + _createElementVNode("em", null, [ + (_openBlock(), _createBlock(_resolveDynamicComponent(children))) + ]) + ]), + [4]: _withCtx(({children}) => [ + _createElementVNode("i", null, [ + (_openBlock(), _createBlock(_resolveDynamicComponent(children))) + ]) + ]), + _: 2 /* DYNAMIC */ + }, 1032 /* PROPS, DYNAMIC_SLOTS */, ["values"]) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should handle simple quotes", () => { + const { code } = run( + ` + John's car is red + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "H3I1xb", + message: "John's car is red" + }) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should handle double quotes", () => { + const { code } = run( + ` + This car is "red" + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "HP8WZU", + message: "This car is \\"red\\"" + }) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should handle mixed quotes", () => { + const { code } = run( + ` + John's car is "red" + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "dp/IGY", + message: "John's car is \\"red\\"" + }) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should handle complex interpolation", () => { + const { code } = run( + ` + Hello {{ user.name }} + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "Y7riaK", + message: "Hello {0}", + values: {0: (_ctx.user.name)} + }, null, 8 /* PROPS */, ["values"]) + ])) +}` + + expect(code).toEqual(result) + }) + + it("should handle multiple complex interpolation", () => { + const { code } = run( + ` + Hello {{ user.name }}! Do you love {{ isCatPerson ? "cat" : "dogs" }}? + `, + transformTrans + ) + + const result = `import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +export function render(_ctx, _cache) { + const _component_Trans = _resolveComponent("Trans") + + return (_openBlock(), _createElementBlock("template", null, [ + _createVNode(_component_Trans, { + id: "EtDOPn", + message: "Hello {0}! Do you love {1}?", + values: {0: (_ctx.user.name), 1: (_ctx.isCatPerson ? "cat" : "dogs")} + }, null, 8 /* PROPS */, ["values"]) + ])) +}` + + expect(code).toEqual(result) + }) +}) diff --git a/packages/vue/src/compiler/transformer.ts b/packages/vue/src/compiler/transformer.ts new file mode 100644 index 000000000..be6f72884 --- /dev/null +++ b/packages/vue/src/compiler/transformer.ts @@ -0,0 +1,92 @@ +import { + createSimpleExpression, + processExpression, + type DirectiveNode, + type ElementNode, + type NodeTransform, + type SourceLocation, + type TransformContext, +} from "@vue/compiler-core" + +import { isTrans } from "../common/predicates" +import { getContent, getContext, getId } from "../common/Trans" + +// + +// build the prop AST for :values="{foo: bar, ...}" passing to Trans components +// the contextual values used in the message +function buildValuesDirective( + values: Record, + loc: SourceLocation, + context: TransformContext +): DirectiveNode { + if (!Object.keys(values).length) throw new Error("values is empty") + // manually build :values source that would have been written + let source = "{" + source += Object.entries(values) + .map(([key, content]) => `${key}: (${content})`) + .join(", ") + source += "}" + // create & process the expression + const exp = processExpression( + createSimpleExpression(source, false, loc, 0), + context + ) + // return the :values directive + return { + type: /* DIRECTIVE */ 7, + name: "bind", + rawName: ":values", + exp, + arg: createSimpleExpression("values", true, loc, 3), + modifiers: [], + loc, + } +} + +export function transformTrans( + node: ElementNode, + transformContext: TransformContext +) { + const loc = node.loc + const { content, values, templateSlots } = getContent(node) + const props: ElementNode["props"] = [ + { + type: /* ATTRIBUTE */ 6, + name: "id", + value: { content: getId(node, content), type: 2, loc }, + nameLoc: loc, + loc: loc, + }, + { + type: /* ATTRIBUTE */ 6, + name: "message", + value: { content, type: 2, loc }, + nameLoc: loc, + loc, + }, + ] + if (Object.keys(values).length) { + props.push(buildValuesDirective(values, loc, transformContext)) + } + const context = getContext(node) + if (context) { + props.push({ + type: 6, + name: "context", + value: { content: context, type: 2, loc }, + nameLoc: loc, + loc, + }) + } + node.props = props + node.isSelfClosing = true + node.children = [...templateSlots] + node.tagType = 1 +} + +export const transformer: NodeTransform = (node, context) => { + if (isTrans(node)) { + transformTrans(node, context) + } +} diff --git a/packages/vue/src/components/Trans.ts b/packages/vue/src/components/Trans.ts new file mode 100644 index 000000000..c7b415942 --- /dev/null +++ b/packages/vue/src/components/Trans.ts @@ -0,0 +1,34 @@ +import { defineComponent, type MaybeRef, type PropType, unref } from "vue" + +import { useI18n } from "../plugins/lingui" +import { formatElements } from "./format" + +// + +type Values = Record + +// eslint-disable-next-line import/no-default-export +export default defineComponent({ + props: { + id: { type: String, required: false }, + values: { type: Object as PropType, required: false }, + message: { type: String, required: false }, + context: { type: String, required: false }, + }, + setup(props, ctx) { + return () => { + if (!props.id) return "" + const i18n = useI18n() + const unrefValues = Object.fromEntries( + Object.entries(props.values || {}).map(([key, value]) => [ + key, + unref(value), + ]) + ) + const translation = i18n.t(props.id, unrefValues, { + message: props.message || "fallback message", + }) + return formatElements(translation, { ...ctx.slots }) + } + }, +}) diff --git a/packages/vue/src/components/format.ts b/packages/vue/src/components/format.ts new file mode 100644 index 000000000..f44dfde66 --- /dev/null +++ b/packages/vue/src/components/format.ts @@ -0,0 +1,86 @@ +import { h, type Slot } from "vue" + +// + +// match paired and unpaired tags +const tagRe = /<([a-zA-Z0-9]+)>(.*?)<\/\1>|<([a-zA-Z0-9]+)\/>/u +const nlRe = /(?:\r\n|\r|\n)/gu + +type Element = ReturnType | ReturnType +type Node = Element | string | undefined + +/* + * `getElements` - return array of element indices and element children + * + * `parts` is array of [pairedIndex, children, unpairedIndex, textAfter, ...] + * where: + * - `pairedIndex` is index of paired element (undef for unpaired) + * - `children` are children of paired element (undef for unpaired) + * - `unpairedIndex` is index of unpaired element (undef for paired) + * - `textAfter` is string after all elements (empty string, if there's nothing) + * + * `parts` length is always a multiple of 4 + * + * Returns: Array<[elementIndex, children, after]> + */ +function getElements( + parts: string[] +): Array { + if (!parts.length) return [] + + const [paired, children, unpaired, after] = parts.slice(0, 4) + + const triple = [paired || unpaired, children || "", after] as const + return [triple].concat(getElements(parts.slice(4, parts.length))) +} + +/** + * `formatElements` - parse string and return tree of vue elements + * + * `value` is string to be formatted with Paired or (unpaired) + * placeholders. `elements` is a array of vue slots which indexes + * correspond to element indexes in formatted string + */ +export function formatElements( + value: string, + elements: { [key: string]: Slot | undefined } = {} +): Array { + const parts = value.replace(nlRe, "").split(tagRe) + + // no inline elements, return + if (parts.length === 1) return [value] + + const tree: Array = [] + + const before = parts.shift() + if (before) tree.push(before) + + for (const [index, children, after] of getElements(parts)) { + const slot = typeof index !== "undefined" ? elements[index] : undefined + let element: Element + + if (!slot) { + console.error( + `Can't use slot at index '${index}' as it is not declared in the original translation` + ) + // ignore problematic element but push its children and elements after it + element = h("span", children) + } else { + // slots display props with text interpolation + // only way to do recursive thing is to give a component that will render + // our subchildren / subslot + const childrenInComponent = { + setup: () => () => formatElements(children, elements), + } + element = slot({ + children: childrenInComponent, + }) + } + + tree.push(element) + + if (after) tree.push(after) + } + + return tree +} diff --git a/packages/vue/src/components/vt.ts b/packages/vue/src/components/vt.ts new file mode 100644 index 000000000..8103b4535 --- /dev/null +++ b/packages/vue/src/components/vt.ts @@ -0,0 +1,33 @@ +import { type MessageDescriptor } from "@lingui/core" +import { generateMessageId } from "@lingui/message-utils/generateMessageId" + +import { useI18n } from "../plugins/lingui" + +// + +function isTD(strings: unknown): strings is TemplateStringsArray { + return Array.isArray(strings) +} + +export function vt( + stringsOrMD: TemplateStringsArray | MessageDescriptor, + ...args: Array +) { + const i18n = useI18n() + if (isTD(stringsOrMD)) { + const message = stringsOrMD.reduce((msg, current, index) => { + if (index === 0) { + return current + } + return `${msg}{${index - 1}}${current}` + }, "") + const values: Record = {} + for (let index = 0; index < args.length; index++) { + values[index] = args[index] + } + return i18n.t(generateMessageId(message), values) + } + return i18n.t(stringsOrMD.id, stringsOrMD.values, { + message: stringsOrMD.message || "fallback message", + }) +} diff --git a/packages/vue/src/extractor/extractor.ts b/packages/vue/src/extractor/extractor.ts new file mode 100644 index 000000000..1ce179c0c --- /dev/null +++ b/packages/vue/src/extractor/extractor.ts @@ -0,0 +1,104 @@ +import { extractor as defaultExtractor } from "@lingui/cli/api" +import { + compileTemplate, + parse, + type SFCBlock, + type SFCTemplateCompileResults, +} from "@vue/compiler-sfc" + +import { createTransformer } from "./transformer" + +// + +type RawSourceMap = SFCTemplateCompileResults["map"] + +function isFirstIsString( + arr: [string | undefined, RawSourceMap | undefined, boolean] +): arr is [string, RawSourceMap | undefined, boolean] { + return typeof arr[0] === "string" +} + +// +// from official @lingui/vue-extractor +// + +type ExtractorType = typeof defaultExtractor + +export const vueExtractor: ExtractorType = { + match(filename) { + return filename.endsWith(".vue") + }, + async extract(filename, code, onMessageExtracted, ctx) { + const { descriptor } = parse(code, { + sourceMap: true, + filename, + ignoreEmpty: true, + }) + const isTsBlock = (block: SFCBlock | null | undefined) => + block?.lang === "ts" + const compiledTemplate = + descriptor.template && + compileTemplate({ + source: descriptor.template.content, + filename, + inMap: descriptor.template.map, + id: filename, + compilerOptions: { + isTS: + isTsBlock(descriptor.script) || isTsBlock(descriptor.scriptSetup), + // the magic starts here + nodeTransforms: [ + createTransformer((extrated) => + onMessageExtracted({ + ...extrated, + origin: [ + filename, + // node line starts from