From ae74a29e5aef1bb993ef4a1ace9541f9f20d8a09 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 17:01:20 -0700 Subject: [PATCH 01/28] feat(vue): add lightweight Vue runtime --- .changeset/calm-pandas-translate.md | 5 + .size-limit.cjs | 7 + README.md | 1 + packages/vue/README.md | 114 +++++ packages/vue/package.json | 64 +++ packages/vue/src/__tests__/runtime.test.ts | 541 +++++++++++++++++++++ packages/vue/src/components.ts | 234 +++++++++ packages/vue/src/index.ts | 13 + packages/vue/src/locale.ts | 10 + packages/vue/src/rich.ts | 503 +++++++++++++++++++ packages/vue/src/state.ts | 91 ++++ packages/vue/src/string.ts | 124 +++++ packages/vue/src/types.ts | 41 ++ packages/vue/tsconfig.json | 20 + packages/vue/tsdown.config.mts | 13 + packages/vue/vitest.config.ts | 11 + pnpm-lock.yaml | 226 +++++++-- 17 files changed, 1981 insertions(+), 37 deletions(-) create mode 100644 .changeset/calm-pandas-translate.md create mode 100644 packages/vue/README.md create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/__tests__/runtime.test.ts create mode 100644 packages/vue/src/components.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/src/locale.ts create mode 100644 packages/vue/src/rich.ts create mode 100644 packages/vue/src/state.ts create mode 100644 packages/vue/src/string.ts create mode 100644 packages/vue/src/types.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsdown.config.mts create mode 100644 packages/vue/vitest.config.ts diff --git a/.changeset/calm-pandas-translate.md b/.changeset/calm-pandas-translate.md new file mode 100644 index 0000000000..7aae92d3a1 --- /dev/null +++ b/.changeset/calm-pandas-translate.md @@ -0,0 +1,5 @@ +--- +'gt-vue': minor +--- + +Add a lightweight Vue 3 runtime with catalog-backed string and rich-content translation, reactive locale switching, and child-only formatting components. diff --git a/.size-limit.cjs b/.size-limit.cjs index 362e592a90..57da074eda 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -38,6 +38,11 @@ const react = (name, file, limit = '50 kB') => ignore: reactPeerIgnore, }); +const vue = (name, file, limit = '15 kB') => + entry(name, `packages/vue/dist/${file}.mjs`, limit, { + ignore: ['vue'], + }); + const reactNode = (name, file, limit = '50 kB') => nodeEntry(name, `packages/react/dist/${file}.mjs`, limit, { ignore: reactPeerIgnore, @@ -103,6 +108,8 @@ module.exports = [ reactNode('gt-react (server)', 'index.server', '55 kB'), react('gt-react/macros', 'macros'), + vue('gt-vue', 'index'), + next('gt-next (client)', 'index.client', '75 kB'), nextNode('gt-next (rsc)', 'index.rsc', '85 kB'), nextNode('gt-next (server)', 'index.server', '85 kB'), diff --git a/README.md b/README.md index 5884f88569..7fc68f9650 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Visit [https://generaltranslation.com/docs](https://generaltranslation.com/docs) | ------------------------------------------------------------------- | ------------------------------------------------- | | [gt-next](packages/next) | Automatic i18n for Next.js | | [gt-react](packages/react) | Automatic i18n for React | +| [gt-vue](packages/vue) | Lightweight i18n for Vue | | [gt-i18n](packages/i18n) | Pure JavaScript i18n library | | [gt](packages/cli) | CLI tool for continuous localization | | [gt-sanity](packages/sanity) | Plugin for Sanity Studio v3 | diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 0000000000..2ca42ff260 --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,114 @@ +

+ + + + General Translation + + +

+ +

+ Documentation · Report Bug +

+ +# gt-vue + +A lightweight General Translation runtime for Vue 3. + +## Installation + +```bash +npm install gt-vue +``` + +## Quick Start + +Register one plugin instance with your Vue app. Translation files are loaded +once per locale and cached for the lifetime of that instance. + +```ts +// main.ts +import { createApp } from 'vue'; +import { createGT } from 'gt-vue'; +import App from './App.vue'; + +const loadTranslations = async (locale: string) => { + try { + return (await import(`./_gt/${locale}.json`)).default; + } catch { + return {}; + } +}; + +createApp(App) + .use(createGT({ defaultLocale: 'en', loadTranslations })) + .mount('#app'); +``` + +Use `` for rich content. Runtime values are provided as slot children, not +through `name` or `value` props. + +```vue + + + +``` + +`useGT()` performs a synchronous catalog lookup. Its only option is +`$context`; braces are literal text and no ICU formatting or interpolation is +applied. + +## Registered Messages + +`msg()` marks a string at module scope and `useMessages()` resolves it inside +a component. + +```vue + + + +``` + +## Components + +- `` translates rich slot content. +- `` preserves a dynamic slot value inside ``. +- ``, ``, and `` format their slot values for the + active locale. +- `` selects named slots such as `#one` and `#other`. +- `` selects an arbitrary named slot. + +`setLocale()` loads a missing catalog, switches the reactive locale, and +rerenders consumers. Locale persistence and development hot reload are outside +this package; applications can persist their chosen locale separately. + +For SSR, call and await `plugin.loadTranslations(locale)` or +`plugin.setLocale(locale)` before rendering the app, and create a fresh +`createGT()` instance for each request so locale and catalog state stay +request-scoped. diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 0000000000..842358df21 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,64 @@ +{ + "name": "gt-vue", + "version": "0.0.0", + "description": "A lightweight Vue internationalization library for General Translation.", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.cts", + "files": [ + "dist", + "CHANGELOG.md", + "README.md" + ], + "sideEffects": false, + "peerDependencies": { + "vue": ">=3.3.0" + }, + "dependencies": { + "generaltranslation": "workspace:*" + }, + "scripts": { + "build": "tsdown", + "build:clean": "sh ../../scripts/clean.sh && pnpm run build", + "build:release": "pnpm run build:clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/generaltranslation/gt.git" + }, + "author": "General Translation, Inc.", + "license": "FSL-1.1-ALv2", + "bugs": { + "url": "https://github.com/generaltranslation/gt/issues" + }, + "homepage": "https://generaltranslation.com/", + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "vue": "^3.5.0" + }, + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + } + }, + "keywords": [ + "vue", + "translation", + "internationalization", + "localization", + "i18n" + ] +} diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts new file mode 100644 index 0000000000..833019d3a1 --- /dev/null +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -0,0 +1,541 @@ +import type { JsxChildren } from 'generaltranslation/types'; +import { hashSource } from 'generaltranslation/id'; +import { + createRenderer, + createSSRApp, + defineComponent, + h, + nextTick, + ref, + vShow, + withDirectives, +} from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { + Branch, + Currency, + DateTime, + Num, + Plural, + T, + Var, + createGT, + msg, + useGT, + useLocale, + useMessages, +} from '../index'; +import type { TranslationCatalog } from '../types'; + +describe('gt-vue runtime', () => { + it('loads and caches plain STRING translations, then rerenders on locale changes', async () => { + const source = 'Hello, {name}!'; + const encoded = msg('Navigation: home', { $context: 'navigation' }); + const loadTranslations = vi.fn(async (locale: string) => + locale === 'fr' + ? { + [stringHash(source, 'greeting')]: 'Bonjour, {name}!', + [stringHash('Navigation: home', 'navigation')]: + 'Navigation : accueil', + } + : {} + ); + const plugin = createGT({ loadTranslations }); + const Root = defineComponent({ + setup() { + const gt = useGT(); + const m = useMessages(); + const locale = useLocale(); + return () => + h( + 'p', + `${locale.value}|${gt(source, { $context: 'greeting' })}|${m(encoded)}` + ); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe( + 'en|Hello, {name}!|Navigation: home' + ); + + await Promise.all([ + plugin.loadTranslations('fr'), + plugin.loadTranslations('fr'), + ]); + await plugin.setLocale('fr'); + await nextTick(); + + expect(textContent(mounted.root)).toBe( + 'fr|Bonjour, {name}!|Navigation : accueil' + ); + expect(loadTranslations).toHaveBeenCalledTimes(1); + + await plugin.setLocale('en'); + await plugin.setLocale('fr'); + expect(loadTranslations).toHaveBeenCalledTimes(1); + mounted.app.unmount(); + }); + + it('keeps msg and useMessages context-only and never interpolates', async () => { + const contextual = msg('Literal {name}: 你好', { $context: 'example' }); + const messages = msg(['First', 'Second'], { $context: 'list' }); + const plugin = createGT({ + loadTranslations: async () => ({ + [stringHash('Literal {name}: 你好', 'example')]: + 'Littéral {name} : 你好', + [stringHash('First', 'list')]: 'Premier', + [stringHash('Second', 'list')]: 'Deuxième', + }), + }); + await plugin.setLocale('fr'); + + let nullResult: null | undefined | string; + let undefinedResult: null | undefined | string; + const Root = defineComponent({ + setup() { + const m = useMessages(); + nullResult = m(null); + undefinedResult = m(undefined); + return () => h('p', [m(contextual), '|', ...messages.map(m)]); + }, + }); + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('Littéral {name} : 你好|PremierDeuxième'); + expect(nullResult!).toBeNull(); + expect(undefinedResult!).toBeUndefined(); + }); + + it('renders translated rich children and preserves child-only variables', async () => { + const name = ref('Ada'); + const source: JsxChildren = { + t: 'p', + i: 1, + d: { ti: 'Greeting' }, + c: ['Hello, ', { i: 2, k: '_gt_value_2', v: 'v' }, '!'], + }; + const target: JsxChildren = { + t: 'p', + i: 1, + d: { ti: 'Salutation' }, + c: ['Bonjour, ', { i: 2, k: '_gt_value_2', v: 'v' }, ' !'], + }; + const plugin = createGT({ + loadTranslations: async () => ({ [jsxHash(source, 'hero')]: target }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { $context: 'hero' }, + { + default: () => + h('p', { title: 'Greeting' }, [ + 'Hello, ', + h(Var, null, { default: () => name.value }), + '!', + ]), + } + ); + }, + }); + const mounted = mount(Root, plugin); + await nextTick(); + + expect(textContent(mounted.root)).toBe('Bonjour, Ada !'); + expect(findElement(mounted.root, 'p')?.props.title).toBe('Salutation'); + + name.value = 'Grace'; + await nextTick(); + expect(textContent(mounted.root)).toBe('Bonjour, Grace !'); + mounted.app.unmount(); + }); + + it('numbers variables independently within every plural and branch slot', async () => { + const pluralSource: JsxChildren = { + t: 'Plural', + i: 1, + d: { + b: { + one: ['one ', { i: 2, k: '_gt_value_2', v: 'v' }], + other: ['other ', { i: 2, k: '_gt_value_2', v: 'v' }], + }, + t: 'p', + }, + c: ['fallback ', { i: 2, k: '_gt_value_2', v: 'v' }], + }; + const branchSource: JsxChildren = { + t: 'Branch', + i: 3, + d: { + b: { + formal: ['formal ', { i: 4, k: '_gt_value_4', v: 'v' }], + casual: ['casual ', { i: 4, k: '_gt_value_4', v: 'v' }], + }, + t: 'b', + }, + c: ['fallback ', { i: 4, k: '_gt_value_4', v: 'v' }], + }; + const source: JsxChildren = [pluralSource, ' / ', branchSource]; + const target: JsxChildren = [ + { + ...pluralSource, + d: { + b: { + one: ['un ', { i: 2, k: '_gt_value_2', v: 'v' }], + other: ['plusieurs ', { i: 2, k: '_gt_value_2', v: 'v' }], + }, + t: 'p', + }, + }, + ' / ', + { + ...branchSource, + d: { + b: { + formal: ['bonjour ', { i: 4, k: '_gt_value_4', v: 'v' }], + casual: ['salut ', { i: 4, k: '_gt_value_4', v: 'v' }], + }, + t: 'b', + }, + }, + ]; + const plugin = createGT({ + loadTranslations: async () => ({ [jsxHash(source)]: target }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => [ + h( + Plural, + { n: 1 }, + { + default: () => [ + 'fallback ', + h(Var, null, { default: () => 'article' }), + ], + one: () => [ + 'one ', + h(Var, null, { default: () => 'article' }), + ], + other: () => [ + 'other ', + h(Var, null, { default: () => 'articles' }), + ], + } + ), + ' / ', + h( + Branch, + { branch: 'formal' }, + { + default: () => [ + 'fallback ', + h(Var, null, { default: () => 'Ada' }), + ], + formal: () => [ + 'formal ', + h(Var, null, { default: () => 'Ada' }), + ], + casual: () => [ + 'casual ', + h(Var, null, { default: () => 'Ada' }), + ], + } + ), + ], + }); + }, + }); + + expect( + stripFragmentMarkers(await renderWithPlugin(Root, plugin)) + ).toContain('un article / bonjour Ada'); + }); + + it('formats slot children and renders standalone branch components', async () => { + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, { locales: ['en-US'] }, { default: () => '1234.5' }), + '|', + h( + Currency, + { currency: 'USD', locales: ['en-US'] }, + { default: () => '12' } + ), + '|', + h( + DateTime, + { + locales: ['en-US'], + options: { timeZone: 'UTC', year: 'numeric' }, + }, + { default: () => '2024-01-01T00:00:00.000Z' } + ), + '|', + h( + Plural, + { n: 2, other: 'items' }, + { one: () => 'item', default: () => 'fallback' } + ), + '|', + h( + Branch, + { branch: 'formal', formal: 'Welcome' }, + { default: () => 'Hi' } + ), + ]); + }, + }); + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('1,234.5|$12.00|2024|items|Welcome'); + }); + + it('preserves Vue directives while replacing rich children', async () => { + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => + withDirectives(h('p', null, 'Hidden'), [[vShow, false]]), + }); + }, + }); + + expect(await renderWithPlugin(Root, plugin)).toContain( + '

Hidden

' + ); + }); + + it('applies translated content props to leaf elements', async () => { + const plugin = createGT({ + loadTranslations: async () => ({ + image: { t: 'img', i: 1, d: { alt: 'Portrait traduit' } }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'image' }, + { + default: () => h('img', { alt: 'Source portrait' }), + } + ); + }, + }); + + const html = await renderWithPlugin(Root, plugin); + expect(html).toContain('alt="Portrait traduit"'); + expect(html).not.toContain('alt="Source portrait"'); + }); + + it('uses the default locale for untranslated rich source fallbacks', async () => { + const plugin = createGT({ + defaultLocale: 'en-US', + loadTranslations: async () => ({}), + }); + await plugin.setLocale('fr-FR'); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => [ + h( + Plural, + { n: 0 }, + { + one: () => 'one', + other: () => 'other', + } + ), + '|', + h(Num, null, { default: () => '1234.5' }), + ], + }); + }, + }); + + expect( + stripFragmentMarkers(await renderWithPlugin(Root, plugin)) + ).toContain('other|1,234.5'); + }); + + it('requires explicit preloading for an asynchronous SSR locale', async () => { + const source = 'Hello'; + let resolveCatalog!: (catalog: TranslationCatalog) => void; + const plugin = createGT({ + locale: 'fr', + loadTranslations: () => + new Promise((resolve) => { + resolveCatalog = resolve; + }), + }); + const Root = defineComponent({ + setup() { + const gt = useGT(); + return () => h('p', gt(source)); + }, + }); + + const initialRender = renderWithPlugin(Root, plugin); + expect(await initialRender).toContain('Hello'); + + const preload = plugin.loadTranslations('fr'); + resolveCatalog({ [stringHash(source)]: 'Bonjour' }); + await preload; + + expect(await renderWithPlugin(Root, plugin)).toContain('Bonjour'); + }); + + it('keeps app caches isolated during concurrent SSR', async () => { + const source = 'Hello'; + const french = createGT({ + loadTranslations: async () => ({ [stringHash(source)]: 'Bonjour' }), + }); + const chinese = createGT({ + loadTranslations: async () => ({ [stringHash(source)]: '你好' }), + }); + await Promise.all([french.setLocale('fr'), chinese.setLocale('zh')]); + const Root = defineComponent({ + setup() { + const gt = useGT(); + return () => h('p', gt(source)); + }, + }); + + const [fr, zh] = await Promise.all([ + renderWithPlugin(Root, french), + renderWithPlugin(Root, chinese), + ]); + expect(fr).toContain('Bonjour'); + expect(zh).toContain('你好'); + }); + + it('applies only the latest concurrent locale request', async () => { + const resolvers = new Map void>(); + const plugin = createGT({ + loadTranslations: (locale) => + new Promise((resolve) => resolvers.set(locale, resolve)), + }); + const Root = defineComponent({ + setup() { + const locale = useLocale(); + return () => h('p', locale.value); + }, + }); + const mounted = mount(Root, plugin); + const french = plugin.setLocale('fr'); + const chinese = plugin.setLocale('zh'); + + await vi.waitFor(() => expect(resolvers.size).toBe(2)); + resolvers.get('zh')?.({}); + await chinese; + resolvers.get('fr')?.({}); + await french; + await nextTick(); + + expect(textContent(mounted.root)).toBe('zh'); + mounted.app.unmount(); + }); +}); + +function stringHash(source: string, context?: string): string { + return hashSource({ source, context, dataFormat: 'STRING' }); +} + +function jsxHash(source: JsxChildren, context?: string): string { + return hashSource({ source, context, dataFormat: 'JSX' }); +} + +function stripFragmentMarkers(html: string): string { + return html.replaceAll('', '').replaceAll('', ''); +} + +async function renderWithPlugin( + root: ReturnType, + plugin: ReturnType +): Promise { + const app = createSSRApp(root); + app.use(plugin); + return renderToString(app); +} + +type HostNode = { + children: HostNode[]; + parent?: HostNode; + props: Record; + text?: string; + type: string; +}; + +const renderer = createRenderer({ + createComment: (text) => createHostNode('#comment', text), + createElement: (type) => createHostNode(type), + createText: (text) => createHostNode('#text', text), + insert(child, parent, anchor) { + child.parent = parent; + const index = anchor ? parent.children.indexOf(anchor) : -1; + if (index < 0) parent.children.push(child); + else parent.children.splice(index, 0, child); + }, + nextSibling(node) { + if (!node.parent) return null; + const index = node.parent.children.indexOf(node); + return node.parent.children[index + 1] ?? null; + }, + parentNode: (node) => node.parent ?? null, + patchProp(element, key, _previous, value) { + element.props[key] = value; + }, + remove(child) { + if (!child.parent) return; + const index = child.parent.children.indexOf(child); + if (index >= 0) child.parent.children.splice(index, 1); + }, + setElementText(element, text) { + const child = createHostNode('#text', text); + child.parent = element; + element.children = [child]; + }, + setText(node, text) { + node.text = text; + }, +}); + +function createHostNode(type: string, text?: string): HostNode { + return { children: [], props: {}, text, type }; +} + +function mount( + rootComponent: ReturnType, + plugin: ReturnType +) { + const root = createHostNode('root'); + const app = renderer.createApp(rootComponent); + app.use(plugin); + app.mount(root); + return { app, root }; +} + +function textContent(node: HostNode): string { + return node.text ?? node.children.map(textContent).join(''); +} + +function findElement(node: HostNode, type: string): HostNode | undefined { + if (node.type === type) return node; + return node.children.map((child) => findElement(child, type)).find(Boolean); +} diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts new file mode 100644 index 0000000000..a106d30115 --- /dev/null +++ b/packages/vue/src/components.ts @@ -0,0 +1,234 @@ +import { + getPluralForm, + isAcceptedPluralForm, +} from 'generaltranslation/internal'; +import { + defineComponent, + isVNode, + type Component, + type PropType, + type Slots, + type VNodeChild, +} from 'vue'; +import { translateVueChildren } from './rich'; +import { useGTState } from './state'; + +type GTComponent = T & { _gtt: string }; + +function withGTMetadata( + component: T, + metadata: string +): GTComponent { + return Object.assign(component, { _gtt: metadata }); +} + +export const T = withGTMetadata( + defineComponent({ + inheritAttrs: false, + name: 'T', + props: { + /** @internal Compile-time hash inserted by GT tooling. */ + _hash: String, + context: String, + }, + setup(props, { attrs, slots }) { + const state = useGTState(); + return () => + translateVueChildren(slots.default?.() ?? [], state, { + ...props, + ...(typeof attrs.$context === 'string' && { + $context: attrs.$context, + }), + }); + }, + }), + 'translate-client' +); + +export const Var = withGTMetadata( + defineComponent({ + name: 'Var', + setup(_props, { slots }) { + return () => slots.default?.() ?? null; + }, + }), + 'variable-variable' +); + +export const Num = withGTMetadata( + defineComponent({ + name: 'Num', + props: { + locales: Array as PropType, + options: Object as PropType, + }, + setup(props, { slots }) { + const state = useGTState(); + return () => { + const value = readSlotText(slots); + if (!value) return null; + const number = Number.parseFloat(value); + return Number.isNaN(number) + ? value + : new Intl.NumberFormat( + getFormatLocales(props.locales, state.locale.value), + props.options + ).format(number); + }; + }, + }), + 'variable-number' +); + +export const DateTime = withGTMetadata( + defineComponent({ + name: 'DateTime', + props: { + locales: Array as PropType, + options: Object as PropType, + }, + setup(props, { slots }) { + const state = useGTState(); + return () => { + const value = readSlotText(slots); + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat( + getFormatLocales(props.locales, state.locale.value), + props.options + ) + .format(date) + .replace(/[\u200F\u202B\u202E]/g, ''); + }; + }, + }), + 'variable-datetime' +); + +export const Currency = withGTMetadata( + defineComponent({ + name: 'Currency', + props: { + currency: { + default: 'USD', + type: String, + }, + locales: Array as PropType, + options: Object as PropType, + }, + setup(props, { slots }) { + const state = useGTState(); + return () => { + const value = readSlotText(slots); + if (!value) return null; + const number = Number.parseFloat(value); + return Number.isNaN(number) + ? value + : new Intl.NumberFormat( + getFormatLocales(props.locales, state.locale.value), + { + ...props.options, + currency: props.currency, + style: 'currency', + } + ).format(number); + }; + }, + }), + 'variable-currency' +); + +export const Plural = withGTMetadata( + defineComponent({ + inheritAttrs: false, + name: 'Plural', + props: { + locales: Array as PropType, + n: { + required: true, + type: Number, + }, + }, + setup(props, { attrs, slots }) { + const state = useGTState(); + return () => { + const branches = getBranchNames(attrs, slots).filter( + isAcceptedPluralForm + ); + const branch = getPluralForm( + props.n, + branches, + getFormatLocales(props.locales, state.locale.value) + ); + return getBranchContent(branch, attrs, slots); + }; + }, + }), + 'plural' +); + +export const Branch = withGTMetadata( + defineComponent({ + inheritAttrs: false, + name: 'Branch', + props: { + branch: [String, Number, Boolean] as PropType, + }, + setup(props, { attrs, slots }) { + return () => { + const branch = props.branch?.toString(); + return getBranchContent( + branch && !branch.startsWith('data-') ? branch : undefined, + attrs, + slots + ); + }; + }, + }), + 'branch' +); + +function getFormatLocales( + locales: string[] | undefined, + locale: string +): string[] { + return [...(locales ?? []), locale]; +} + +function readSlotText(slots: Slots): string { + return (slots.default?.() ?? []).map(readVNodeText).join(''); +} + +function readVNodeText(node: VNodeChild): string { + if (node == null || typeof node === 'boolean') return ''; + if (Array.isArray(node)) return node.map(readVNodeText).join(''); + if (!isVNode(node)) return String(node); + if (typeof node.children === 'string') return node.children; + if (Array.isArray(node.children)) { + return node.children.map(readVNodeText).join(''); + } + return ''; +} + +function getBranchNames( + attrs: Record, + slots: Slots +): string[] { + return [ + ...Object.keys(attrs).filter((key) => !key.startsWith('data-')), + ...Object.keys(slots).filter( + (key) => key !== 'default' && !key.startsWith('_') + ), + ]; +} + +function getBranchContent( + branch: string | undefined, + attrs: Record, + slots: Slots +) { + if (branch && slots[branch]) return slots[branch]?.(); + if (branch && attrs[branch] !== undefined) return String(attrs[branch]); + return slots.default?.() ?? null; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 0000000000..c480d8bb49 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,13 @@ +export { Branch, Currency, DateTime, Num, Plural, T, Var } from './components'; +export { useLocale, useSetLocale } from './locale'; +export { createGT } from './state'; +export { msg, useGT, useMessages } from './string'; +export type { + CreateGTOptions, + GTFunction, + GTPlugin, + GTStringOptions, + LoadTranslations, + MessagesFunction, + TranslationCatalog, +} from './types'; diff --git a/packages/vue/src/locale.ts b/packages/vue/src/locale.ts new file mode 100644 index 0000000000..aac7e5e5cf --- /dev/null +++ b/packages/vue/src/locale.ts @@ -0,0 +1,10 @@ +import { readonly, type DeepReadonly, type Ref } from 'vue'; +import { useGTState } from './state'; + +export function useLocale(): DeepReadonly> { + return readonly(useGTState().locale); +} + +export function useSetLocale(): (locale: string) => Promise { + return useGTState().setLocale; +} diff --git a/packages/vue/src/rich.ts b/packages/vue/src/rich.ts new file mode 100644 index 0000000000..097863b089 --- /dev/null +++ b/packages/vue/src/rich.ts @@ -0,0 +1,503 @@ +import { + HTML_CONTENT_PROPS, + type GTProp, + type JsxChild, + type JsxChildren, + type JsxElement, + type Variable, + type VariableType, +} from 'generaltranslation/types'; +import type { HtmlContentPropKeysRecord } from 'generaltranslation/types'; +import { + getPluralForm, + isAcceptedPluralForm, +} from 'generaltranslation/internal'; +import { hashSource } from 'generaltranslation/id'; +import { + Comment, + Fragment, + Text, + h, + isVNode, + type Component, + type Slots, + type VNode, + type VNodeChild, +} from 'vue'; +import type { GTState } from './types'; + +const variableTypes = { + currency: { name: 'cost', type: 'c' }, + datetime: { name: 'date', type: 'd' }, + number: { name: 'n', type: 'n' }, + variable: { name: 'value', type: 'v' }, +} as const satisfies Record; + +type Transformation = 'branch' | 'fragment' | 'plural' | 'variable' | undefined; + +type SourceElement = { + branches: Record; + children: SourceNode[]; + id: number; + transformation: Transformation; + variableName?: string; + variableType?: VariableType; + vnode: VNode; +}; + +type SourceNode = SourceElement | string; + +type ComponentWithGTMetadata = Component & { + _gtt?: string; + __name?: string; + name?: string; +}; + +type RichTranslationOptions = { + $context?: string; + _hash?: string; + context?: string; +}; + +export function translateVueChildren( + children: VNode[], + state: GTState, + options: RichTranslationOptions +): VNodeChild { + const source = createSourceNodes(children); + const serialized = serializeNodes(source); + const hash = + options._hash ?? + hashSource({ + context: options.$context ?? options.context, + dataFormat: 'JSX', + source: serialized, + }); + const target = state.getCatalog()[hash]; + if (target == null) { + return renderDefaultNodes(source, state, state.defaultLocale); + } + return renderNodes(source, target, state); +} + +function createSourceNodes(children: unknown): SourceNode[] { + const index = { value: 0 }; + return visitChildren(children, index); +} + +function visitChildren( + children: unknown, + index: { value: number } +): SourceNode[] { + if (Array.isArray(children)) { + return children.flatMap((child) => visitChildren(child, index)); + } + if (children == null || typeof children === 'boolean') return []; + if (!isVNode(children)) return [String(children)]; + if (children.type === Comment) return []; + if (children.type === Text) return [String(children.children ?? '')]; + if (children.type === Fragment) { + return visitChildren(children.children, index); + } + + index.value += 1; + const id = index.value; + const metadata = getGTMetadata(children); + const transformation = getTransformation(metadata); + const variable = + transformation === 'variable' ? getVariable(metadata, id) : undefined; + const source: SourceElement = { + branches: {}, + children: variable + ? [] + : visitChildren(getDefaultSlotChildren(children), index), + id, + transformation, + variableName: variable?.name, + variableType: variable?.type, + vnode: children, + }; + + if (transformation === 'branch' || transformation === 'plural') { + source.branches = getBranches(children, transformation, id); + } + return [source]; +} + +function getGTMetadata(vnode: VNode): string | undefined { + if (typeof vnode.type !== 'function' && typeof vnode.type !== 'object') { + return undefined; + } + return (vnode.type as ComponentWithGTMetadata)._gtt; +} + +function getTransformation(metadata?: string): Transformation { + const [type] = metadata?.split('-') ?? []; + if (type === 'translate') return 'fragment'; + if (type === 'branch' || type === 'plural' || type === 'variable') { + return type; + } + return undefined; +} + +function getVariable( + metadata: string | undefined, + id: number +): { name: string; type: VariableType } { + const variableType = metadata?.split('-')[1] ?? 'variable'; + const variable = + variableTypes[variableType as keyof typeof variableTypes] ?? + variableTypes.variable; + return { + name: `_gt_${variable.name}_${id}`, + type: variable.type, + }; +} + +function getDefaultSlotChildren(vnode: VNode): unknown { + if (isSlots(vnode.children)) return vnode.children.default?.(); + return vnode.children; +} + +function getBranches( + vnode: VNode, + transformation: 'branch' | 'plural', + branchElementId: number +): Record { + const inputs: Record = {}; + if (isSlots(vnode.children)) { + for (const [key, slot] of Object.entries(vnode.children)) { + if ( + key !== 'default' && + !key.startsWith('_') && + typeof slot === 'function' + ) { + inputs[key] = slot(); + } + } + } + for (const [key, value] of Object.entries(vnode.props ?? {})) { + if ( + key !== 'branch' && + key !== 'n' && + key !== 'locales' && + !key.startsWith('data-') && + !(key in inputs) + ) { + inputs[key] = value; + } + } + + return Object.fromEntries( + Object.entries(inputs) + .filter( + ([key]) => transformation === 'branch' || isAcceptedPluralForm(key) + ) + // Branches are mutually exclusive. Number each one independently from + // the parent so they share stable variable names and do not shift later + // siblings, matching the React renderer. + .map(([key, value]) => [ + key, + visitChildren(value, { value: branchElementId }), + ]) + ); +} + +function isSlots(children: unknown): children is Slots { + return !!children && typeof children === 'object' && !Array.isArray(children); +} + +function serializeNodes(nodes: SourceNode[]): JsxChildren { + const serialized = nodes.map(serializeNode); + return serialized.length === 1 ? serialized[0] : serialized; +} + +function serializeNode(node: SourceNode): JsxChild { + if (typeof node === 'string') return node; + if (node.transformation === 'variable') { + return { + i: node.id, + k: node.variableName ?? `_gt_value_${node.id}`, + v: node.variableType ?? 'v', + }; + } + + const data: GTProp = {}; + for (const [shortName, propName] of Object.entries(HTML_CONTENT_PROPS)) { + const value = node.vnode.props?.[propName]; + if (typeof value === 'string') { + data[shortName as keyof HtmlContentPropKeysRecord] = value; + } + } + if ( + (node.transformation === 'branch' || node.transformation === 'plural') && + Object.keys(node.branches).length + ) { + data.b = Object.fromEntries( + Object.entries(node.branches).map(([key, branch]) => [ + key, + serializeNodes(branch), + ]) + ); + data.t = node.transformation === 'plural' ? 'p' : 'b'; + } + + return { + t: getElementName(node.vnode), + i: node.id, + ...(Object.keys(data).length && { d: data }), + ...(node.children.length && { c: serializeNodes(node.children) }), + }; +} + +function getElementName(vnode: VNode): string { + if (typeof vnode.type === 'string') return vnode.type; + if (typeof vnode.type === 'function') return vnode.type.name || 'function'; + if (typeof vnode.type === 'object') { + const type = vnode.type as ComponentWithGTMetadata; + return type.name || type.__name || 'component'; + } + return 'component'; +} + +function renderNodes( + source: SourceNode[], + target: JsxChildren | undefined, + state: GTState +): VNodeChild { + if (target == null) { + // A partial translated tree falls back within the active locale. A wholly + // missing catalog entry is handled above using the source/default locale. + return renderDefaultNodes(source, state, state.locale.value); + } + if (typeof target === 'string') return target; + + const targets = Array.isArray(target) ? target : [target]; + const sourceElements = source.filter( + (node): node is SourceElement => typeof node !== 'string' + ); + const variables = new Map( + sourceElements + .filter((node) => node.transformation === 'variable') + .map((node) => [node.variableName, node]) + ); + const ordinary = sourceElements.filter( + (node) => node.transformation !== 'variable' + ); + + return targets.map((targetNode) => { + if (typeof targetNode === 'string') return targetNode; + if (isVariable(targetNode)) { + const variable = variables.get(targetNode.k); + return variable ? renderDefaultNode(variable, state) : null; + } + + const matchingIndex = ordinary.findIndex( + (sourceNode) => sourceNode.id === targetNode.i + ); + const sourceNode = + matchingIndex >= 0 + ? ordinary.splice(matchingIndex, 1)[0] + : ordinary.shift(); + return sourceNode ? renderElement(sourceNode, targetNode, state) : null; + }); +} + +function renderElement( + source: SourceElement, + target: JsxElement, + state: GTState +): VNodeChild { + if (source.transformation === 'branch') { + const branch = getBranchKey(source.vnode); + return renderNodes( + getSelectedSourceBranch(source, branch), + getSelectedTargetBranch(target, branch), + state + ); + } + if (source.transformation === 'plural') { + const n = source.vnode.props?.n; + if (typeof n !== 'number') return renderDefaultNode(source, state); + const sourceBranch = getPluralKey( + n, + Object.keys(source.branches), + source, + state + ); + const targetBranches = target.d?.b ?? {}; + const targetBranch = getPluralKey( + n, + Object.keys(targetBranches), + source, + state + ); + return renderNodes( + getSelectedSourceBranch(source, sourceBranch), + (targetBranch && targetBranches[targetBranch]) ?? target.c, + state + ); + } + if (source.transformation === 'fragment') { + return renderNodes(source.children, target.c, state); + } + const translatedProps = getTranslatedProps(target); + if (target.c == null) { + return Object.keys(translatedProps).length + ? cloneWithProps(source.vnode, translatedProps) + : renderDefaultNode(source, state); + } + + return cloneWithChildren( + source.vnode, + renderNodes(source.children, target.c, state), + translatedProps + ); +} + +function getBranchKey(source: VNode): string | undefined { + const branch = source.props?.branch; + if (branch == null) return undefined; + const key = String(branch); + return key && !key.startsWith('data-') ? key : undefined; +} + +function getPluralKey( + n: number, + branches: string[], + source: SourceElement, + state: GTState, + locale = state.locale.value +): string | undefined { + const forms = branches.filter(isAcceptedPluralForm); + if (!forms.length) return undefined; + const locales = Array.isArray(source.vnode.props?.locales) + ? source.vnode.props.locales.filter( + (locale): locale is string => typeof locale === 'string' + ) + : []; + return ( + getPluralForm(n, forms, [...locales, locale, state.defaultLocale]) || + undefined + ); +} + +function getSelectedSourceBranch( + source: SourceElement, + branch?: string +): SourceNode[] { + return branch && source.branches[branch] !== undefined + ? source.branches[branch] + : source.children; +} + +function getSelectedTargetBranch( + target: JsxElement, + branch?: string +): JsxChildren | undefined { + return branch && target.d?.b?.[branch] !== undefined + ? target.d.b[branch] + : target.c; +} + +function getTranslatedProps(target: JsxElement): Record { + const result: Record = {}; + for (const [shortName, propName] of Object.entries(HTML_CONTENT_PROPS)) { + const value = target.d?.[shortName as keyof HtmlContentPropKeysRecord]; + if (typeof value === 'string') result[propName] = value; + } + return result; +} + +function renderDefaultNodes( + nodes: SourceNode[], + state: GTState, + locale = state.defaultLocale +): VNodeChild[] { + return nodes.map((node) => renderDefaultNode(node, state, locale)); +} + +function renderDefaultNode( + node: SourceNode, + state: GTState, + locale?: string +): VNodeChild { + if (typeof node === 'string') return node; + if (node.transformation === 'variable') { + return locale && node.variableType !== 'v' + ? cloneWithProps(node.vnode, { + locales: [ + ...(Array.isArray(node.vnode.props?.locales) + ? node.vnode.props.locales + : []), + locale, + ], + }) + : node.vnode; + } + if (node.transformation === 'fragment') { + return renderDefaultNodes(node.children, state, locale); + } + if (node.transformation === 'branch') { + return renderDefaultNodes( + getSelectedSourceBranch(node, getBranchKey(node.vnode)), + state, + locale + ); + } + if (node.transformation === 'plural') { + const n = node.vnode.props?.n; + if (typeof n !== 'number') { + return renderDefaultNodes(node.children, state, locale); + } + const branch = getPluralKey( + n, + Object.keys(node.branches), + node, + state, + locale + ); + return renderDefaultNodes( + getSelectedSourceBranch(node, branch), + state, + locale + ); + } + if (!node.children.length) return node.vnode; + return cloneWithChildren( + node.vnode, + renderDefaultNodes(node.children, state, locale) + ); +} + +function cloneWithChildren( + vnode: VNode, + children: VNodeChild, + extraProps: Record = {} +): VNode { + // Passing the original VNode to h() uses Vue's clone path. Besides + // normalizing the replacement children, this preserves directives, + // transitions, scope IDs, refs, and app context from the source VNode. + // Vue supports this at runtime even though the public h() overloads only + // advertise element and component types. + const type = vnode as unknown as Component; + const props = Object.keys(extraProps).length ? extraProps : null; + if (typeof vnode.type === 'string') return h(type, props, [children]); + + const slots = isSlots(vnode.children) ? vnode.children : {}; + return h(type, props, { + ...slots, + default: () => children, + }); +} + +function cloneWithProps( + vnode: VNode, + extraProps: Record +): VNode { + return h(vnode as unknown as Component, extraProps); +} + +function isVariable(value: JsxElement | Variable): value is Variable { + return 'k' in value && typeof value.k === 'string'; +} diff --git a/packages/vue/src/state.ts b/packages/vue/src/state.ts new file mode 100644 index 0000000000..cc56083d22 --- /dev/null +++ b/packages/vue/src/state.ts @@ -0,0 +1,91 @@ +import { inject, ref, type InjectionKey } from 'vue'; +import { + createDiagnosticMessage, + libraryDefaultLocale, +} from 'generaltranslation/internal'; +import type { + CreateGTOptions, + GTPlugin, + GTState, + TranslationCatalog, +} from './types'; + +const gtContextKey: InjectionKey = Symbol('gt-vue'); + +export function createGT({ + defaultLocale = libraryDefaultLocale, + loadTranslations, + locale: initialLocale = defaultLocale, +}: CreateGTOptions = {}): GTPlugin { + const locale = ref(initialLocale); + const revision = ref(0); + const catalogs = new Map([[defaultLocale, {}]]); + const pending = new Map>(); + let localeRequest = 0; + + const load = async (targetLocale: string): Promise => { + const cached = catalogs.get(targetLocale); + if (cached) return cached; + + const currentPending = pending.get(targetLocale); + if (currentPending) return currentPending; + + const promise = Promise.resolve() + .then(() => loadTranslations?.(targetLocale) ?? {}) + .then((catalog) => { + catalogs.set(targetLocale, catalog); + revision.value += 1; + return catalog; + }) + .finally(() => pending.delete(targetLocale)); + + pending.set(targetLocale, promise); + return promise; + }; + + const setLocale = async (targetLocale: string): Promise => { + const request = ++localeRequest; + await load(targetLocale); + if (request === localeRequest) locale.value = targetLocale; + }; + + const state: GTState = { + defaultLocale, + getCatalog() { + // The Map intentionally stays non-reactive. This counter makes newly + // loaded catalogs invalidate any render that performed a lookup. + void revision.value; + return catalogs.get(locale.value) ?? {}; + }, + loadTranslations: load, + locale, + revision, + setLocale, + }; + + return { + getLocale: () => locale.value, + install(app) { + app.provide(gtContextKey, state); + // Client apps may mount immediately and render source content until the + // initial asynchronous catalog arrives. Its revision update rerenders. + void load(initialLocale).catch(() => undefined); + }, + loadTranslations: load, + setLocale, + }; +} + +export function useGTState(): GTState { + const state = inject(gtContextKey); + if (state) return state; + + throw new Error( + createDiagnosticMessage({ + source: 'gt-vue', + severity: 'Error', + whatHappened: 'The GT Vue plugin is not installed', + fix: 'Install the plugin with app.use(createGT(options))', + }) + ); +} diff --git a/packages/vue/src/string.ts b/packages/vue/src/string.ts new file mode 100644 index 0000000000..702803b0c4 --- /dev/null +++ b/packages/vue/src/string.ts @@ -0,0 +1,124 @@ +import { hashSource } from 'generaltranslation/id'; +import { decode, encode } from 'generaltranslation/internal'; +import { useGTState } from './state'; +import type { + GTFunction, + GTState, + GTStringOptions, + MessagesFunction, +} from './types'; + +type InternalStringOptions = GTStringOptions & { + /** @internal Compile-time hash inserted by GT tooling. */ + $_hash?: string; +}; + +function translateString( + state: GTState, + message: string, + options: InternalStringOptions = {} +): string { + const hash = + options.$_hash ?? + hashSource({ + context: options.$context, + dataFormat: 'STRING', + source: message, + }); + const translation = state.getCatalog()[hash]; + return typeof translation === 'string' ? translation : message; +} + +export function useGT(): GTFunction { + const state = useGTState(); + return (message, options = {}) => + translateString(state, message, options as InternalStringOptions); +} + +export function useMessages(): MessagesFunction { + const state = useGTState(); + return (( + message: T, + options: GTStringOptions = {} + ): T extends string ? string : T => { + if (message == null) return message as T extends string ? string : T; + + const decoded = decodeMessageOptions(message); + if ( + decoded && + typeof decoded.$_source === 'string' && + typeof decoded.$_hash === 'string' + ) { + return translateString(state, decoded.$_source, { + $context: decoded.$context, + $_hash: decoded.$_hash, + }) as T extends string ? string : T; + } + + return translateString(state, message, options) as T extends string + ? string + : T; + }) as MessagesFunction; +} + +export function msg(message: T): T; +export function msg( + message: T, + options?: GTStringOptions +): T; +export function msg( + message: string | string[], + options?: GTStringOptions +): string | string[] { + if (Array.isArray(message)) { + return message.map((entry) => msg(entry, options)); + } + if (!options) return message; + + const $_hash = hashSource({ + context: options.$context, + dataFormat: 'STRING', + source: message, + }); + const encoded = encode( + JSON.stringify({ + ...options, + $_hash, + $_source: message, + }) + ); + return `${message}:${encoded}`; +} + +function decodeMessageOptions(message: string): + | { + $context?: string; + $_hash: string; + $_source: string; + } + | undefined { + const separator = message.lastIndexOf(':'); + if (separator < 0) return undefined; + + try { + const options = JSON.parse(decode(message.slice(separator + 1))) as Record< + string, + unknown + >; + if ( + typeof options.$_hash === 'string' && + typeof options.$_source === 'string' + ) { + return { + ...(typeof options.$context === 'string' && { + $context: options.$context, + }), + $_hash: options.$_hash, + $_source: options.$_source, + }; + } + } catch { + // A normal string may contain a colon. It is not an encoded message. + } + return undefined; +} diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts new file mode 100644 index 0000000000..6aaa4219a1 --- /dev/null +++ b/packages/vue/src/types.ts @@ -0,0 +1,41 @@ +import type { JsxChildren } from 'generaltranslation/types'; +import type { App, Ref } from 'vue'; + +export type TranslationCatalog = Record; + +export type LoadTranslations = (locale: string) => Promise; + +/** Options supported by gt-vue's plain string translations. */ +export type GTStringOptions = { + $context?: string; +}; + +export type GTFunction = (message: string, options?: GTStringOptions) => string; + +export type MessagesFunction = ( + message: T, + options?: GTStringOptions +) => T extends string ? string : T; + +export type CreateGTOptions = { + defaultLocale?: string; + loadTranslations?: LoadTranslations; + locale?: string; +}; + +export type GTPlugin = { + getLocale(): string; + install(app: App): void; + loadTranslations(locale: string): Promise; + setLocale(locale: string): Promise; +}; + +/** Internal reactive state scoped to one installed plugin instance. */ +export type GTState = { + defaultLocale: string; + getCatalog(): TranslationCatalog; + loadTranslations(locale: string): Promise; + locale: Ref; + revision: Ref; + setLocale(locale: string): Promise; +}; diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 0000000000..98f6ecd295 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "Bundler", + "sourceMap": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts"], + "references": [ + { + "path": "../core" + } + ] +} diff --git a/packages/vue/tsdown.config.mts b/packages/vue/tsdown.config.mts new file mode 100644 index 0000000000..a9642044db --- /dev/null +++ b/packages/vue/tsdown.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; +import { createTsdownConfig } from '../../tsdown.preset.mts'; + +const deps = { + neverBundle: [ + /^vue$/, + /^vue\//, + /^generaltranslation$/, + /^generaltranslation\//, + ], +}; + +export default defineConfig(createTsdownConfig(['src/index.ts'], deps)); diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts new file mode 100644 index 0000000000..7f6fa40059 --- /dev/null +++ b/packages/vue/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + env: { + _GT_LOG_LEVEL: 'off', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84f4481779..2b1862c765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,7 +233,7 @@ importers: version: 1.2.3(@types/react-dom@19.1.9(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@vercel/analytics': specifier: ^1.3.1 - version: 1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vue@3.5.40(typescript@5.9.3)) '@vercel/blob': specifier: ^0.24.1 version: 0.24.1 @@ -1634,6 +1634,28 @@ importers: specifier: 'catalog:' version: 3.2.4(@edge-runtime/vm@4.0.4)(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.7.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) + packages/vue: + dependencies: + generaltranslation: + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.13.10 + tsdown: + specifier: 'catalog:' + version: 0.21.10(synckit@0.11.11)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 3.2.4(@edge-runtime/vm@4.0.4)(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.7.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) + vue: + specifier: ^3.5.0 + version: 3.5.40(typescript@5.9.3) + tests/apps/cli-test-app: dependencies: gt: @@ -2618,6 +2640,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.3': resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2630,6 +2656,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.3': resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2660,6 +2690,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0-rc.3': resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3304,6 +3339,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.3': resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9769,6 +9808,33 @@ packages: '@vscode/sudo-prompt@9.3.2': resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} + + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} + + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + + '@vue/reactivity@3.5.40': + resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} + + '@vue/runtime-core@3.5.40': + resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==} + + '@vue/runtime-dom@3.5.40': + resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==} + + '@vue/server-renderer@3.5.40': + resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==} + + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -19219,6 +19285,14 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vue@3.5.40: + resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-hr-time@1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} deprecated: Use your platform's native performance.now() and performance.timeOrigin. @@ -20213,12 +20287,16 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0-rc.3': {} '@babel/helper-validator-identifier@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.0-rc.3': {} '@babel/helper-validator-option@7.27.1': {} @@ -20251,6 +20329,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/parser@8.0.0-rc.3': dependencies: '@babel/types': 8.0.0-rc.3 @@ -21040,6 +21122,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0-rc.3': dependencies: '@babel/helper-string-parser': 8.0.0-rc.3 @@ -21067,7 +21154,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.5 '@changesets/assemble-release-plan@6.0.9': dependencies: @@ -21076,7 +21163,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.7.4 + semver: 7.8.5 '@changesets/changelog-git@0.2.1': dependencies: @@ -21142,7 +21229,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.7.4 + semver: 7.8.5 '@changesets/get-github-info@0.6.0': dependencies: @@ -22598,7 +22685,7 @@ snapshots: dependencies: '@floating-ui/dom': 1.7.4 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -23748,7 +23835,7 @@ snapshots: pkce-challenge: 4.1.0 prismjs: 1.30.0 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) react-simple-code-editor: 0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) serve-handler: 6.1.6 tailwind-merge: 2.6.0 @@ -24603,7 +24690,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24619,7 +24706,7 @@ snapshots: '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24643,7 +24730,7 @@ snapshots: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24710,7 +24797,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) react-remove-scroll: 2.7.1(@types/react@19.2.17)(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 @@ -24749,7 +24836,7 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24798,7 +24885,7 @@ snapshots: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24838,7 +24925,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24886,7 +24973,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) react-remove-scroll: 2.7.1(@types/react@19.2.17)(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 @@ -24923,7 +25010,7 @@ snapshots: '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/rect': 1.1.1 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24943,7 +25030,7 @@ snapshots: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24963,7 +25050,7 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24981,7 +25068,7 @@ snapshots: dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25024,7 +25111,7 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25081,7 +25168,7 @@ snapshots: '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) react-remove-scroll: 2.7.1(@types/react@19.2.17)(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 @@ -25128,7 +25215,7 @@ snapshots: '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25148,7 +25235,7 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25188,7 +25275,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25314,7 +25401,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -28056,7 +28143,7 @@ snapshots: '@types/papaparse@5.5.2': dependencies: - '@types/node': 22.13.10 + '@types/node': 24.13.3 '@types/parse-json@4.0.2': {} @@ -28066,7 +28153,7 @@ snapshots: '@types/pdf-parse@1.1.5': dependencies: - '@types/node': 22.13.10 + '@types/node': 24.13.3 '@types/pg@8.11.6': dependencies: @@ -28556,10 +28643,11 @@ snapshots: - babel-plugin-macros - supports-color - '@vercel/analytics@1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vue@3.5.40(typescript@5.9.3))': optionalDependencies: next: 15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + vue: 3.5.40(typescript@5.9.3) '@vercel/blob@0.24.1': dependencies: @@ -28726,6 +28814,60 @@ snapshots: '@vscode/sudo-prompt@9.3.2': optional: true + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-sfc@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.19 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.40': + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/reactivity@3.5.40': + dependencies: + '@vue/shared': 3.5.40 + + '@vue/runtime-core@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/runtime-dom@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/runtime-core': 3.5.40 + '@vue/shared': 3.5.40 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.40': + dependencies: + '@vue/compiler-ssr': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/shared@3.5.40': {} + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -30043,7 +30185,7 @@ snapshots: '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -30333,7 +30475,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.19) postcss-modules-values: 4.0.0(postcss@8.5.19) postcss-value-parser: 4.2.0 - semver: 7.7.4 + semver: 7.8.5 optionalDependencies: webpack: 5.106.2 @@ -36738,7 +36880,7 @@ snapshots: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.5.19 - semver: 7.7.4 + semver: 7.8.5 webpack: 5.106.2 postcss-logical@5.0.4(postcss@8.5.19): @@ -37443,10 +37585,10 @@ snapshots: - bufferutil - utf-8-validate - react-dom@18.3.1(react@18.3.1): + react-dom@18.3.1(react@19.2.7): dependencies: loose-envify: 1.4.0 - react: 18.3.1 + react: 19.2.7 scheduler: 0.23.2 react-dom@19.1.1(react@19.1.1): @@ -37853,7 +37995,7 @@ snapshots: react-simple-code-editor@0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 18.3.1(react@19.2.7) react-style-singleton@2.2.3(@types/react@19.2.17)(react@18.3.1): dependencies: @@ -39768,13 +39910,13 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyglobby@0.2.17: dependencies: @@ -40915,6 +41057,16 @@ snapshots: void-elements@3.1.0: {} + vue@3.5.40(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-sfc': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/server-renderer': 3.5.40 + '@vue/shared': 3.5.40 + optionalDependencies: + typescript: 5.9.3 + w3c-hr-time@1.0.2: dependencies: browser-process-hrtime: 1.0.0 From f9eaecb878116d38beecb6c310054b7c25451772 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 17:32:03 -0700 Subject: [PATCH 02/28] fix(vue): preserve runtime extraction parity --- packages/vue/LICENSE.md | 105 +++++++++++++++++++ packages/vue/src/__tests__/runtime.test.ts | 112 ++++++++++++++++++++- packages/vue/src/components.ts | 65 +++++++++--- packages/vue/src/rich.ts | 32 +++++- packages/vue/src/string.ts | 12 +-- 5 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 packages/vue/LICENSE.md diff --git a/packages/vue/LICENSE.md b/packages/vue/LICENSE.md new file mode 100644 index 0000000000..28fe750986 --- /dev/null +++ b/packages/vue/LICENSE.md @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, ALv2 Future License + +## Abbreviation + +FSL-1.1-ALv2 + +## Notice + +Copyright 2025 General Translation, Inc. + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 833019d3a1..37e5f1287d 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -1,6 +1,8 @@ import type { JsxChildren } from 'generaltranslation/types'; import { hashSource } from 'generaltranslation/id'; import { + Fragment, + createCommentVNode, createRenderer, createSSRApp, defineComponent, @@ -79,7 +81,9 @@ describe('gt-vue runtime', () => { it('keeps msg and useMessages context-only and never interpolates', async () => { const contextual = msg('Literal {name}: 你好', { $context: 'example' }); - const messages = msg(['First', 'Second'], { $context: 'list' }); + const messages: string[] = msg(['First', 'Second'] as const, { + $context: 'list', + }); const plugin = createGT({ loadTranslations: async () => ({ [stringHash('Literal {name}: 你好', 'example')]: @@ -154,6 +158,33 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('coalesces text around comments and fragments for stable rich hashes', async () => { + const source = 'Hello world'; + const plugin = createGT({ + loadTranslations: async () => ({ + [jsxHash(source)]: 'Bonjour le monde', + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => + h(Fragment, null, [ + 'Hello', + createCommentVNode('translator note'), + ' world', + ]), + }); + }, + }); + + expect( + stripFragmentMarkers(await renderWithPlugin(Root, plugin)) + ).toContain('Bonjour le monde'); + }); + it('numbers variables independently within every plural and branch slot', async () => { const pluralSource: JsxChildren = { t: 'Plural', @@ -259,6 +290,50 @@ describe('gt-vue runtime', () => { ).toContain('un article / bonjour Ada'); }); + it('ignores Vue-reserved VNode props when hashing rich branches', async () => { + const source: JsxChildren = { + t: 'Branch', + i: 1, + d: { b: { formal: 'Hello' }, t: 'b' }, + c: 'Fallback', + }; + const target: JsxChildren = { + t: 'Branch', + i: 1, + d: { b: { formal: 'Bonjour' }, t: 'b' }, + c: 'Repli', + }; + const plugin = createGT({ + loadTranslations: async () => ({ [jsxHash(source)]: target }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => + h( + Branch, + { + branch: 'formal', + key: 'stable', + onVnodeMounted: () => undefined, + ref: () => undefined, + ref_for: true, + ref_key: 'greeting', + }, + { + default: () => 'Fallback', + formal: () => 'Hello', + } + ), + }); + }, + }); + + expect(await renderWithPlugin(Root, plugin)).toContain('Bonjour'); + }); + it('formats slot children and renders standalone branch components', async () => { const plugin = createGT(); const Root = defineComponent({ @@ -301,6 +376,41 @@ describe('gt-vue runtime', () => { expect(html).toContain('1,234.5|$12.00|2024|items|Welcome'); }); + it('falls back safely for inherited object-property branch names', async () => { + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h( + Branch, + { branch: 'toString' }, + { default: () => 'Standalone fallback' } + ), + '|', + h( + Branch, + { branch: 'missing', missing: undefined }, + { default: () => 'Undefined fallback' } + ), + '|', + h(T, null, { + default: () => + h( + Branch, + { branch: 'constructor' }, + { default: () => 'Rich fallback' } + ), + }), + ]); + }, + }); + + expect( + stripFragmentMarkers(await renderWithPlugin(Root, plugin)) + ).toContain('Standalone fallback|Undefined fallback|Rich fallback'); + }); + it('preserves Vue directives while replacing rich children', async () => { const plugin = createGT(); const Root = defineComponent({ diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts index a106d30115..c9c4a4a2d9 100644 --- a/packages/vue/src/components.ts +++ b/packages/vue/src/components.ts @@ -6,6 +6,7 @@ import { defineComponent, isVNode, type Component, + type DefineComponent, type PropType, type Slots, type VNodeChild, @@ -13,16 +14,46 @@ import { import { translateVueChildren } from './rich'; import { useGTState } from './state'; -type GTComponent = T & { _gtt: string }; +type GTComponent = DefineComponent & { _gtt: string }; -function withGTMetadata( - component: T, +type TProps = { + /** @internal Compile-time hash inserted by GT tooling. */ + _hash?: string; + $context?: string; + context?: string; +}; + +type NumberFormatProps = { + locales?: string[]; + options?: Intl.NumberFormatOptions; +}; + +type DateTimeProps = { + locales?: string[]; + options?: Intl.DateTimeFormatOptions; +}; + +type CurrencyProps = NumberFormatProps & { + currency?: string; +}; + +type PluralProps = { + locales?: string[]; + n: number; +}; + +type BranchProps = { + branch?: string | number | boolean; +}; + +function withGTMetadata( + component: Component, metadata: string -): GTComponent { - return Object.assign(component, { _gtt: metadata }); +): GTComponent { + return Object.assign(component, { _gtt: metadata }) as GTComponent; } -export const T = withGTMetadata( +export const T = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'T', @@ -55,7 +86,7 @@ export const Var = withGTMetadata( 'variable-variable' ); -export const Num = withGTMetadata( +export const Num = withGTMetadata( defineComponent({ name: 'Num', props: { @@ -80,7 +111,7 @@ export const Num = withGTMetadata( 'variable-number' ); -export const DateTime = withGTMetadata( +export const DateTime = withGTMetadata( defineComponent({ name: 'DateTime', props: { @@ -106,7 +137,7 @@ export const DateTime = withGTMetadata( 'variable-datetime' ); -export const Currency = withGTMetadata( +export const Currency = withGTMetadata( defineComponent({ name: 'Currency', props: { @@ -139,7 +170,7 @@ export const Currency = withGTMetadata( 'variable-currency' ); -export const Plural = withGTMetadata( +export const Plural = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'Plural', @@ -168,7 +199,7 @@ export const Plural = withGTMetadata( 'plural' ); -export const Branch = withGTMetadata( +export const Branch = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'Branch', @@ -228,7 +259,15 @@ function getBranchContent( attrs: Record, slots: Slots ) { - if (branch && slots[branch]) return slots[branch]?.(); - if (branch && attrs[branch] !== undefined) return String(attrs[branch]); + if ( + branch && + Object.hasOwn(slots, branch) && + typeof slots[branch] === 'function' + ) { + return slots[branch](); + } + if (branch && Object.hasOwn(attrs, branch) && attrs[branch] !== undefined) { + return String(attrs[branch]); + } return slots.default?.() ?? null; } diff --git a/packages/vue/src/rich.ts b/packages/vue/src/rich.ts index 097863b089..722c331b3b 100644 --- a/packages/vue/src/rich.ts +++ b/packages/vue/src/rich.ts @@ -90,7 +90,9 @@ function visitChildren( index: { value: number } ): SourceNode[] { if (Array.isArray(children)) { - return children.flatMap((child) => visitChildren(child, index)); + return mergeAdjacentStrings( + children.flatMap((child) => visitChildren(child, index)) + ); } if (children == null || typeof children === 'boolean') return []; if (!isVNode(children)) return [String(children)]; @@ -164,7 +166,7 @@ function getBranches( transformation: 'branch' | 'plural', branchElementId: number ): Record { - const inputs: Record = {}; + const inputs = Object.create(null) as Record; if (isSlots(vnode.children)) { for (const [key, slot] of Object.entries(vnode.children)) { if ( @@ -181,8 +183,15 @@ function getBranches( key !== 'branch' && key !== 'n' && key !== 'locales' && + key !== 'key' && + key !== 'ref' && + key !== 'ref_for' && + key !== 'ref_key' && + key !== 'ref-for' && + key !== 'ref-key' && + !key.startsWith('onVnode') && !key.startsWith('data-') && - !(key in inputs) + !Object.hasOwn(inputs, key) ) { inputs[key] = value; } @@ -386,7 +395,7 @@ function getSelectedSourceBranch( source: SourceElement, branch?: string ): SourceNode[] { - return branch && source.branches[branch] !== undefined + return branch && Object.hasOwn(source.branches, branch) ? source.branches[branch] : source.children; } @@ -395,11 +404,24 @@ function getSelectedTargetBranch( target: JsxElement, branch?: string ): JsxChildren | undefined { - return branch && target.d?.b?.[branch] !== undefined + return branch && target.d?.b && Object.hasOwn(target.d.b, branch) ? target.d.b[branch] : target.c; } +function mergeAdjacentStrings(nodes: SourceNode[]): SourceNode[] { + const result: SourceNode[] = []; + for (const node of nodes) { + const previous = result.at(-1); + if (typeof previous === 'string' && typeof node === 'string') { + result[result.length - 1] = previous + node; + } else { + result.push(node); + } + } + return result; +} + function getTranslatedProps(target: JsxElement): Record { const result: Record = {}; for (const [shortName, propName] of Object.entries(HTML_CONTENT_PROPS)) { diff --git a/packages/vue/src/string.ts b/packages/vue/src/string.ts index 702803b0c4..fcf519b9e0 100644 --- a/packages/vue/src/string.ts +++ b/packages/vue/src/string.ts @@ -61,16 +61,16 @@ export function useMessages(): MessagesFunction { }) as MessagesFunction; } -export function msg(message: T): T; -export function msg( +export function msg(message: T): T; +export function msg( message: T, options?: GTStringOptions -): T; +): T extends string ? string : string[]; export function msg( - message: string | string[], + message: string | readonly string[], options?: GTStringOptions -): string | string[] { - if (Array.isArray(message)) { +): string | readonly string[] { + if (typeof message !== 'string') { return message.map((entry) => msg(entry, options)); } if (!options) return message; From fbb516bcf90d5680af1db04b9f67d5e1e6fe7da7 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 17:42:18 -0700 Subject: [PATCH 03/28] fix(vue): normalize translated element children --- packages/vue/src/__tests__/runtime.test.ts | 47 ++++++++++++++++++++++ packages/vue/src/rich.ts | 40 ++++++++++++++---- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 37e5f1287d..a4ffcf8255 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -158,6 +158,53 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('updates multi-root rich children from arrays to scalar text on the client', async () => { + const source: JsxChildren = [ + { + t: 'h1', + i: 1, + c: ['Hello, ', { i: 2, k: '_gt_value_2', v: 'v' }, '!'], + }, + { t: 'p', i: 3, c: 'Source paragraph.' }, + ]; + const target: JsxChildren = [ + { + t: 'h1', + i: 1, + c: ['Bonjour, ', { i: 2, k: '_gt_value_2', v: 'v' }, ' !'], + }, + { t: 'p', i: 3, c: 'Paragraphe traduit.' }, + ]; + const plugin = createGT({ + loadTranslations: async (locale) => + locale === 'fr' ? { [jsxHash(source)]: target } : {}, + }); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => [ + h('h1', null, [ + 'Hello, ', + h(Var, null, { default: () => 'Ada' }), + '!', + ]), + h('p', null, 'Source paragraph.'), + ], + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('Hello, Ada!Source paragraph.'); + + await plugin.setLocale('fr'); + await nextTick(); + + expect(textContent(mounted.root)).toBe('Bonjour, Ada !Paragraphe traduit.'); + mounted.app.unmount(); + }); + it('coalesces text around comments and fragments for stable rich hashes', async () => { const source = 'Hello world'; const plugin = createGT({ diff --git a/packages/vue/src/rich.ts b/packages/vue/src/rich.ts index 722c331b3b..df8a595407 100644 --- a/packages/vue/src/rich.ts +++ b/packages/vue/src/rich.ts @@ -19,6 +19,7 @@ import { Text, h, isVNode, + mergeProps, type Component, type Slots, type VNode, @@ -59,6 +60,11 @@ type RichTranslationOptions = { context?: string; }; +type VNodeWithRenderMetadata = VNode & { + ctx?: unknown; + slotScopeIds?: string[] | null; +}; + export function translateVueChildren( children: VNode[], state: GTState, @@ -497,15 +503,35 @@ function cloneWithChildren( children: VNodeChild, extraProps: Record = {} ): VNode { - // Passing the original VNode to h() uses Vue's clone path. Besides - // normalizing the replacement children, this preserves directives, - // transitions, scope IDs, refs, and app context from the source VNode. - // Vue supports this at runtime even though the public h() overloads only - // advertise element and component types. + if (typeof vnode.type === 'string') { + const props = Object.keys(extraProps).length + ? mergeProps(vnode.props ?? {}, extraProps) + : vnode.props; + const cloned = h(vnode.type, props, children ?? undefined); + + // A Vue clone retains the source VNode's child shape flags. Rich + // translations can change an element from array children to scalar text, + // so create a fresh element with normalized children and copy only the + // render metadata that must survive reconstruction. + cloned.appContext = vnode.appContext; + (cloned as VNodeWithRenderMetadata).ctx = ( + vnode as VNodeWithRenderMetadata + ).ctx; + cloned.dirs = vnode.dirs; + cloned.ref = vnode.ref; + cloned.scopeId = vnode.scopeId; + (cloned as VNodeWithRenderMetadata).slotScopeIds = ( + vnode as VNodeWithRenderMetadata + ).slotScopeIds; + cloned.transition = vnode.transition; + return cloned; + } + + // Components need their original slot set and identity. Passing a VNode to + // h() uses Vue's clone path even though its public overloads do not expose + // that runtime-supported form. const type = vnode as unknown as Component; const props = Object.keys(extraProps).length ? extraProps : null; - if (typeof vnode.type === 'string') return h(type, props, [children]); - const slots = isSlots(vnode.children) ? vnode.children : {}; return h(type, props, { ...slots, From 334de4a002ab1f38a7836a8ea16dad2d94c0d77c Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 17:49:07 -0700 Subject: [PATCH 04/28] chore(vue): normalize lockfile entries --- pnpm-lock.yaml | 75 +++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1862c765..aa0a0c71e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,7 +233,7 @@ importers: version: 1.2.3(@types/react-dom@19.1.9(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@vercel/analytics': specifier: ^1.3.1 - version: 1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vue@3.5.40(typescript@5.9.3)) + version: 1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@vercel/blob': specifier: ^0.24.1 version: 0.24.1 @@ -21154,7 +21154,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.8.5 + semver: 7.7.4 '@changesets/assemble-release-plan@6.0.9': dependencies: @@ -21163,7 +21163,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.8.5 + semver: 7.7.4 '@changesets/changelog-git@0.2.1': dependencies: @@ -21229,7 +21229,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.8.5 + semver: 7.7.4 '@changesets/get-github-info@0.6.0': dependencies: @@ -22685,7 +22685,7 @@ snapshots: dependencies: '@floating-ui/dom': 1.7.4 react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -23835,7 +23835,7 @@ snapshots: pkce-challenge: 4.1.0 prismjs: 1.30.0 react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) react-simple-code-editor: 0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) serve-handler: 6.1.6 tailwind-merge: 2.6.0 @@ -24690,7 +24690,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24706,7 +24706,7 @@ snapshots: '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24730,7 +24730,7 @@ snapshots: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24797,7 +24797,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.1(@types/react@19.2.17)(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 @@ -24836,7 +24836,7 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24885,7 +24885,7 @@ snapshots: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24925,7 +24925,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -24973,7 +24973,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.1(@types/react@19.2.17)(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 @@ -25010,7 +25010,7 @@ snapshots: '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/rect': 1.1.1 react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25030,7 +25030,7 @@ snapshots: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25050,7 +25050,7 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25068,7 +25068,7 @@ snapshots: dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25111,7 +25111,7 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25168,7 +25168,7 @@ snapshots: '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.1(@types/react@19.2.17)(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 @@ -25215,7 +25215,7 @@ snapshots: '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25235,7 +25235,7 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25275,7 +25275,7 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -25401,7 +25401,7 @@ snapshots: dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -28143,7 +28143,7 @@ snapshots: '@types/papaparse@5.5.2': dependencies: - '@types/node': 24.13.3 + '@types/node': 22.13.10 '@types/parse-json@4.0.2': {} @@ -28153,7 +28153,7 @@ snapshots: '@types/pdf-parse@1.1.5': dependencies: - '@types/node': 24.13.3 + '@types/node': 22.13.10 '@types/pg@8.11.6': dependencies: @@ -28643,11 +28643,10 @@ snapshots: - babel-plugin-macros - supports-color - '@vercel/analytics@1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vue@3.5.40(typescript@5.9.3))': + '@vercel/analytics@1.6.1(next@15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': optionalDependencies: next: 15.5.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 - vue: 3.5.40(typescript@5.9.3) '@vercel/blob@0.24.1': dependencies: @@ -30185,7 +30184,7 @@ snapshots: '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@18.3.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -30475,7 +30474,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.19) postcss-modules-values: 4.0.0(postcss@8.5.19) postcss-value-parser: 4.2.0 - semver: 7.8.5 + semver: 7.7.4 optionalDependencies: webpack: 5.106.2 @@ -36880,7 +36879,7 @@ snapshots: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.5.19 - semver: 7.8.5 + semver: 7.7.4 webpack: 5.106.2 postcss-logical@5.0.4(postcss@8.5.19): @@ -37585,10 +37584,10 @@ snapshots: - bufferutil - utf-8-validate - react-dom@18.3.1(react@19.2.7): + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 - react: 19.2.7 + react: 18.3.1 scheduler: 0.23.2 react-dom@19.1.1(react@19.1.1): @@ -37995,7 +37994,7 @@ snapshots: react-simple-code-editor@0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 - react-dom: 18.3.1(react@19.2.7) + react-dom: 18.3.1(react@18.3.1) react-style-singleton@2.2.3(@types/react@19.2.17)(react@18.3.1): dependencies: @@ -39910,13 +39909,13 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyglobby@0.2.17: dependencies: From a772f4ccdc7e3dfec746e02e940700c9be57ec56 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 18:21:33 -0700 Subject: [PATCH 05/28] refactor(vue): organize package sources --- packages/vue/__tests__/package-layout.test.ts | 31 ++ .../vue/{src => }/__tests__/runtime.test.ts | 4 +- packages/vue/components/T.ts | 52 ++++ packages/vue/components/branches.ts | 90 ++++++ packages/vue/components/utils.ts | 77 +++++ packages/vue/components/variables.ts | 151 ++++++++++ packages/vue/composables/locale.ts | 28 ++ packages/vue/composables/strings.ts | 62 ++++ packages/vue/messages/msg.ts | 53 ++++ packages/vue/messages/translation.ts | 60 ++++ .../translateVueChildren.ts} | 2 +- packages/vue/{src => runtime}/state.ts | 31 +- packages/vue/src/components.ts | 273 ------------------ packages/vue/src/index.ts | 13 +- packages/vue/src/locale.ts | 10 - packages/vue/src/string.ts | 124 -------- packages/vue/src/types.ts | 41 --- packages/vue/tsconfig.json | 12 +- packages/vue/tsdown.config.mts | 13 +- packages/vue/types/index.ts | 93 ++++++ 20 files changed, 760 insertions(+), 460 deletions(-) create mode 100644 packages/vue/__tests__/package-layout.test.ts rename packages/vue/{src => }/__tests__/runtime.test.ts (99%) create mode 100644 packages/vue/components/T.ts create mode 100644 packages/vue/components/branches.ts create mode 100644 packages/vue/components/utils.ts create mode 100644 packages/vue/components/variables.ts create mode 100644 packages/vue/composables/locale.ts create mode 100644 packages/vue/composables/strings.ts create mode 100644 packages/vue/messages/msg.ts create mode 100644 packages/vue/messages/translation.ts rename packages/vue/{src/rich.ts => rendering/translateVueChildren.ts} (99%) rename packages/vue/{src => runtime}/state.ts (69%) delete mode 100644 packages/vue/src/components.ts delete mode 100644 packages/vue/src/locale.ts delete mode 100644 packages/vue/src/string.ts delete mode 100644 packages/vue/src/types.ts create mode 100644 packages/vue/types/index.ts diff --git a/packages/vue/__tests__/package-layout.test.ts b/packages/vue/__tests__/package-layout.test.ts new file mode 100644 index 0000000000..3953d60c41 --- /dev/null +++ b/packages/vue/__tests__/package-layout.test.ts @@ -0,0 +1,31 @@ +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import * as gtVue from '../src'; + +describe('gt-vue package layout', () => { + it('reserves src for package entry points', () => { + const src = fileURLToPath(new URL('../src', import.meta.url)); + expect(readdirSync(src).sort()).toEqual(['index.ts']); + }); + + it('exports the complete runtime API from the root entry point', () => { + expect(Object.keys(gtVue).sort()).toEqual( + [ + 'Branch', + 'Currency', + 'DateTime', + 'Num', + 'Plural', + 'T', + 'Var', + 'createGT', + 'msg', + 'useGT', + 'useLocale', + 'useMessages', + 'useSetLocale', + ].sort() + ); + }); +}); diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/__tests__/runtime.test.ts similarity index 99% rename from packages/vue/src/__tests__/runtime.test.ts rename to packages/vue/__tests__/runtime.test.ts index a4ffcf8255..47f0a856c2 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/__tests__/runtime.test.ts @@ -26,8 +26,8 @@ import { useGT, useLocale, useMessages, -} from '../index'; -import type { TranslationCatalog } from '../types'; +} from '../src'; +import type { TranslationCatalog } from '../src'; describe('gt-vue runtime', () => { it('loads and caches plain STRING translations, then rerenders on locale changes', async () => { diff --git a/packages/vue/components/T.ts b/packages/vue/components/T.ts new file mode 100644 index 0000000000..661e09dd15 --- /dev/null +++ b/packages/vue/components/T.ts @@ -0,0 +1,52 @@ +import { defineComponent } from 'vue'; +import { translateVueChildren } from '../rendering/translateVueChildren'; +import { useGTState } from '../runtime/state'; +import { withGTMetadata } from './utils'; + +type TProps = { + /** @internal Compile-time hash inserted by GT tooling. */ + _hash?: string; + /** Translation context using the API-parity `$context` spelling. */ + $context?: string; + /** Translation context using a Vue-template-friendly prop name. */ + context?: string; +}; + +/** + * Translates rich content from its default slot with the active locale's + * catalog. Missing entries render the source slot, and loaded catalogs or + * locale changes trigger a reactive rerender. + * + * Wrap runtime values in {@link Var}; `T` does not interpolate string + * placeholders or process ICU syntax. + * + * @example + * ```vue + * + * Hello, {{ name }}! + * + * ``` + */ +export const T = withGTMetadata( + defineComponent({ + inheritAttrs: false, + name: 'T', + props: { + /** @internal Compile-time hash inserted by GT tooling. */ + _hash: String, + /** Translation context used to disambiguate identical source content. */ + context: String, + }, + setup(props, { attrs, slots }) { + const state = useGTState(); + return () => + translateVueChildren(slots.default?.() ?? [], state, { + ...props, + ...(typeof attrs.$context === 'string' && { + $context: attrs.$context, + }), + }); + }, + }), + 'translate-client' +); diff --git a/packages/vue/components/branches.ts b/packages/vue/components/branches.ts new file mode 100644 index 0000000000..2cdc741147 --- /dev/null +++ b/packages/vue/components/branches.ts @@ -0,0 +1,90 @@ +import { + getPluralForm, + isAcceptedPluralForm, +} from 'generaltranslation/internal'; +import { defineComponent, type PropType } from 'vue'; +import { useGTState } from '../runtime/state'; +import { + getBranchContent, + getBranchNames, + getFormatLocales, + withGTMetadata, +} from './utils'; + +type PluralProps = { + /** Locale preferences tried before the active GT locale. */ + locales?: string[]; + /** Numeric value used to select a plural category. */ + n: number; +}; + +type BranchProps = { + /** Named branch to render after conversion to a string. */ + branch?: string | number | boolean; +}; + +/** + * Selects a named plural slot such as `one` or `other` from `n` and the active + * locale's plural rules. Missing categories fall back to the default slot. + * + * Explicit `locales` are tried before the active GT locale. + */ +export const Plural = withGTMetadata( + defineComponent({ + inheritAttrs: false, + name: 'Plural', + props: { + /** Locale preferences tried before the active GT locale. */ + locales: Array as PropType, + /** Numeric value used to select a plural category. */ + n: { + required: true, + type: Number, + }, + }, + setup(props, { attrs, slots }) { + const state = useGTState(); + return () => { + const branches = getBranchNames(attrs, slots).filter( + isAcceptedPluralForm + ); + const branch = getPluralForm( + props.n, + branches, + getFormatLocales(props.locales, state.locale.value) + ); + return getBranchContent(branch, attrs, slots); + }; + }, + }), + 'plural' +); + +/** + * Selects an arbitrary named slot or attribute from `branch`. Missing branch + * keys render the default slot. + * + * When used inside {@link T}, every named branch is extracted for translation + * while only the active branch is rendered. + */ +export const Branch = withGTMetadata( + defineComponent({ + inheritAttrs: false, + name: 'Branch', + props: { + /** Named branch to render after conversion to a string. */ + branch: [String, Number, Boolean] as PropType, + }, + setup(props, { attrs, slots }) { + return () => { + const branch = props.branch?.toString(); + return getBranchContent( + branch && !branch.startsWith('data-') ? branch : undefined, + attrs, + slots + ); + }; + }, + }), + 'branch' +); diff --git a/packages/vue/components/utils.ts b/packages/vue/components/utils.ts new file mode 100644 index 0000000000..4f01388b40 --- /dev/null +++ b/packages/vue/components/utils.ts @@ -0,0 +1,77 @@ +import { + isVNode, + type Component, + type DefineComponent, + type Slots, + type VNodeChild, +} from 'vue'; + +/** @internal GT metadata attached to components for rich-content extraction. */ +export type GTComponent = DefineComponent & { + /** @internal */ + _gtt: string; +}; + +/** @internal */ +export function withGTMetadata( + component: Component, + metadata: string +): GTComponent { + return Object.assign(component, { _gtt: metadata }) as GTComponent; +} + +/** @internal */ +export function getFormatLocales( + locales: string[] | undefined, + locale: string +): string[] { + return [...(locales ?? []), locale]; +} + +/** @internal */ +export function readSlotText(slots: Slots): string { + return (slots.default?.() ?? []).map(readVNodeText).join(''); +} + +function readVNodeText(node: VNodeChild): string { + if (node == null || typeof node === 'boolean') return ''; + if (Array.isArray(node)) return node.map(readVNodeText).join(''); + if (!isVNode(node)) return String(node); + if (typeof node.children === 'string') return node.children; + if (Array.isArray(node.children)) { + return node.children.map(readVNodeText).join(''); + } + return ''; +} + +/** @internal */ +export function getBranchNames( + attrs: Record, + slots: Slots +): string[] { + return [ + ...Object.keys(attrs).filter((key) => !key.startsWith('data-')), + ...Object.keys(slots).filter( + (key) => key !== 'default' && !key.startsWith('_') + ), + ]; +} + +/** @internal */ +export function getBranchContent( + branch: string | undefined, + attrs: Record, + slots: Slots +) { + if ( + branch && + Object.hasOwn(slots, branch) && + typeof slots[branch] === 'function' + ) { + return slots[branch](); + } + if (branch && Object.hasOwn(attrs, branch) && attrs[branch] !== undefined) { + return String(attrs[branch]); + } + return slots.default?.() ?? null; +} diff --git a/packages/vue/components/variables.ts b/packages/vue/components/variables.ts new file mode 100644 index 0000000000..e0226592e1 --- /dev/null +++ b/packages/vue/components/variables.ts @@ -0,0 +1,151 @@ +import { defineComponent, type PropType } from 'vue'; +import { useGTState } from '../runtime/state'; +import { getFormatLocales, readSlotText, withGTMetadata } from './utils'; + +type NumberFormatProps = { + /** Locale preferences tried before the active GT locale. */ + locales?: string[]; + /** Options forwarded to `Intl.NumberFormat`. */ + options?: Intl.NumberFormatOptions; +}; + +type DateTimeProps = { + /** Locale preferences tried before the active GT locale. */ + locales?: string[]; + /** Options forwarded to `Intl.DateTimeFormat`. */ + options?: Intl.DateTimeFormatOptions; +}; + +type CurrencyProps = NumberFormatProps & { + /** ISO 4217 currency code. Defaults to `USD`. */ + currency?: string; +}; + +/** + * Marks its default-slot child as an opaque runtime value inside {@link T}. + * The child renders unchanged and is reinserted wherever the translated rich + * tree references it. + * + * `Var` is intentionally child-only: it does not accept `name` or `value` + * props. + * + * @example + * ```vue + * {{ accountName }} + * ``` + */ +export const Var = withGTMetadata( + defineComponent({ + name: 'Var', + setup(_props, { slots }) { + return () => slots.default?.() ?? null; + }, + }), + 'variable-variable' +); + +/** + * Formats numeric default-slot text with `Intl.NumberFormat` for the active + * locale. Explicit `locales` are tried first. Slot text that + * `Number.parseFloat` cannot parse is returned unchanged. + */ +export const Num = withGTMetadata( + defineComponent({ + name: 'Num', + props: { + /** Locale preferences tried before the active GT locale. */ + locales: Array as PropType, + /** Options forwarded to `Intl.NumberFormat`. */ + options: Object as PropType, + }, + setup(props, { slots }) { + const state = useGTState(); + return () => { + const value = readSlotText(slots); + if (!value) return null; + const number = Number.parseFloat(value); + return Number.isNaN(number) + ? value + : new Intl.NumberFormat( + getFormatLocales(props.locales, state.locale.value), + props.options + ).format(number); + }; + }, + }), + 'variable-number' +); + +/** + * Parses and formats default-slot text with `Intl.DateTimeFormat` for the + * active locale. Explicit `locales` are tried first, and invalid dates are + * returned unchanged. + */ +export const DateTime = withGTMetadata( + defineComponent({ + name: 'DateTime', + props: { + /** Locale preferences tried before the active GT locale. */ + locales: Array as PropType, + /** Options forwarded to `Intl.DateTimeFormat`. */ + options: Object as PropType, + }, + setup(props, { slots }) { + const state = useGTState(); + return () => { + const value = readSlotText(slots); + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat( + getFormatLocales(props.locales, state.locale.value), + props.options + ) + .format(date) + .replace(/[\u200F\u202B\u202E]/g, ''); + }; + }, + }), + 'variable-datetime' +); + +/** + * Formats numeric default-slot text as currency for the active locale. + * `currency` defaults to `USD`. Slot text that `Number.parseFloat` cannot parse + * is returned unchanged. + */ +export const Currency = withGTMetadata( + defineComponent({ + name: 'Currency', + props: { + /** ISO 4217 currency code. Defaults to `USD`. */ + currency: { + default: 'USD', + type: String, + }, + /** Locale preferences tried before the active GT locale. */ + locales: Array as PropType, + /** Additional options forwarded to `Intl.NumberFormat`. */ + options: Object as PropType, + }, + setup(props, { slots }) { + const state = useGTState(); + return () => { + const value = readSlotText(slots); + if (!value) return null; + const number = Number.parseFloat(value); + return Number.isNaN(number) + ? value + : new Intl.NumberFormat( + getFormatLocales(props.locales, state.locale.value), + { + ...props.options, + currency: props.currency, + style: 'currency', + } + ).format(number); + }; + }, + }), + 'variable-currency' +); diff --git a/packages/vue/composables/locale.ts b/packages/vue/composables/locale.ts new file mode 100644 index 0000000000..a0a26799dc --- /dev/null +++ b/packages/vue/composables/locale.ts @@ -0,0 +1,28 @@ +import { readonly, type DeepReadonly, type Ref } from 'vue'; +import { useGTState } from '../runtime/state'; + +/** + * Returns a readonly ref for the active locale. + * + * Components, computed values, and render effects that read this ref update + * after {@link useSetLocale} finishes switching locales. Vue templates unwrap + * the ref automatically. + * + * @returns A readonly reactive locale ref. + */ +export function useLocale(): DeepReadonly> { + return readonly(useGTState().locale); +} + +/** + * Returns the active plugin's asynchronous locale setter. + * + * The setter loads and caches a missing catalog before updating the reactive + * locale. It rejects when the configured loader rejects, and only the latest + * overlapping request is applied. + * + * @returns An async function that switches to the requested locale. + */ +export function useSetLocale(): (locale: string) => Promise { + return useGTState().setLocale; +} diff --git a/packages/vue/composables/strings.ts b/packages/vue/composables/strings.ts new file mode 100644 index 0000000000..8467e61af3 --- /dev/null +++ b/packages/vue/composables/strings.ts @@ -0,0 +1,62 @@ +import { + decodeMessageOptions, + translateString, + type InternalStringOptions, +} from '../messages/translation'; +import { useGTState } from '../runtime/state'; +import type { GTFunction, GTStringOptions, MessagesFunction } from '../types'; + +/** + * Returns a synchronous plain-string translation function for the active GT + * plugin. + * + * `$context` is the only supported option. Braces remain literal and no ICU + * formatting or interpolation is applied. Missing entries return the source + * string. Call the returned function from a render, computed value, or + * reactive effect when the result should update with locale/catalog changes. + * + * @returns A synchronous STRING catalog lookup function. + */ +export function useGT(): GTFunction { + const state = useGTState(); + return (message, options = {}) => + translateString(state, message, options as InternalStringOptions); +} + +/** + * Returns a synchronous resolver for values registered with {@link msg} and + * for ordinary source strings. + * + * Encoded `msg` metadata takes precedence over call-site options. Raw strings + * may supply `$context`; null and undefined are returned unchanged. Missing + * entries fall back to the source text, with no ICU formatting or + * interpolation. Call the resolver from a render, computed value, or reactive + * effect when the result should update with locale/catalog changes. + * + * @returns A resolver for registered messages and raw strings. + */ +export function useMessages(): MessagesFunction { + const state = useGTState(); + return (( + message: T, + options: GTStringOptions = {} + ): T extends string ? string : T => { + if (message == null) return message as T extends string ? string : T; + + const decoded = decodeMessageOptions(message); + if ( + decoded && + typeof decoded.$_source === 'string' && + typeof decoded.$_hash === 'string' + ) { + return translateString(state, decoded.$_source, { + $context: decoded.$context, + $_hash: decoded.$_hash, + }) as T extends string ? string : T; + } + + return translateString(state, message, options) as T extends string + ? string + : T; + }) as MessagesFunction; +} diff --git a/packages/vue/messages/msg.ts b/packages/vue/messages/msg.ts new file mode 100644 index 0000000000..8ca22fac22 --- /dev/null +++ b/packages/vue/messages/msg.ts @@ -0,0 +1,53 @@ +import { hashSource } from 'generaltranslation/id'; +import { encode } from 'generaltranslation/internal'; +import type { GTStringOptions } from '../types'; + +/** + * Registers one or more static strings for extraction and later resolution by + * {@link useMessages}. This function does not perform a translation lookup. + * + * With no options, the original string or array is returned unchanged. When an + * options object is supplied, `msg` appends opaque STRING lookup metadata + * while preserving the source text literally; it performs no ICU processing + * or interpolation. + * + * @param message - Static source string or readonly array of static strings. + * @param options - Optional context used to disambiguate the source hash. + * @returns The original input when no options are supplied, otherwise an + * encoded string or array of encoded strings. + * + * @example + * ```ts + * const saved = msg('Your preferences are saved.', { + * $context: 'status message', + * }); + * ``` + */ +export function msg(message: T): T; +export function msg( + message: T, + options?: GTStringOptions +): T extends string ? string : string[]; +export function msg( + message: string | readonly string[], + options?: GTStringOptions +): string | readonly string[] { + if (typeof message !== 'string') { + return message.map((entry) => msg(entry, options)); + } + if (!options) return message; + + const $_hash = hashSource({ + context: options.$context, + dataFormat: 'STRING', + source: message, + }); + const encoded = encode( + JSON.stringify({ + ...options, + $_hash, + $_source: message, + }) + ); + return `${message}:${encoded}`; +} diff --git a/packages/vue/messages/translation.ts b/packages/vue/messages/translation.ts new file mode 100644 index 0000000000..3ff11b6f7c --- /dev/null +++ b/packages/vue/messages/translation.ts @@ -0,0 +1,60 @@ +import { hashSource } from 'generaltranslation/id'; +import { decode } from 'generaltranslation/internal'; +import type { GTState, GTStringOptions } from '../types'; + +/** @internal */ +export type InternalStringOptions = GTStringOptions & { + /** @internal Compile-time hash inserted by GT tooling. */ + $_hash?: string; +}; + +/** @internal */ +export function translateString( + state: GTState, + message: string, + options: InternalStringOptions = {} +): string { + const hash = + options.$_hash ?? + hashSource({ + context: options.$context, + dataFormat: 'STRING', + source: message, + }); + const translation = state.getCatalog()[hash]; + return typeof translation === 'string' ? translation : message; +} + +/** @internal */ +export function decodeMessageOptions(message: string): + | { + $context?: string; + $_hash: string; + $_source: string; + } + | undefined { + const separator = message.lastIndexOf(':'); + if (separator < 0) return undefined; + + try { + const options = JSON.parse(decode(message.slice(separator + 1))) as Record< + string, + unknown + >; + if ( + typeof options.$_hash === 'string' && + typeof options.$_source === 'string' + ) { + return { + ...(typeof options.$context === 'string' && { + $context: options.$context, + }), + $_hash: options.$_hash, + $_source: options.$_source, + }; + } + } catch { + // A normal string may contain a colon. It is not an encoded message. + } + return undefined; +} diff --git a/packages/vue/src/rich.ts b/packages/vue/rendering/translateVueChildren.ts similarity index 99% rename from packages/vue/src/rich.ts rename to packages/vue/rendering/translateVueChildren.ts index df8a595407..1396013ccb 100644 --- a/packages/vue/src/rich.ts +++ b/packages/vue/rendering/translateVueChildren.ts @@ -25,7 +25,7 @@ import { type VNode, type VNodeChild, } from 'vue'; -import type { GTState } from './types'; +import type { GTState } from '../types'; const variableTypes = { currency: { name: 'cost', type: 'c' }, diff --git a/packages/vue/src/state.ts b/packages/vue/runtime/state.ts similarity index 69% rename from packages/vue/src/state.ts rename to packages/vue/runtime/state.ts index cc56083d22..de990d36fd 100644 --- a/packages/vue/src/state.ts +++ b/packages/vue/runtime/state.ts @@ -8,10 +8,38 @@ import type { GTPlugin, GTState, TranslationCatalog, -} from './types'; +} from '../types'; const gtContextKey: InjectionKey = Symbol('gt-vue'); +/** + * Creates an isolated gt-vue plugin with reactive locale state and a + * per-locale translation cache. + * + * Successful catalog loads are cached for the lifetime of this plugin, and + * concurrent requests for the same locale share one promise. The plugin + * loads an uncached locale before switching reactive consumers to it; when + * locale requests overlap, only the latest request is applied. + * + * Client applications can render source content while the initial catalog is + * loading. For SSR, create one plugin per request and await + * `loadTranslations(locale)` or `setLocale(locale)` before rendering. + * + * @param options - Initial locale, fallback locale, and async catalog loader. + * @returns A Vue plugin for `app.use()` plus imperative preload and locale + * controls. + * + * @example + * ```ts + * const gt = createGT({ + * defaultLocale: 'en', + * loadTranslations: async (locale) => + * (await import(`./_gt/${locale}.json`)).default, + * }); + * + * createApp(App).use(gt).mount('#app'); + * ``` + */ export function createGT({ defaultLocale = libraryDefaultLocale, loadTranslations, @@ -76,6 +104,7 @@ export function createGT({ }; } +/** @internal Returns the GT state provided to the current Vue component. */ export function useGTState(): GTState { const state = inject(gtContextKey); if (state) return state; diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts deleted file mode 100644 index c9c4a4a2d9..0000000000 --- a/packages/vue/src/components.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { - getPluralForm, - isAcceptedPluralForm, -} from 'generaltranslation/internal'; -import { - defineComponent, - isVNode, - type Component, - type DefineComponent, - type PropType, - type Slots, - type VNodeChild, -} from 'vue'; -import { translateVueChildren } from './rich'; -import { useGTState } from './state'; - -type GTComponent = DefineComponent & { _gtt: string }; - -type TProps = { - /** @internal Compile-time hash inserted by GT tooling. */ - _hash?: string; - $context?: string; - context?: string; -}; - -type NumberFormatProps = { - locales?: string[]; - options?: Intl.NumberFormatOptions; -}; - -type DateTimeProps = { - locales?: string[]; - options?: Intl.DateTimeFormatOptions; -}; - -type CurrencyProps = NumberFormatProps & { - currency?: string; -}; - -type PluralProps = { - locales?: string[]; - n: number; -}; - -type BranchProps = { - branch?: string | number | boolean; -}; - -function withGTMetadata( - component: Component, - metadata: string -): GTComponent { - return Object.assign(component, { _gtt: metadata }) as GTComponent; -} - -export const T = withGTMetadata( - defineComponent({ - inheritAttrs: false, - name: 'T', - props: { - /** @internal Compile-time hash inserted by GT tooling. */ - _hash: String, - context: String, - }, - setup(props, { attrs, slots }) { - const state = useGTState(); - return () => - translateVueChildren(slots.default?.() ?? [], state, { - ...props, - ...(typeof attrs.$context === 'string' && { - $context: attrs.$context, - }), - }); - }, - }), - 'translate-client' -); - -export const Var = withGTMetadata( - defineComponent({ - name: 'Var', - setup(_props, { slots }) { - return () => slots.default?.() ?? null; - }, - }), - 'variable-variable' -); - -export const Num = withGTMetadata( - defineComponent({ - name: 'Num', - props: { - locales: Array as PropType, - options: Object as PropType, - }, - setup(props, { slots }) { - const state = useGTState(); - return () => { - const value = readSlotText(slots); - if (!value) return null; - const number = Number.parseFloat(value); - return Number.isNaN(number) - ? value - : new Intl.NumberFormat( - getFormatLocales(props.locales, state.locale.value), - props.options - ).format(number); - }; - }, - }), - 'variable-number' -); - -export const DateTime = withGTMetadata( - defineComponent({ - name: 'DateTime', - props: { - locales: Array as PropType, - options: Object as PropType, - }, - setup(props, { slots }) { - const state = useGTState(); - return () => { - const value = readSlotText(slots); - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat( - getFormatLocales(props.locales, state.locale.value), - props.options - ) - .format(date) - .replace(/[\u200F\u202B\u202E]/g, ''); - }; - }, - }), - 'variable-datetime' -); - -export const Currency = withGTMetadata( - defineComponent({ - name: 'Currency', - props: { - currency: { - default: 'USD', - type: String, - }, - locales: Array as PropType, - options: Object as PropType, - }, - setup(props, { slots }) { - const state = useGTState(); - return () => { - const value = readSlotText(slots); - if (!value) return null; - const number = Number.parseFloat(value); - return Number.isNaN(number) - ? value - : new Intl.NumberFormat( - getFormatLocales(props.locales, state.locale.value), - { - ...props.options, - currency: props.currency, - style: 'currency', - } - ).format(number); - }; - }, - }), - 'variable-currency' -); - -export const Plural = withGTMetadata( - defineComponent({ - inheritAttrs: false, - name: 'Plural', - props: { - locales: Array as PropType, - n: { - required: true, - type: Number, - }, - }, - setup(props, { attrs, slots }) { - const state = useGTState(); - return () => { - const branches = getBranchNames(attrs, slots).filter( - isAcceptedPluralForm - ); - const branch = getPluralForm( - props.n, - branches, - getFormatLocales(props.locales, state.locale.value) - ); - return getBranchContent(branch, attrs, slots); - }; - }, - }), - 'plural' -); - -export const Branch = withGTMetadata( - defineComponent({ - inheritAttrs: false, - name: 'Branch', - props: { - branch: [String, Number, Boolean] as PropType, - }, - setup(props, { attrs, slots }) { - return () => { - const branch = props.branch?.toString(); - return getBranchContent( - branch && !branch.startsWith('data-') ? branch : undefined, - attrs, - slots - ); - }; - }, - }), - 'branch' -); - -function getFormatLocales( - locales: string[] | undefined, - locale: string -): string[] { - return [...(locales ?? []), locale]; -} - -function readSlotText(slots: Slots): string { - return (slots.default?.() ?? []).map(readVNodeText).join(''); -} - -function readVNodeText(node: VNodeChild): string { - if (node == null || typeof node === 'boolean') return ''; - if (Array.isArray(node)) return node.map(readVNodeText).join(''); - if (!isVNode(node)) return String(node); - if (typeof node.children === 'string') return node.children; - if (Array.isArray(node.children)) { - return node.children.map(readVNodeText).join(''); - } - return ''; -} - -function getBranchNames( - attrs: Record, - slots: Slots -): string[] { - return [ - ...Object.keys(attrs).filter((key) => !key.startsWith('data-')), - ...Object.keys(slots).filter( - (key) => key !== 'default' && !key.startsWith('_') - ), - ]; -} - -function getBranchContent( - branch: string | undefined, - attrs: Record, - slots: Slots -) { - if ( - branch && - Object.hasOwn(slots, branch) && - typeof slots[branch] === 'function' - ) { - return slots[branch](); - } - if (branch && Object.hasOwn(attrs, branch) && attrs[branch] !== undefined) { - return String(attrs[branch]); - } - return slots.default?.() ?? null; -} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index c480d8bb49..550fa50cea 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,7 +1,10 @@ -export { Branch, Currency, DateTime, Num, Plural, T, Var } from './components'; -export { useLocale, useSetLocale } from './locale'; -export { createGT } from './state'; -export { msg, useGT, useMessages } from './string'; +export { Branch, Plural } from '../components/branches'; +export { T } from '../components/T'; +export { Currency, DateTime, Num, Var } from '../components/variables'; +export { useLocale, useSetLocale } from '../composables/locale'; +export { useGT, useMessages } from '../composables/strings'; +export { msg } from '../messages/msg'; +export { createGT } from '../runtime/state'; export type { CreateGTOptions, GTFunction, @@ -10,4 +13,4 @@ export type { LoadTranslations, MessagesFunction, TranslationCatalog, -} from './types'; +} from '../types'; diff --git a/packages/vue/src/locale.ts b/packages/vue/src/locale.ts deleted file mode 100644 index aac7e5e5cf..0000000000 --- a/packages/vue/src/locale.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { readonly, type DeepReadonly, type Ref } from 'vue'; -import { useGTState } from './state'; - -export function useLocale(): DeepReadonly> { - return readonly(useGTState().locale); -} - -export function useSetLocale(): (locale: string) => Promise { - return useGTState().setLocale; -} diff --git a/packages/vue/src/string.ts b/packages/vue/src/string.ts deleted file mode 100644 index fcf519b9e0..0000000000 --- a/packages/vue/src/string.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { hashSource } from 'generaltranslation/id'; -import { decode, encode } from 'generaltranslation/internal'; -import { useGTState } from './state'; -import type { - GTFunction, - GTState, - GTStringOptions, - MessagesFunction, -} from './types'; - -type InternalStringOptions = GTStringOptions & { - /** @internal Compile-time hash inserted by GT tooling. */ - $_hash?: string; -}; - -function translateString( - state: GTState, - message: string, - options: InternalStringOptions = {} -): string { - const hash = - options.$_hash ?? - hashSource({ - context: options.$context, - dataFormat: 'STRING', - source: message, - }); - const translation = state.getCatalog()[hash]; - return typeof translation === 'string' ? translation : message; -} - -export function useGT(): GTFunction { - const state = useGTState(); - return (message, options = {}) => - translateString(state, message, options as InternalStringOptions); -} - -export function useMessages(): MessagesFunction { - const state = useGTState(); - return (( - message: T, - options: GTStringOptions = {} - ): T extends string ? string : T => { - if (message == null) return message as T extends string ? string : T; - - const decoded = decodeMessageOptions(message); - if ( - decoded && - typeof decoded.$_source === 'string' && - typeof decoded.$_hash === 'string' - ) { - return translateString(state, decoded.$_source, { - $context: decoded.$context, - $_hash: decoded.$_hash, - }) as T extends string ? string : T; - } - - return translateString(state, message, options) as T extends string - ? string - : T; - }) as MessagesFunction; -} - -export function msg(message: T): T; -export function msg( - message: T, - options?: GTStringOptions -): T extends string ? string : string[]; -export function msg( - message: string | readonly string[], - options?: GTStringOptions -): string | readonly string[] { - if (typeof message !== 'string') { - return message.map((entry) => msg(entry, options)); - } - if (!options) return message; - - const $_hash = hashSource({ - context: options.$context, - dataFormat: 'STRING', - source: message, - }); - const encoded = encode( - JSON.stringify({ - ...options, - $_hash, - $_source: message, - }) - ); - return `${message}:${encoded}`; -} - -function decodeMessageOptions(message: string): - | { - $context?: string; - $_hash: string; - $_source: string; - } - | undefined { - const separator = message.lastIndexOf(':'); - if (separator < 0) return undefined; - - try { - const options = JSON.parse(decode(message.slice(separator + 1))) as Record< - string, - unknown - >; - if ( - typeof options.$_hash === 'string' && - typeof options.$_source === 'string' - ) { - return { - ...(typeof options.$context === 'string' && { - $context: options.$context, - }), - $_hash: options.$_hash, - $_source: options.$_source, - }; - } - } catch { - // A normal string may contain a colon. It is not an encoded message. - } - return undefined; -} diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts deleted file mode 100644 index 6aaa4219a1..0000000000 --- a/packages/vue/src/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { JsxChildren } from 'generaltranslation/types'; -import type { App, Ref } from 'vue'; - -export type TranslationCatalog = Record; - -export type LoadTranslations = (locale: string) => Promise; - -/** Options supported by gt-vue's plain string translations. */ -export type GTStringOptions = { - $context?: string; -}; - -export type GTFunction = (message: string, options?: GTStringOptions) => string; - -export type MessagesFunction = ( - message: T, - options?: GTStringOptions -) => T extends string ? string : T; - -export type CreateGTOptions = { - defaultLocale?: string; - loadTranslations?: LoadTranslations; - locale?: string; -}; - -export type GTPlugin = { - getLocale(): string; - install(app: App): void; - loadTranslations(locale: string): Promise; - setLocale(locale: string): Promise; -}; - -/** Internal reactive state scoped to one installed plugin instance. */ -export type GTState = { - defaultLocale: string; - getCatalog(): TranslationCatalog; - loadTranslations(locale: string): Promise; - locale: Ref; - revision: Ref; - setLocale(locale: string): Promise; -}; diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 98f6ecd295..2eccbf61ff 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -7,10 +7,18 @@ "sourceMap": true, "declarationMap": true, "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", "composite": true }, - "include": ["src/**/*"], + "include": [ + "components/**/*", + "composables/**/*", + "messages/**/*", + "rendering/**/*", + "runtime/**/*", + "src/**/*", + "types/**/*" + ], "exclude": ["**/*.test.ts"], "references": [ { diff --git a/packages/vue/tsdown.config.mts b/packages/vue/tsdown.config.mts index a9642044db..599c8dd15f 100644 --- a/packages/vue/tsdown.config.mts +++ b/packages/vue/tsdown.config.mts @@ -10,4 +10,15 @@ const deps = { ], }; -export default defineConfig(createTsdownConfig(['src/index.ts'], deps)); +const configs = createTsdownConfig(['src/index.ts'], deps).map((config) => ({ + ...config, + outputOptions: { + comments: { + annotation: true, + jsdoc: false, + legal: true, + }, + }, +})); + +export default defineConfig(configs); diff --git a/packages/vue/types/index.ts b/packages/vue/types/index.ts new file mode 100644 index 0000000000..1504054ccf --- /dev/null +++ b/packages/vue/types/index.ts @@ -0,0 +1,93 @@ +import type { JsxChildren } from 'generaltranslation/types'; +import type { App, Ref } from 'vue'; + +/** + * A hash-keyed locale catalog containing either plain STRING translations or + * structured rich-content translations consumed by {@link T}. + */ +export type TranslationCatalog = Record; + +/** + * Loads the complete translation catalog for one locale. + * + * Return an empty object when a locale has no catalog. {@link createGT} + * caches successful results and deduplicates concurrent requests. + * + * @param locale - Locale code requested by the active GT plugin. + * @returns The locale's translation catalog. + */ +export type LoadTranslations = (locale: string) => Promise; + +/** + * Options supported by gt-vue plain-string lookups. + * + * gt-vue intentionally does not support `$maxChars`, `$format`, ICU syntax, + * or interpolation variables. + */ +export type GTStringOptions = { + /** Disambiguates identical source strings when calculating their hash. */ + $context?: string; +}; + +/** + * Performs a synchronous STRING catalog lookup. + * + * @param message - Source string to translate. Braces remain literal. + * @param options - Optional context used when hashing the source. + * @returns The translated string, or `message` when no entry exists. + */ +export type GTFunction = (message: string, options?: GTStringOptions) => string; + +/** + * Resolves strings registered by {@link msg}, raw source strings, and nullish + * values. + * + * @param message - Encoded message, raw source string, `null`, or `undefined`. + * @param options - Optional context for raw source strings. Encoded metadata + * takes precedence. + * @returns A translation for string input; nullish input is returned as-is. + */ +export type MessagesFunction = ( + message: T, + options?: GTStringOptions +) => T extends string ? string : T; + +/** Options used to create an isolated gt-vue plugin instance. */ +export type CreateGTOptions = { + /** Source and fallback locale. Defaults to GT's library default locale. */ + defaultLocale?: string; + /** Async loader called once for each uncached locale. */ + loadTranslations?: LoadTranslations; + /** Initial active locale. Defaults to `defaultLocale`. */ + locale?: string; +}; + +/** + * Vue plugin returned by {@link createGT}. + * + * Install it with `app.use(plugin)`. Its locale and catalog cache are scoped + * to that plugin instance. + */ +export type GTPlugin = { + /** Returns a non-reactive snapshot of the active locale. */ + getLocale(): string; + /** Provides the GT state to a Vue application. Usually called by `app.use`. */ + install(app: App): void; + /** Preloads and caches a locale without changing the active locale. */ + loadTranslations(locale: string): Promise; + /** + * Loads a locale when needed, then switches reactive consumers to it. + * Only the latest overlapping locale request is applied. + */ + setLocale(locale: string): Promise; +}; + +/** @internal Reactive state scoped to one installed plugin instance. */ +export type GTState = { + defaultLocale: string; + getCatalog(): TranslationCatalog; + loadTranslations(locale: string): Promise; + locale: Ref; + revision: Ref; + setLocale(locale: string): Promise; +}; From aee723f9f85a0b19789064813b7475db1e8b0651 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 18:21:36 -0700 Subject: [PATCH 06/28] docs(vue): mark package as unstable --- packages/vue/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vue/README.md b/packages/vue/README.md index 2ca42ff260..3b773ea0fc 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -15,6 +15,10 @@ A lightweight General Translation runtime for Vue 3. +> [!WARNING] +> `gt-vue` is currently unstable. Its API and behavior may change between +> releases while the package is under active development. + ## Installation ```bash From 9e745b3a28aff0ea773647a09c4b728d3a24d362 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 18:26:55 -0700 Subject: [PATCH 07/28] fix(vue): deduplicate branch names --- packages/vue/__tests__/runtime.test.ts | 14 ++++++++++++++ packages/vue/components/utils.ts | 10 ++++++---- packages/vue/rendering/translateVueChildren.ts | 9 +++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/vue/__tests__/runtime.test.ts b/packages/vue/__tests__/runtime.test.ts index 47f0a856c2..a90095e034 100644 --- a/packages/vue/__tests__/runtime.test.ts +++ b/packages/vue/__tests__/runtime.test.ts @@ -11,8 +11,10 @@ import { ref, vShow, withDirectives, + type Slots, } from 'vue'; import { renderToString } from 'vue/server-renderer'; +import { getBranchNames } from '../components/utils'; import { Branch, Currency, @@ -30,6 +32,18 @@ import { import type { TranslationCatalog } from '../src'; describe('gt-vue runtime', () => { + it('deduplicates branch names shared by attrs and slots', () => { + const slots = { + default: () => [], + one: () => [], + other: () => [], + } as Slots; + + expect( + getBranchNames({ one: 'attribute', 'data-note': 'ignored' }, slots) + ).toEqual(['one', 'other']); + }); + it('loads and caches plain STRING translations, then rerenders on locale changes', async () => { const source = 'Hello, {name}!'; const encoded = msg('Navigation: home', { $context: 'navigation' }); diff --git a/packages/vue/components/utils.ts b/packages/vue/components/utils.ts index 4f01388b40..c097605187 100644 --- a/packages/vue/components/utils.ts +++ b/packages/vue/components/utils.ts @@ -50,10 +50,12 @@ export function getBranchNames( slots: Slots ): string[] { return [ - ...Object.keys(attrs).filter((key) => !key.startsWith('data-')), - ...Object.keys(slots).filter( - (key) => key !== 'default' && !key.startsWith('_') - ), + ...new Set([ + ...Object.keys(attrs).filter((key) => !key.startsWith('data-')), + ...Object.keys(slots).filter( + (key) => key !== 'default' && !key.startsWith('_') + ), + ]), ]; } diff --git a/packages/vue/rendering/translateVueChildren.ts b/packages/vue/rendering/translateVueChildren.ts index 1396013ccb..295d2e560a 100644 --- a/packages/vue/rendering/translateVueChildren.ts +++ b/packages/vue/rendering/translateVueChildren.ts @@ -509,10 +509,11 @@ function cloneWithChildren( : vnode.props; const cloned = h(vnode.type, props, children ?? undefined); - // A Vue clone retains the source VNode's child shape flags. Rich - // translations can change an element from array children to scalar text, - // so create a fresh element with normalized children and copy only the - // render metadata that must survive reconstruction. + // cloneVNode retains the source VNode's child shape flags and its public + // API cannot replace and renormalize children. Rich translations can + // change an element from array children to scalar text, so create a fresh + // element with normalized children and copy only the render metadata that + // must survive reconstruction. cloned.appContext = vnode.appContext; (cloned as VNodeWithRenderMetadata).ctx = ( vnode as VNodeWithRenderMetadata From b6d9e6a02f402a7f1257964dbecf6a7179f99073 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 30 Jul 2026 18:27:14 -0700 Subject: [PATCH 08/28] fix(vue): use public vnode prop cloning --- packages/vue/rendering/translateVueChildren.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/vue/rendering/translateVueChildren.ts b/packages/vue/rendering/translateVueChildren.ts index 295d2e560a..71553f89e2 100644 --- a/packages/vue/rendering/translateVueChildren.ts +++ b/packages/vue/rendering/translateVueChildren.ts @@ -17,6 +17,7 @@ import { Comment, Fragment, Text, + cloneVNode, h, isVNode, mergeProps, @@ -528,9 +529,11 @@ function cloneWithChildren( return cloned; } - // Components need their original slot set and identity. Passing a VNode to - // h() uses Vue's clone path even though its public overloads do not expose - // that runtime-supported form. + // Components need their original slot set and identity. cloneVNode cannot + // safely replace the default slot because it retains optimized block + // metadata that can suppress later slot updates. Passing a VNode to h() + // takes Vue's clone-and-renormalize path, although its public overloads do + // not expose that runtime-supported form. const type = vnode as unknown as Component; const props = Object.keys(extraProps).length ? extraProps : null; const slots = isSlots(vnode.children) ? vnode.children : {}; @@ -544,7 +547,7 @@ function cloneWithProps( vnode: VNode, extraProps: Record ): VNode { - return h(vnode as unknown as Component, extraProps); + return cloneVNode(vnode, extraProps); } function isVariable(value: JsxElement | Variable): value is Variable { From 18f0f283ff23c99f4f405ff40db6d0ef095bf331 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 10:45:46 -0700 Subject: [PATCH 09/28] refactor(vue): keep package sources under src --- packages/vue/__tests__/package-layout.test.ts | 31 ------------ .../vue/src/__tests__/package-layout.test.ts | 48 +++++++++++++++++++ .../vue/{ => src}/__tests__/runtime.test.ts | 4 +- packages/vue/{ => src}/components/T.ts | 0 packages/vue/{ => src}/components/branches.ts | 0 packages/vue/{ => src}/components/utils.ts | 0 .../vue/{ => src}/components/variables.ts | 0 packages/vue/{ => src}/composables/locale.ts | 0 packages/vue/{ => src}/composables/strings.ts | 0 packages/vue/src/index.ts | 16 +++---- packages/vue/{ => src}/messages/msg.ts | 0 .../vue/{ => src}/messages/translation.ts | 0 .../rendering/translateVueChildren.ts | 0 packages/vue/{ => src}/runtime/state.ts | 0 packages/vue/{ => src}/types/index.ts | 0 packages/vue/tsconfig.json | 12 +---- 16 files changed, 60 insertions(+), 51 deletions(-) delete mode 100644 packages/vue/__tests__/package-layout.test.ts create mode 100644 packages/vue/src/__tests__/package-layout.test.ts rename packages/vue/{ => src}/__tests__/runtime.test.ts (99%) rename packages/vue/{ => src}/components/T.ts (100%) rename packages/vue/{ => src}/components/branches.ts (100%) rename packages/vue/{ => src}/components/utils.ts (100%) rename packages/vue/{ => src}/components/variables.ts (100%) rename packages/vue/{ => src}/composables/locale.ts (100%) rename packages/vue/{ => src}/composables/strings.ts (100%) rename packages/vue/{ => src}/messages/msg.ts (100%) rename packages/vue/{ => src}/messages/translation.ts (100%) rename packages/vue/{ => src}/rendering/translateVueChildren.ts (100%) rename packages/vue/{ => src}/runtime/state.ts (100%) rename packages/vue/{ => src}/types/index.ts (100%) diff --git a/packages/vue/__tests__/package-layout.test.ts b/packages/vue/__tests__/package-layout.test.ts deleted file mode 100644 index 3953d60c41..0000000000 --- a/packages/vue/__tests__/package-layout.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { readdirSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import * as gtVue from '../src'; - -describe('gt-vue package layout', () => { - it('reserves src for package entry points', () => { - const src = fileURLToPath(new URL('../src', import.meta.url)); - expect(readdirSync(src).sort()).toEqual(['index.ts']); - }); - - it('exports the complete runtime API from the root entry point', () => { - expect(Object.keys(gtVue).sort()).toEqual( - [ - 'Branch', - 'Currency', - 'DateTime', - 'Num', - 'Plural', - 'T', - 'Var', - 'createGT', - 'msg', - 'useGT', - 'useLocale', - 'useMessages', - 'useSetLocale', - ].sort() - ); - }); -}); diff --git a/packages/vue/src/__tests__/package-layout.test.ts b/packages/vue/src/__tests__/package-layout.test.ts new file mode 100644 index 0000000000..b9680e7aa4 --- /dev/null +++ b/packages/vue/src/__tests__/package-layout.test.ts @@ -0,0 +1,48 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import * as gtVue from '../index'; + +const sourceDirectories = [ + '__tests__', + 'components', + 'composables', + 'messages', + 'rendering', + 'runtime', + 'types', +]; + +describe('gt-vue package layout', () => { + it('keeps every source directory under src', () => { + const src = fileURLToPath(new URL('..', import.meta.url)); + expect(readdirSync(src).sort()).toEqual( + [...sourceDirectories, 'index.ts'].sort() + ); + expect( + sourceDirectories.filter((directory) => + existsSync(new URL(`../../${directory}`, import.meta.url)) + ) + ).toEqual([]); + }); + + it('exports the complete runtime API from the root entry point', () => { + expect(Object.keys(gtVue).sort()).toEqual( + [ + 'Branch', + 'Currency', + 'DateTime', + 'Num', + 'Plural', + 'T', + 'Var', + 'createGT', + 'msg', + 'useGT', + 'useLocale', + 'useMessages', + 'useSetLocale', + ].sort() + ); + }); +}); diff --git a/packages/vue/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts similarity index 99% rename from packages/vue/__tests__/runtime.test.ts rename to packages/vue/src/__tests__/runtime.test.ts index a90095e034..bdf9db4d91 100644 --- a/packages/vue/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -28,8 +28,8 @@ import { useGT, useLocale, useMessages, -} from '../src'; -import type { TranslationCatalog } from '../src'; +} from '../index'; +import type { TranslationCatalog } from '../index'; describe('gt-vue runtime', () => { it('deduplicates branch names shared by attrs and slots', () => { diff --git a/packages/vue/components/T.ts b/packages/vue/src/components/T.ts similarity index 100% rename from packages/vue/components/T.ts rename to packages/vue/src/components/T.ts diff --git a/packages/vue/components/branches.ts b/packages/vue/src/components/branches.ts similarity index 100% rename from packages/vue/components/branches.ts rename to packages/vue/src/components/branches.ts diff --git a/packages/vue/components/utils.ts b/packages/vue/src/components/utils.ts similarity index 100% rename from packages/vue/components/utils.ts rename to packages/vue/src/components/utils.ts diff --git a/packages/vue/components/variables.ts b/packages/vue/src/components/variables.ts similarity index 100% rename from packages/vue/components/variables.ts rename to packages/vue/src/components/variables.ts diff --git a/packages/vue/composables/locale.ts b/packages/vue/src/composables/locale.ts similarity index 100% rename from packages/vue/composables/locale.ts rename to packages/vue/src/composables/locale.ts diff --git a/packages/vue/composables/strings.ts b/packages/vue/src/composables/strings.ts similarity index 100% rename from packages/vue/composables/strings.ts rename to packages/vue/src/composables/strings.ts diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 550fa50cea..e70a42346a 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,10 +1,10 @@ -export { Branch, Plural } from '../components/branches'; -export { T } from '../components/T'; -export { Currency, DateTime, Num, Var } from '../components/variables'; -export { useLocale, useSetLocale } from '../composables/locale'; -export { useGT, useMessages } from '../composables/strings'; -export { msg } from '../messages/msg'; -export { createGT } from '../runtime/state'; +export { Branch, Plural } from './components/branches'; +export { T } from './components/T'; +export { Currency, DateTime, Num, Var } from './components/variables'; +export { useLocale, useSetLocale } from './composables/locale'; +export { useGT, useMessages } from './composables/strings'; +export { msg } from './messages/msg'; +export { createGT } from './runtime/state'; export type { CreateGTOptions, GTFunction, @@ -13,4 +13,4 @@ export type { LoadTranslations, MessagesFunction, TranslationCatalog, -} from '../types'; +} from './types'; diff --git a/packages/vue/messages/msg.ts b/packages/vue/src/messages/msg.ts similarity index 100% rename from packages/vue/messages/msg.ts rename to packages/vue/src/messages/msg.ts diff --git a/packages/vue/messages/translation.ts b/packages/vue/src/messages/translation.ts similarity index 100% rename from packages/vue/messages/translation.ts rename to packages/vue/src/messages/translation.ts diff --git a/packages/vue/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts similarity index 100% rename from packages/vue/rendering/translateVueChildren.ts rename to packages/vue/src/rendering/translateVueChildren.ts diff --git a/packages/vue/runtime/state.ts b/packages/vue/src/runtime/state.ts similarity index 100% rename from packages/vue/runtime/state.ts rename to packages/vue/src/runtime/state.ts diff --git a/packages/vue/types/index.ts b/packages/vue/src/types/index.ts similarity index 100% rename from packages/vue/types/index.ts rename to packages/vue/src/types/index.ts diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 2eccbf61ff..98f6ecd295 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -7,18 +7,10 @@ "sourceMap": true, "declarationMap": true, "outDir": "./dist", - "rootDir": ".", + "rootDir": "./src", "composite": true }, - "include": [ - "components/**/*", - "composables/**/*", - "messages/**/*", - "rendering/**/*", - "runtime/**/*", - "src/**/*", - "types/**/*" - ], + "include": ["src/**/*"], "exclude": ["**/*.test.ts"], "references": [ { From 9d478b191682c5ee110fe438c594c5b2cd97f500 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 11:17:02 -0700 Subject: [PATCH 10/28] test(vue): cover translated component props --- packages/vue/src/__tests__/runtime.test.ts | 75 +++++++++++++++++++ .../vue/src/rendering/translateVueChildren.ts | 5 +- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index bdf9db4d91..db9d174437 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -172,6 +172,81 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('preserves component props while replacing translated slot children', async () => { + const onNavigate = vi.fn(); + const Link = defineComponent({ + emits: ['navigate'], + name: 'TestLink', + props: { + title: { type: String, required: true }, + to: { type: String, required: true }, + }, + setup(props, { emit, slots }) { + return () => + h( + 'a', + { + href: props.to, + onClick: () => emit('navigate'), + title: props.title, + }, + slots.default?.() + ); + }, + }); + const plugin = createGT({ + loadTranslations: async () => ({ + link: { + t: 'TestLink', + i: 1, + d: { ti: 'Titre traduit' }, + c: 'Lien traduit', + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'link' }, + { + default: () => + h( + Link, + { + class: 'source-link', + id: 'docs-link', + onNavigate, + title: 'Source title', + to: '/docs', + }, + { default: () => 'Source link' } + ), + } + ); + }, + }); + + const mounted = mount(Root, plugin); + await nextTick(); + + const anchor = findElement(mounted.root, 'a'); + expect(anchor?.props).toMatchObject({ + class: 'source-link', + href: '/docs', + id: 'docs-link', + title: 'Titre traduit', + }); + expect(textContent(mounted.root)).toBe('Lien traduit'); + const onClick = anchor?.props.onClick; + expect(onClick).toBeTypeOf('function'); + (onClick as () => void)(); + expect(onNavigate).toHaveBeenCalledTimes(1); + mounted.app.unmount(); + }); + it('updates multi-root rich children from arrays to scalar text on the client', async () => { const source: JsxChildren = [ { diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 71553f89e2..77745b4806 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -532,8 +532,9 @@ function cloneWithChildren( // Components need their original slot set and identity. cloneVNode cannot // safely replace the default slot because it retains optimized block // metadata that can suppress later slot updates. Passing a VNode to h() - // takes Vue's clone-and-renormalize path, although its public overloads do - // not expose that runtime-supported form. + // takes Vue's clone-and-renormalize path, preserving the source props and + // merging only these translated props. Its public overloads do not expose + // that runtime-supported form. const type = vnode as unknown as Component; const props = Object.keys(extraProps).length ? extraProps : null; const slots = isSlots(vnode.children) ? vnode.children : {}; From 5a4359b350211fea78d8d7339d4a96060cfe68e0 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 11:17:10 -0700 Subject: [PATCH 11/28] refactor(i18n): share string message helpers --- .changeset/tidy-vue-messages.md | 6 +++ packages/i18n/package.json | 13 ++++++ packages/i18n/src/internal-string.ts | 3 ++ .../msg/__tests__/msg.test.ts | 15 +++++++ .../translation-functions/msg/encodeMsg.ts | 10 +++++ .../i18n/src/translation-functions/msg/msg.ts | 15 +++---- .../translation-functions/msg/msgString.ts | 41 +++++++++++++++++++ .../isEncodedTranslationOptions.test.ts | 16 ++++++++ .../utils/isEncodedTranslationOptions.ts | 5 ++- packages/i18n/src/utils/hashMessage.ts | 4 ++ packages/i18n/src/utils/hashStringMessage.ts | 20 +++++++++ packages/i18n/tsdown.config.mts | 1 + packages/vue/package.json | 1 + packages/vue/src/__tests__/runtime.test.ts | 7 +++- packages/vue/src/composables/strings.ts | 13 +++--- packages/vue/src/messages/msg.ts | 22 +--------- packages/vue/src/messages/translation.ts | 35 ---------------- packages/vue/tsdown.config.mts | 1 + pnpm-lock.yaml | 3 ++ 19 files changed, 157 insertions(+), 74 deletions(-) create mode 100644 .changeset/tidy-vue-messages.md create mode 100644 packages/i18n/src/internal-string.ts create mode 100644 packages/i18n/src/translation-functions/msg/encodeMsg.ts create mode 100644 packages/i18n/src/translation-functions/msg/msgString.ts create mode 100644 packages/i18n/src/translation-functions/utils/__tests__/isEncodedTranslationOptions.test.ts create mode 100644 packages/i18n/src/utils/hashStringMessage.ts diff --git a/.changeset/tidy-vue-messages.md b/.changeset/tidy-vue-messages.md new file mode 100644 index 0000000000..f721da9c0f --- /dev/null +++ b/.changeset/tidy-vue-messages.md @@ -0,0 +1,6 @@ +--- +'gt-i18n': patch +--- + +Add lightweight shared helpers for registering and decoding literal `STRING` +messages, and validate encoded message fields by type. diff --git a/packages/i18n/package.json b/packages/i18n/package.json index abefc3dd76..9a9ba17daf 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -86,6 +86,16 @@ "default": "./dist/internal.mjs" } }, + "./internal/string": { + "require": { + "types": "./dist/internal-string.d.cts", + "default": "./dist/internal-string.cjs" + }, + "import": { + "types": "./dist/internal-string.d.mts", + "default": "./dist/internal-string.mjs" + } + }, "./internal/types": { "require": { "types": "./dist/internal-types.d.cts", @@ -105,6 +115,9 @@ "internal": [ "./dist/internal.d.cts" ], + "internal/string": [ + "./dist/internal-string.d.cts" + ], "internal/types": [ "./dist/internal-types.d.cts" ] diff --git a/packages/i18n/src/internal-string.ts b/packages/i18n/src/internal-string.ts new file mode 100644 index 0000000000..5225d1c600 --- /dev/null +++ b/packages/i18n/src/internal-string.ts @@ -0,0 +1,3 @@ +export { decodeOptions } from './translation-functions/msg/decodeOptions'; +export { msgString as msg } from './translation-functions/msg/msgString'; +export { isEncodedTranslationOptions } from './translation-functions/utils/isEncodedTranslationOptions'; diff --git a/packages/i18n/src/translation-functions/msg/__tests__/msg.test.ts b/packages/i18n/src/translation-functions/msg/__tests__/msg.test.ts index 3ab8b3cc60..83b74b0d1e 100644 --- a/packages/i18n/src/translation-functions/msg/__tests__/msg.test.ts +++ b/packages/i18n/src/translation-functions/msg/__tests__/msg.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { msg } from '../msg'; import { decodeMsg } from '../decodeMsg'; +import { decodeOptions } from '../decodeOptions'; import { derive, declareVar } from 'generaltranslation/internal'; import type { RegisterableMessages } from '../../types/message'; @@ -17,6 +18,20 @@ describe('msg function integration', () => { expect(decoded).toBe('Hello World'); }); + it('preserves literal braces for STRING messages', () => { + const result = msg('Hello {name}', { + $context: 'literal example', + $format: 'STRING', + }); + + expect(decodeMsg(result)).toBe('Hello {name}'); + expect(decodeOptions(result)).toMatchObject({ + $context: 'literal example', + $format: 'STRING', + $_source: 'Hello {name}', + }); + }); + it('should not format variables in quoted text', () => { const result = msg("'Hello {name}'"); const decoded = decodeMsg(result); diff --git a/packages/i18n/src/translation-functions/msg/encodeMsg.ts b/packages/i18n/src/translation-functions/msg/encodeMsg.ts new file mode 100644 index 0000000000..ae28db8ccb --- /dev/null +++ b/packages/i18n/src/translation-functions/msg/encodeMsg.ts @@ -0,0 +1,10 @@ +import { encode } from 'generaltranslation/internal'; +import type { EncodedTranslationOptions } from '../types/options'; + +/** Encodes message metadata for later resolution by an `m` function. */ +export function encodeMsg( + message: string, + options: EncodedTranslationOptions +): string { + return `${message}:${encode(JSON.stringify(options))}`; +} diff --git a/packages/i18n/src/translation-functions/msg/msg.ts b/packages/i18n/src/translation-functions/msg/msg.ts index 94964ea0ef..b8c6a5a50e 100644 --- a/packages/i18n/src/translation-functions/msg/msg.ts +++ b/packages/i18n/src/translation-functions/msg/msg.ts @@ -4,7 +4,6 @@ import type { } from '../types/options'; import { formatMessage } from '@generaltranslation/format'; import { - encode, libraryDefaultLocale, VAR_IDENTIFIER, } from 'generaltranslation/internal'; @@ -13,6 +12,8 @@ import logger from '../../logs/logger'; import { extractVariables } from '../../utils/extractVariables'; import { hashMessage } from '../../utils/hashMessage'; import { RegisterableMessages } from '../types/message'; +import { encodeMsg } from './encodeMsg'; +import { msgString } from './msgString'; /** * Registers a message to be translated. Returns the message unchanged if no options are provided. @@ -57,6 +58,8 @@ export function msg( message: RegisterableMessages, options?: GTTranslationOptions ): RegisterableMessages { + if (options?.$format === 'STRING') return msgString(message, options); + // Handle array if (typeof message !== 'string') { if (!options) return message; @@ -73,11 +76,8 @@ export function msg( return message; } - // Extract variables const variables = extractVariables(options); - - // Interpolate string - let interpolatedString: string = message; + let interpolatedString: string; try { interpolatedString = formatMessage(message, { locales: [libraryDefaultLocale], // TODO: use compiler to insert locales @@ -105,8 +105,5 @@ export function msg( $_source, $_hash, }; - const optionsEncoding = encode(JSON.stringify(encodedOptions)); - - // Construct result - return `${interpolatedString}:${optionsEncoding}`; + return encodeMsg(interpolatedString, encodedOptions); } diff --git a/packages/i18n/src/translation-functions/msg/msgString.ts b/packages/i18n/src/translation-functions/msg/msgString.ts new file mode 100644 index 0000000000..9b849203a0 --- /dev/null +++ b/packages/i18n/src/translation-functions/msg/msgString.ts @@ -0,0 +1,41 @@ +import type { GTTranslationOptions } from '../types/options'; +import type { RegisterableMessages } from '../types/message'; +import { hashStringMessage } from '../../utils/hashStringMessage'; +import { encodeMsg } from './encodeMsg'; + +/** + * Registers one or more literal STRING messages without applying ICU + * interpolation. + * + * Framework integrations should narrow the options they expose in their + * public API. + */ +export function msgString( + message: T +): T; +export function msgString( + message: T, + options?: GTTranslationOptions +): T extends string ? string : string[]; +export function msgString( + message: RegisterableMessages, + options?: GTTranslationOptions +): RegisterableMessages { + if (typeof message !== 'string') { + if (!options) return message; + return message.map((entry, index) => + msgString(entry, { + ...options, + ...(options.$id && { $id: `${options.$id}.${index}` }), + }) + ); + } + if (!options) return message; + + const stringOptions = { ...options, $format: 'STRING' as const }; + return encodeMsg(message, { + ...stringOptions, + $_hash: hashStringMessage(message, stringOptions), + $_source: message, + }); +} diff --git a/packages/i18n/src/translation-functions/utils/__tests__/isEncodedTranslationOptions.test.ts b/packages/i18n/src/translation-functions/utils/__tests__/isEncodedTranslationOptions.test.ts new file mode 100644 index 0000000000..23dab2fda3 --- /dev/null +++ b/packages/i18n/src/translation-functions/utils/__tests__/isEncodedTranslationOptions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isEncodedTranslationOptions } from '../isEncodedTranslationOptions'; + +describe('isEncodedTranslationOptions', () => { + it('accepts encoded empty source strings', () => { + expect(isEncodedTranslationOptions({ $_hash: 'hash', $_source: '' })).toBe( + true + ); + }); + + it('rejects non-string encoded fields', () => { + expect(isEncodedTranslationOptions({ $_hash: {}, $_source: {} })).toBe( + false + ); + }); +}); diff --git a/packages/i18n/src/translation-functions/utils/isEncodedTranslationOptions.ts b/packages/i18n/src/translation-functions/utils/isEncodedTranslationOptions.ts index 0bf46c55ea..d30be98ba7 100644 --- a/packages/i18n/src/translation-functions/utils/isEncodedTranslationOptions.ts +++ b/packages/i18n/src/translation-functions/utils/isEncodedTranslationOptions.ts @@ -7,5 +7,8 @@ import { EncodedTranslationOptions } from '../types/options'; export function isEncodedTranslationOptions( decodedOptions: Record // TODO: next major version, this should be Record ): decodedOptions is EncodedTranslationOptions { - return !!(decodedOptions.$_hash && decodedOptions.$_source); + return ( + typeof decodedOptions.$_hash === 'string' && + typeof decodedOptions.$_source === 'string' + ); } diff --git a/packages/i18n/src/utils/hashMessage.ts b/packages/i18n/src/utils/hashMessage.ts index 361787e00b..d8c303aaf1 100644 --- a/packages/i18n/src/utils/hashMessage.ts +++ b/packages/i18n/src/utils/hashMessage.ts @@ -3,6 +3,7 @@ import { indexVars } from 'generaltranslation/internal'; import { LookupOptions } from '../translation-functions/types/options'; import { Translation } from '../types'; import type { IcuMessage } from '@generaltranslation/format/types'; +import { hashStringMessage } from './hashStringMessage'; /** * Hash a message string @@ -21,6 +22,9 @@ export function hashMessage( if (metadataOptions.$_hash != null) { return metadataOptions.$_hash; } + if (options.$format === 'STRING') { + return hashStringMessage(message as string, metadataOptions); + } return hashSource({ source: diff --git a/packages/i18n/src/utils/hashStringMessage.ts b/packages/i18n/src/utils/hashStringMessage.ts new file mode 100644 index 0000000000..0f127f94d6 --- /dev/null +++ b/packages/i18n/src/utils/hashStringMessage.ts @@ -0,0 +1,20 @@ +import { hashSource } from 'generaltranslation/id'; +import type { GTTranslationOptions } from '../translation-functions/types/options'; + +/** Hashes a literal STRING message and its supported lookup metadata. */ +export function hashStringMessage( + message: string, + options: GTTranslationOptions = {} +): string { + if (options.$_hash != null) return options.$_hash; + + return hashSource({ + source: message, + ...(options.$context && { context: options.$context }), + ...(options.$maxChars != null && { + maxChars: Math.abs(options.$maxChars), + }), + ...(options.$requiresReview === true && { requiresReview: true }), + dataFormat: 'STRING', + }); +} diff --git a/packages/i18n/tsdown.config.mts b/packages/i18n/tsdown.config.mts index ee0536ed33..f806040edc 100644 --- a/packages/i18n/tsdown.config.mts +++ b/packages/i18n/tsdown.config.mts @@ -4,6 +4,7 @@ import { createTsdownConfig } from '../../tsdown.preset.mts'; export default defineConfig( createTsdownConfig([ 'src/index.ts', + 'src/internal-string.ts', 'src/types.ts', 'src/internal.ts', 'src/internal-types.ts', diff --git a/packages/vue/package.json b/packages/vue/package.json index 842358df21..13e9b2aca5 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -37,6 +37,7 @@ "homepage": "https://generaltranslation.com/", "devDependencies": { "@types/node": "catalog:", + "gt-i18n": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index db9d174437..287c8fc3e2 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -95,6 +95,7 @@ describe('gt-vue runtime', () => { it('keeps msg and useMessages context-only and never interpolates', async () => { const contextual = msg('Literal {name}: 你好', { $context: 'example' }); + const empty = msg('', { $context: 'empty' }); const messages: string[] = msg(['First', 'Second'] as const, { $context: 'list', }); @@ -104,6 +105,7 @@ describe('gt-vue runtime', () => { 'Littéral {name} : 你好', [stringHash('First', 'list')]: 'Premier', [stringHash('Second', 'list')]: 'Deuxième', + [stringHash('', 'empty')]: 'Vide', }), }); await plugin.setLocale('fr'); @@ -115,12 +117,13 @@ describe('gt-vue runtime', () => { const m = useMessages(); nullResult = m(null); undefinedResult = m(undefined); - return () => h('p', [m(contextual), '|', ...messages.map(m)]); + return () => + h('p', [m(contextual), '|', ...messages.map(m), '|', m(empty)]); }, }); const html = await renderWithPlugin(Root, plugin); - expect(html).toContain('Littéral {name} : 你好|PremierDeuxième'); + expect(html).toContain('Littéral {name} : 你好|PremierDeuxième|Vide'); expect(nullResult!).toBeNull(); expect(undefinedResult!).toBeUndefined(); }); diff --git a/packages/vue/src/composables/strings.ts b/packages/vue/src/composables/strings.ts index 8467e61af3..1588e5a826 100644 --- a/packages/vue/src/composables/strings.ts +++ b/packages/vue/src/composables/strings.ts @@ -1,5 +1,8 @@ import { - decodeMessageOptions, + decodeOptions, + isEncodedTranslationOptions, +} from 'gt-i18n/internal/string'; +import { translateString, type InternalStringOptions, } from '../messages/translation'; @@ -43,12 +46,8 @@ export function useMessages(): MessagesFunction { ): T extends string ? string : T => { if (message == null) return message as T extends string ? string : T; - const decoded = decodeMessageOptions(message); - if ( - decoded && - typeof decoded.$_source === 'string' && - typeof decoded.$_hash === 'string' - ) { + const decoded = decodeOptions(message); + if (decoded && isEncodedTranslationOptions(decoded)) { return translateString(state, decoded.$_source, { $context: decoded.$context, $_hash: decoded.$_hash, diff --git a/packages/vue/src/messages/msg.ts b/packages/vue/src/messages/msg.ts index 8ca22fac22..68ca55a571 100644 --- a/packages/vue/src/messages/msg.ts +++ b/packages/vue/src/messages/msg.ts @@ -1,5 +1,4 @@ -import { hashSource } from 'generaltranslation/id'; -import { encode } from 'generaltranslation/internal'; +import { msg as registerMessage } from 'gt-i18n/internal/string'; import type { GTStringOptions } from '../types'; /** @@ -32,22 +31,5 @@ export function msg( message: string | readonly string[], options?: GTStringOptions ): string | readonly string[] { - if (typeof message !== 'string') { - return message.map((entry) => msg(entry, options)); - } - if (!options) return message; - - const $_hash = hashSource({ - context: options.$context, - dataFormat: 'STRING', - source: message, - }); - const encoded = encode( - JSON.stringify({ - ...options, - $_hash, - $_source: message, - }) - ); - return `${message}:${encoded}`; + return registerMessage(message, options); } diff --git a/packages/vue/src/messages/translation.ts b/packages/vue/src/messages/translation.ts index 3ff11b6f7c..687311834a 100644 --- a/packages/vue/src/messages/translation.ts +++ b/packages/vue/src/messages/translation.ts @@ -1,5 +1,4 @@ import { hashSource } from 'generaltranslation/id'; -import { decode } from 'generaltranslation/internal'; import type { GTState, GTStringOptions } from '../types'; /** @internal */ @@ -24,37 +23,3 @@ export function translateString( const translation = state.getCatalog()[hash]; return typeof translation === 'string' ? translation : message; } - -/** @internal */ -export function decodeMessageOptions(message: string): - | { - $context?: string; - $_hash: string; - $_source: string; - } - | undefined { - const separator = message.lastIndexOf(':'); - if (separator < 0) return undefined; - - try { - const options = JSON.parse(decode(message.slice(separator + 1))) as Record< - string, - unknown - >; - if ( - typeof options.$_hash === 'string' && - typeof options.$_source === 'string' - ) { - return { - ...(typeof options.$context === 'string' && { - $context: options.$context, - }), - $_hash: options.$_hash, - $_source: options.$_source, - }; - } - } catch { - // A normal string may contain a colon. It is not an encoded message. - } - return undefined; -} diff --git a/packages/vue/tsdown.config.mts b/packages/vue/tsdown.config.mts index 599c8dd15f..fd42ecb868 100644 --- a/packages/vue/tsdown.config.mts +++ b/packages/vue/tsdown.config.mts @@ -8,6 +8,7 @@ const deps = { /^generaltranslation$/, /^generaltranslation\//, ], + alwaysBundle: [/^gt-i18n\/internal\/string$/], }; const configs = createTsdownConfig(['src/index.ts'], deps).map((config) => ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa0a0c71e1..da34ac6e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1643,6 +1643,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.13.10 + gt-i18n: + specifier: workspace:* + version: link:../i18n tsdown: specifier: 'catalog:' version: 0.21.10(synckit@0.11.11)(typescript@5.9.3) From eb92fd173060681b6b69c5c471ecb14c1b09a0ee Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 13:22:04 -0700 Subject: [PATCH 12/28] refactor(i18n): call hashSource directly for string messages --- .../translation-functions/msg/msgString.ts | 13 ++++++++++-- packages/i18n/src/utils/hashMessage.ts | 4 ---- packages/i18n/src/utils/hashStringMessage.ts | 20 ------------------- 3 files changed, 11 insertions(+), 26 deletions(-) delete mode 100644 packages/i18n/src/utils/hashStringMessage.ts diff --git a/packages/i18n/src/translation-functions/msg/msgString.ts b/packages/i18n/src/translation-functions/msg/msgString.ts index 9b849203a0..9b3713fd51 100644 --- a/packages/i18n/src/translation-functions/msg/msgString.ts +++ b/packages/i18n/src/translation-functions/msg/msgString.ts @@ -1,6 +1,6 @@ import type { GTTranslationOptions } from '../types/options'; import type { RegisterableMessages } from '../types/message'; -import { hashStringMessage } from '../../utils/hashStringMessage'; +import { hashSource } from 'generaltranslation/id'; import { encodeMsg } from './encodeMsg'; /** @@ -33,9 +33,18 @@ export function msgString( if (!options) return message; const stringOptions = { ...options, $format: 'STRING' as const }; + const $_hash = + stringOptions.$_hash ?? + hashSource({ + source: message, + context: stringOptions.$context, + maxChars: stringOptions.$maxChars, + requiresReview: stringOptions.$requiresReview, + dataFormat: 'STRING', + }); return encodeMsg(message, { ...stringOptions, - $_hash: hashStringMessage(message, stringOptions), + $_hash, $_source: message, }); } diff --git a/packages/i18n/src/utils/hashMessage.ts b/packages/i18n/src/utils/hashMessage.ts index d8c303aaf1..361787e00b 100644 --- a/packages/i18n/src/utils/hashMessage.ts +++ b/packages/i18n/src/utils/hashMessage.ts @@ -3,7 +3,6 @@ import { indexVars } from 'generaltranslation/internal'; import { LookupOptions } from '../translation-functions/types/options'; import { Translation } from '../types'; import type { IcuMessage } from '@generaltranslation/format/types'; -import { hashStringMessage } from './hashStringMessage'; /** * Hash a message string @@ -22,9 +21,6 @@ export function hashMessage( if (metadataOptions.$_hash != null) { return metadataOptions.$_hash; } - if (options.$format === 'STRING') { - return hashStringMessage(message as string, metadataOptions); - } return hashSource({ source: diff --git a/packages/i18n/src/utils/hashStringMessage.ts b/packages/i18n/src/utils/hashStringMessage.ts deleted file mode 100644 index 0f127f94d6..0000000000 --- a/packages/i18n/src/utils/hashStringMessage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { hashSource } from 'generaltranslation/id'; -import type { GTTranslationOptions } from '../translation-functions/types/options'; - -/** Hashes a literal STRING message and its supported lookup metadata. */ -export function hashStringMessage( - message: string, - options: GTTranslationOptions = {} -): string { - if (options.$_hash != null) return options.$_hash; - - return hashSource({ - source: message, - ...(options.$context && { context: options.$context }), - ...(options.$maxChars != null && { - maxChars: Math.abs(options.$maxChars), - }), - ...(options.$requiresReview === true && { requiresReview: true }), - dataFormat: 'STRING', - }); -} From 76380b5f23b8e48eaf2c9e1409b4cb4c6b8bd7ec Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 19:11:28 -0700 Subject: [PATCH 13/28] refactor(i18n): share literal string hashing --- .changeset/tidy-vue-messages.md | 9 ++-- .size-limit.cjs | 1 + packages/i18n/src/internal-string.ts | 3 +- .../translation-functions/msg/msgString.ts | 12 +----- .../utils/__tests__/hashStringMessage.test.ts | 43 +++++++++++++++++++ packages/i18n/src/utils/hashStringMessage.ts | 28 ++++++++++++ 6 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 packages/i18n/src/utils/__tests__/hashStringMessage.test.ts create mode 100644 packages/i18n/src/utils/hashStringMessage.ts diff --git a/.changeset/tidy-vue-messages.md b/.changeset/tidy-vue-messages.md index f721da9c0f..912ca7342c 100644 --- a/.changeset/tidy-vue-messages.md +++ b/.changeset/tidy-vue-messages.md @@ -1,6 +1,9 @@ --- -'gt-i18n': patch +'gt-i18n': minor --- -Add lightweight shared helpers for registering and decoding literal `STRING` -messages, and validate encoded message fields by type. +Make `msg(..., { $format: 'STRING' })` preserve source text literally instead +of applying ICU interpolation. For example, `msg('Hello {name}', { +$format: 'STRING', name: 'Ada' })` now encodes `Hello {name}` rather than +`Hello Ada`. Add lightweight shared helpers for registering, hashing, and +decoding literal STRING messages, and validate encoded fields by type. diff --git a/.size-limit.cjs b/.size-limit.cjs index 57da074eda..7274d74b32 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -96,6 +96,7 @@ module.exports = [ i18n('gt-i18n', 'index'), i18n('gt-i18n/types', 'types'), i18n('gt-i18n/internal', 'internal'), + i18n('gt-i18n/internal/string', 'internal-string'), i18n('gt-i18n/internal/types', 'internal-types'), reactCore('@generaltranslation/react-core/pure', 'pure'), diff --git a/packages/i18n/src/internal-string.ts b/packages/i18n/src/internal-string.ts index 5225d1c600..63ca72d343 100644 --- a/packages/i18n/src/internal-string.ts +++ b/packages/i18n/src/internal-string.ts @@ -1,3 +1,4 @@ export { decodeOptions } from './translation-functions/msg/decodeOptions'; -export { msgString as msg } from './translation-functions/msg/msgString'; +export { msgString } from './translation-functions/msg/msgString'; export { isEncodedTranslationOptions } from './translation-functions/utils/isEncodedTranslationOptions'; +export { hashStringMessage } from './utils/hashStringMessage'; diff --git a/packages/i18n/src/translation-functions/msg/msgString.ts b/packages/i18n/src/translation-functions/msg/msgString.ts index 9b3713fd51..abe040c6a9 100644 --- a/packages/i18n/src/translation-functions/msg/msgString.ts +++ b/packages/i18n/src/translation-functions/msg/msgString.ts @@ -1,6 +1,6 @@ import type { GTTranslationOptions } from '../types/options'; import type { RegisterableMessages } from '../types/message'; -import { hashSource } from 'generaltranslation/id'; +import { hashStringMessage } from '../../utils/hashStringMessage'; import { encodeMsg } from './encodeMsg'; /** @@ -33,15 +33,7 @@ export function msgString( if (!options) return message; const stringOptions = { ...options, $format: 'STRING' as const }; - const $_hash = - stringOptions.$_hash ?? - hashSource({ - source: message, - context: stringOptions.$context, - maxChars: stringOptions.$maxChars, - requiresReview: stringOptions.$requiresReview, - dataFormat: 'STRING', - }); + const $_hash = hashStringMessage(message, stringOptions); return encodeMsg(message, { ...stringOptions, $_hash, diff --git a/packages/i18n/src/utils/__tests__/hashStringMessage.test.ts b/packages/i18n/src/utils/__tests__/hashStringMessage.test.ts new file mode 100644 index 0000000000..6fedec9a95 --- /dev/null +++ b/packages/i18n/src/utils/__tests__/hashStringMessage.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { hashStringMessage } from '../hashStringMessage'; +import { hashMessage } from '../hashMessage'; + +describe('hashStringMessage', () => { + it.each([ + { name: 'no metadata', options: {} }, + { name: 'context', options: { $context: 'navigation' } }, + { name: 'empty context', options: { $context: '' } }, + { name: 'negative max chars', options: { $maxChars: -12 } }, + { name: 'requires review', options: { $requiresReview: true } }, + { + name: 'all persisted metadata', + options: { + $context: 'button', + $maxChars: 20, + $requiresReview: true, + }, + }, + ] as const)('matches hashMessage for $name', ({ options }) => { + const message = 'Literal {name}: 你好'; + + expect(hashStringMessage(message, options)).toBe( + hashMessage(message, { ...options, $format: 'STRING' }) + ); + }); + + it('preserves an explicit hash, including an empty hash', () => { + expect(hashStringMessage('Hello', { $_hash: 'compiled' })).toBe('compiled'); + expect(hashStringMessage('Hello', { $_hash: '' })).toBe(''); + }); + + it('does not mix custom ids or interpolation variables into STRING hashes', () => { + const expected = hashStringMessage('Hello {name}'); + + expect( + hashStringMessage('Hello {name}', { + $id: 'custom', + name: 'Ada', + }) + ).toBe(expected); + }); +}); diff --git a/packages/i18n/src/utils/hashStringMessage.ts b/packages/i18n/src/utils/hashStringMessage.ts new file mode 100644 index 0000000000..aca1a5ff5b --- /dev/null +++ b/packages/i18n/src/utils/hashStringMessage.ts @@ -0,0 +1,28 @@ +import { hashSource } from 'generaltranslation/id'; +import type { GTTranslationOptions } from '../translation-functions/types/options'; + +/** + * Calculates the canonical lookup hash for a literal STRING message. + * + * Keeping STRING metadata normalization in this dependency-light helper lets + * framework runtimes share the same persisted catalog-key contract without + * importing the ICU message parser used by {@link hashMessage}. + * + * @param message - Literal source text. Braces are not interpreted. + * @param options - Optional precomputed hash and source metadata. + * @returns The precomputed hash when present, otherwise the canonical hash. + */ +export function hashStringMessage( + message: string, + options: GTTranslationOptions = {} +): string { + if (options.$_hash != null) return options.$_hash; + + return hashSource({ + context: options.$context, + dataFormat: 'STRING', + maxChars: options.$maxChars, + requiresReview: options.$requiresReview, + source: message, + }); +} From c755b2c853cea3feca50a4dc43b3b6a8e367928d Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 19:11:36 -0700 Subject: [PATCH 14/28] fix(vue): harden runtime release contracts --- .changeset/calm-pandas-translate.md | 4 +- .github/workflows/ci.yml | 50 ++- .../__tests__/richWireFormatParity.test.tsx | 82 +++++ packages/vue/README.md | 28 +- packages/vue/package.json | 10 +- packages/vue/src/__tests__/branches.test.ts | 286 ++++++++++++++++++ .../__tests__/richWireFormatParity.test.ts | 88 ++++++ packages/vue/src/__tests__/runtime.test.ts | 203 +++++++++++++ packages/vue/src/__tests__/state.test.ts | 161 ++++++++++ packages/vue/src/__tests__/strings.test.ts | 31 ++ packages/vue/src/__tests__/variables.test.ts | 96 ++++++ packages/vue/src/components/T.ts | 2 +- packages/vue/src/components/utils.ts | 64 +++- packages/vue/src/components/variables.ts | 73 +++-- packages/vue/src/messages/msg.ts | 2 +- packages/vue/src/messages/translation.ts | 10 +- .../vue/src/rendering/translateVueChildren.ts | 149 ++++++--- packages/vue/src/runtime/state.ts | 15 +- packages/vue/src/types/index.ts | 13 +- packages/vue/tsconfig.json | 3 + packages/vue/tsdown.config.mts | 6 +- pnpm-lock.yaml | 6 +- pnpm-workspace.yaml | 1 + test-fixtures/README.md | 10 + test-fixtures/rich-content-wire-format.json | 129 ++++++++ 25 files changed, 1427 insertions(+), 95 deletions(-) create mode 100644 packages/react-core/src/utils/internal/__tests__/richWireFormatParity.test.tsx create mode 100644 packages/vue/src/__tests__/branches.test.ts create mode 100644 packages/vue/src/__tests__/richWireFormatParity.test.ts create mode 100644 packages/vue/src/__tests__/state.test.ts create mode 100644 packages/vue/src/__tests__/strings.test.ts create mode 100644 packages/vue/src/__tests__/variables.test.ts create mode 100644 test-fixtures/README.md create mode 100644 test-fixtures/rich-content-wire-format.json diff --git a/.changeset/calm-pandas-translate.md b/.changeset/calm-pandas-translate.md index 7aae92d3a1..dca99a35ec 100644 --- a/.changeset/calm-pandas-translate.md +++ b/.changeset/calm-pandas-translate.md @@ -2,4 +2,6 @@ 'gt-vue': minor --- -Add a lightweight Vue 3 runtime with catalog-backed string and rich-content translation, reactive locale switching, and child-only formatting components. +Add a lightweight Vue 3 runtime with catalog-backed string and rich-content +translation, reactive locale switching, child-only variables, and typed value +props for number, currency, and date formatting. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eba82b302a..b58ff0db8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,53 @@ jobs: pnpm exec turbo build --filter=general-cases... --force pnpm exec turbo build:turbopack --filter=general-cases... --force + test-vue-versions: + strategy: + matrix: + vue: ['3.3.13', '3.5.40'] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if gt-vue changed + id: gt_vue_changed + shell: bash + run: | + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^packages/vue/"; then + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "changed=false" >> $GITHUB_OUTPUT + fi + + - uses: pnpm/action-setup@v4 + if: steps.gt_vue_changed.outputs.changed == 'true' + name: Install pnpm + + - name: Setup Node.js + if: steps.gt_vue_changed.outputs.changed == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + if: steps.gt_vue_changed.outputs.changed == 'true' + run: pnpm install + + - name: Use Vue ${{ matrix.vue }} + if: steps.gt_vue_changed.outputs.changed == 'true' + run: pnpm --filter gt-vue add --save-dev --save-exact --lockfile=false vue@${{ matrix.vue }} + + - name: Build and test gt-vue + if: steps.gt_vue_changed.outputs.changed == 'true' + run: | + pnpm exec turbo build --filter=gt-vue... + pnpm --filter gt-vue typecheck + pnpm --filter gt-vue test + test-cli-binaries: strategy: matrix: @@ -279,7 +326,8 @@ jobs: script: pnpm exec size-limit --json run-tests: - needs: [lint, tests, test-builds, test-cli-binaries, size] + needs: + [lint, tests, test-builds, test-vue-versions, test-cli-binaries, size] runs-on: ubuntu-latest steps: - run: echo "All tests passed" diff --git a/packages/react-core/src/utils/internal/__tests__/richWireFormatParity.test.tsx b/packages/react-core/src/utils/internal/__tests__/richWireFormatParity.test.tsx new file mode 100644 index 0000000000..2f114ea57a --- /dev/null +++ b/packages/react-core/src/utils/internal/__tests__/richWireFormatParity.test.tsx @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import type { ReactNode } from 'react'; +import type { JsxChildren } from '@generaltranslation/format/types'; +import { hashSource } from 'generaltranslation/id'; +import { describe, expect, it } from 'vitest'; +import { Branch } from '../../../components/branches/Branch'; +import { Plural } from '../../../components/branches/Plural'; +import { Num } from '../../../components/variables/Num'; +import { Var } from '../../../components/variables/Var'; +import { addGTIdentifier } from '../addGTIdentifier'; +import { writeChildrenAsObjects } from '../writeChildrenAsObjects'; + +type WireFormatFixture = { + description: string; + hash: string; + id: keyof typeof sources; + source: JsxChildren; +}; + +const sources = { + 'nested-element': [ + 'Hello ', + + wonderful world + , + '.', + ], + 'typed-variables': [ + 'Hello ', + Ada, + ', you have ', + 3, + ' messages.', + ], + 'independent-branch-numbering': [ + Hello, + ' ', + Ada, + ]} + casual={[Hi, ' ', Ada]} + > + Fallback + , + After, + ], + 'independent-plural-numbering': [ + 1
]} + other={['Many ', 2]} + > + Fallback + , + After, + ], +} satisfies Record; + +const fixtures = JSON.parse( + readFileSync( + new URL( + '../../../../../../test-fixtures/rich-content-wire-format.json', + import.meta.url + ), + 'utf8' + ) +) as WireFormatFixture[]; + +describe('shared rich-content wire format', () => { + it.each(fixtures)('$id: $description', (fixture) => { + const source = writeChildrenAsObjects(addGTIdentifier(sources[fixture.id])); + + expect(source).toEqual(fixture.source); + expect(hashSource({ dataFormat: 'JSX', source: fixture.source })).toBe( + fixture.hash + ); + }); +}); diff --git a/packages/vue/README.md b/packages/vue/README.md index 3b773ea0fc..25159e9dd9 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -17,7 +17,8 @@ A lightweight General Translation runtime for Vue 3. > [!WARNING] > `gt-vue` is currently unstable. Its API and behavior may change between -> releases while the package is under active development. +> releases while the package is under active development. Its 0.x releases +> are versioned independently from the stable React framework packages. ## Installation @@ -28,7 +29,9 @@ npm install gt-vue ## Quick Start Register one plugin instance with your Vue app. Translation files are loaded -once per locale and cached for the lifetime of that instance. +once per locale and cached for the lifetime of that instance. The +`defaultLocale` uses source text as its catalog, so `loadTranslations` is never +called for that locale. ```ts // main.ts @@ -49,7 +52,7 @@ createApp(App) .mount('#app'); ``` -Use `` for rich content. Runtime values are provided as slot children, not +Use `` for rich content. `` values are provided as slot children, not through `name` or `value` props. ```vue @@ -81,6 +84,12 @@ const setLocale = useSetLocale(); `$context`; braces are literal text and no ICU formatting or interpolation is applied. +Components whose slots read scoped props are treated as opaque when they are +placed inside ``. The component and its real runtime slot props are +preserved, but that scoped-slot content is not part of the surrounding rich +translation. To translate it, place `` inside the scoped slot and wrap +runtime values in ``. + ## Registered Messages `msg()` marks a string at module scope and `useMessages()` resolves it inside @@ -103,11 +112,20 @@ const m = useMessages(); - `` translates rich slot content. - `` preserves a dynamic slot value inside ``. -- ``, ``, and `` format their slot values for the - active locale. +- ``, ``, and `` accept typed runtime values through + `:value` and also format static slot text for the active locale. - `` selects named slots such as `#one` and `#other`. - `` selects an arbitrary named slot. +Use typed bindings for dynamic formatting values. Slot text is intended for +static literals. + +```vue + + + +``` + `setLocale()` loads a missing catalog, switches the reactive locale, and rerenders consumers. Locale persistence and development hot reload are outside this package; applications can persist their chosen locale separately. diff --git a/packages/vue/package.json b/packages/vue/package.json index 13e9b2aca5..78e536b64f 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -12,15 +12,20 @@ ], "sideEffects": false, "peerDependencies": { - "vue": ">=3.3.0" + "vue": ">=3.3.0 <4.0.0" }, "dependencies": { - "generaltranslation": "workspace:*" + "generaltranslation": "workspace:*", + "gt-i18n": "workspace:*" }, "scripts": { "build": "tsdown", "build:clean": "sh ../../scripts/clean.sh && pnpm run build", "build:release": "pnpm run build:clean", + "release": "pnpm run build:clean && pnpm publish", + "release:alpha": "pnpm run build:clean && pnpm publish --tag alpha", + "release:beta": "pnpm run build:clean && pnpm publish --tag beta", + "release:latest": "pnpm run build:clean && pnpm publish --tag latest", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" @@ -37,7 +42,6 @@ "homepage": "https://generaltranslation.com/", "devDependencies": { "@types/node": "catalog:", - "gt-i18n": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", diff --git a/packages/vue/src/__tests__/branches.test.ts b/packages/vue/src/__tests__/branches.test.ts new file mode 100644 index 0000000000..6d6bf0d5e8 --- /dev/null +++ b/packages/vue/src/__tests__/branches.test.ts @@ -0,0 +1,286 @@ +import { hashSource } from 'generaltranslation/id'; +import type { JsxChildren } from 'generaltranslation/types'; +import { compile, createSSRApp, defineComponent, type Component } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it } from 'vitest'; +import { isBranchAttribute } from '../components/utils'; +import { Branch, Plural, T, createGT } from '../index'; + +describe('Branch and Plural attributes', () => { + it.each([ + ['formal', 'Welcome'], + ['count', 12], + ['large', 12n], + ['flag', false], + ['enabled', true], + ['empty', null], + ['one', 'Singular'], + ])('accepts the primitive branch attribute %s', (name, value) => { + expect(isBranchAttribute(name, value)).toBe(true); + }); + + it.each([ + ['branch', 'formal'], + ['class', 'secret'], + ['style', 'color: red'], + ['style', { color: 'red' }], + ['n', 2], + ['locales', 'fr'], + ['key', 'stable'], + ['ref', 'component'], + ['ref-for', true], + ['ref-key', 'component'], + ['ref_for', true], + ['ref_key', 'component'], + ['onClick', 'not a branch'], + ['on-click', 'not a branch'], + ['onVnodeMounted', 'not a branch'], + ['data-note', 'private'], + ['aria-label', 'Greeting'], + ['formal', { label: 'Welcome' }], + ['formal', ['Welcome']], + ['formal', () => 'Welcome'], + ['formal', undefined], + ['formal', Symbol('Welcome')], + ])('rejects non-content attribute %s', (name, value) => { + expect(isBranchAttribute(name, value)).toBe(false); + }); + + it.each([ + [ + 'class', + 'Class fallback', + 'Class fallback', + ], + [ + 'style', + 'Style fallback', + 'Style fallback', + ], + [ + 'listener', + 'Listener fallback', + 'Listener fallback', + ], + [ + 'object', + 'Object fallback', + 'Object fallback', + ], + [ + 'function', + 'Function fallback', + 'Function fallback', + ], + [ + 'data attribute', + 'Data fallback', + 'Data fallback', + ], + [ + 'ARIA attribute', + 'ARIA fallback', + 'ARIA fallback', + ], + ])( + 'does not render a standalone %s value as branch content', + async (_label, template, fallback) => { + const html = await renderTemplate(template, () => ({ + handler: () => 'secret listener', + payload: { label: 'secret object' }, + })); + + expect(html).toContain(fallback); + expect(html).not.toContain('secret listener'); + expect(html).not.toContain('[object Object]'); + } + ); + + it.each([ + ['string', 'formal', 'formal="Welcome"', 'Welcome'], + ['number', 'count', ':count="12"', '12'], + ['bigint', 'large', ':large="12n"', '12'], + ])( + 'renders a standalone %s branch attribute as text', + async (_label, branch, attribute, expected) => { + const html = await renderTemplate( + `Fallback` + ); + + expect(html).toContain(expected); + expect(html).not.toContain('Fallback'); + } + ); + + it.each([ + ['false', ':flag="false"'], + ['true', ':flag="true"'], + ['null', ':flag="null"'], + ])( + 'treats a standalone %s attribute as a present empty branch', + async (label, attribute) => { + const html = await renderTemplate( + `
beforeFallbackafter
` + ); + + expect(html).toContain('beforeafter'); + expect(html).not.toContain('Fallback'); + expect(html).not.toContain(`>${label}<`); + } + ); + + it('prefers a named Branch slot over an attribute with the same name', async () => { + const html = await renderTemplate( + 'Fallback' + ); + + expect(html).toContain('Slot'); + expect(html).not.toContain('Attribute'); + expect(html).not.toContain('Fallback'); + }); + + it('uses the same filtered inputs for a rich Branch hash', async () => { + const source: JsxChildren = { + t: 'Branch', + i: 1, + d: { + b: { + formal: 'Slot source', + count: '12', + large: '12', + flag: [], + empty: [], + }, + t: 'b', + }, + c: 'Fallback', + }; + const target: JsxChildren = { + t: 'Branch', + i: 1, + d: { + b: { + formal: 'Slot traduit', + count: 'douze', + large: 'grand', + flag: [], + empty: [], + }, + t: 'b', + }, + c: 'Repli', + }; + const plugin = createGT({ + loadTranslations: async () => ({ [jsxHash(source)]: target }), + }); + await plugin.setLocale('fr'); + + const html = await renderTemplate( + 'Fallback', + () => ({ + handler: () => 'secret listener', + payload: { label: 'secret object' }, + }), + plugin + ); + + expect(html).toContain('Slot traduit'); + expect(html).not.toContain('Slot source'); + expect(html).not.toContain('Attribute'); + expect(html).not.toContain('secret'); + expect(html).not.toContain('[object Object]'); + }); + + it('filters standalone Plural attributes and keeps primitive forms', async () => { + const html = await renderTemplate( + '
Object fallback|Fallback|Fallback
', + () => ({ payload: { label: 'secret object' } }) + ); + + expect(html).toContain('Object fallback|One|1'); + expect(html).not.toContain('[object Object]'); + }); + + it.each([ + ['false', ':one="false"'], + ['null', ':one="null"'], + ])( + 'treats a standalone Plural %s form as present and empty', + async (label, attribute) => { + const html = await renderTemplate( + `
beforeFallbackafter
` + ); + + expect(html).toContain('beforeafter'); + expect(html).not.toContain('Fallback'); + expect(html).not.toContain(`>${label}<`); + } + ); + + it('prefers a named Plural slot over an attribute with the same name', async () => { + const html = await renderTemplate( + 'Fallback' + ); + + expect(html).toContain('Slot'); + expect(html).not.toContain('Attribute'); + expect(html).not.toContain('Fallback'); + }); + + it('uses the same filtered inputs for a rich Plural hash', async () => { + const source: JsxChildren = { + t: 'Plural', + i: 1, + d: { b: { other: 'Other' }, t: 'p' }, + c: 'Fallback', + }; + const target: JsxChildren = { + t: 'Plural', + i: 1, + d: { b: { other: 'Autres' }, t: 'p' }, + c: 'Repli', + }; + const plugin = createGT({ + loadTranslations: async () => ({ [jsxHash(source)]: target }), + }); + await plugin.setLocale('fr'); + + const html = await renderTemplate( + 'Fallback', + () => ({ + handler: () => 'secret listener', + payload: { label: 'secret object' }, + }), + plugin + ); + + expect(html).toContain('Autres'); + expect(html).not.toContain('Other'); + expect(html).not.toContain('secret'); + expect(html).not.toContain('[object Object]'); + }); +}); + +/** Renders a compiled Vue template through the server renderer. */ +async function renderTemplate( + template: string, + setup: () => Record = () => ({}), + plugin = createGT() +): Promise { + const Root = defineComponent({ + components: { Branch, Plural, T } satisfies Record, + render: compile(template), + setup, + }); + const app = createSSRApp(Root); + app.use(plugin); + return stripFragmentMarkers(await renderToString(app)); +} + +function jsxHash(source: JsxChildren): string { + return hashSource({ dataFormat: 'JSX', source }); +} + +function stripFragmentMarkers(html: string): string { + return html.replaceAll('', '').replaceAll('', ''); +} diff --git a/packages/vue/src/__tests__/richWireFormatParity.test.ts b/packages/vue/src/__tests__/richWireFormatParity.test.ts new file mode 100644 index 0000000000..f399b4a581 --- /dev/null +++ b/packages/vue/src/__tests__/richWireFormatParity.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs'; +import type { JsxChildren } from 'generaltranslation/types'; +import { createSSRApp, h, type VNodeChild } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it } from 'vitest'; +import { Branch, Num, Plural, T, Var, createGT } from '../index'; + +type WireFormatFixture = { + description: string; + hash: string; + id: keyof typeof sources; + source: JsxChildren; +}; + +const sources = { + 'nested-element': () => [ + 'Hello ', + h('strong', null, ['wonderful ', h('em', null, 'world')]), + '.', + ], + 'typed-variables': () => [ + 'Hello ', + h(Var, null, { default: () => 'Ada' }), + ', you have ', + h(Num, { value: 3 }), + ' messages.', + ], + 'independent-branch-numbering': () => [ + h( + Branch, + { branch: 'formal' }, + { + casual: () => [ + h('em', null, 'Hi'), + ' ', + h(Var, null, { default: () => 'Ada' }), + ], + default: () => 'Fallback', + formal: () => [ + h('strong', null, 'Hello'), + ' ', + h(Var, null, { default: () => 'Ada' }), + ], + } + ), + h('span', null, 'After'), + ], + 'independent-plural-numbering': () => [ + h( + Plural, + { n: 2 }, + { + default: () => 'Fallback', + one: () => ['One ', h(Num, { value: 1 })], + other: () => ['Many ', h(Num, { value: 2 })], + } + ), + h('span', null, 'After'), + ], +} satisfies Record VNodeChild[]>; + +const fixtures = JSON.parse( + readFileSync( + new URL( + '../../../../test-fixtures/rich-content-wire-format.json', + import.meta.url + ), + 'utf8' + ) +) as WireFormatFixture[]; + +describe('shared rich-content wire format', () => { + it.each(fixtures)('$id: $description', async (fixture) => { + const translated = `translated-${fixture.id}`; + const gt = createGT({ + locale: 'fr', + loadTranslations: async () => ({ [fixture.hash]: translated }), + }); + await gt.loadTranslations('fr'); + const app = createSSRApp({ + render: () => + h('div', null, [h(T, null, { default: sources[fixture.id] })]), + }); + app.use(gt); + + expect(await renderToString(app)).toContain(translated); + }); +}); diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 287c8fc3e2..a1ab7e3cc5 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -1,5 +1,6 @@ import type { JsxChildren } from 'generaltranslation/types'; import { hashSource } from 'generaltranslation/id'; +import * as Vue from 'vue'; import { Fragment, createCommentVNode, @@ -13,8 +14,11 @@ import { withDirectives, type Slots, } from 'vue'; +import { compileTemplate } from 'vue/compiler-sfc'; import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it, vi } from 'vitest'; import { getBranchNames } from '../components/utils'; +import { translateVueChildren } from '../rendering/translateVueChildren'; import { Branch, Currency, @@ -93,6 +97,27 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('renders the default locale before serializing or reading a catalog', () => { + const source = h('span'); + Object.defineProperty(source, 'props', { + value: new Proxy(Object.create(null) as object, { + get() { + throw new Error('default-locale source was serialized'); + }, + }), + }); + const state = { + defaultLocale: 'en', + getCatalog: vi.fn(() => { + throw new Error('default-locale catalog was read'); + }), + locale: ref('en'), + } as unknown as Parameters[1]; + + expect(translateVueChildren([source], state, {})).toEqual([source]); + expect(state.getCatalog).not.toHaveBeenCalled(); + }); + it('keeps msg and useMessages context-only and never interpolates', async () => { const contextual = msg('Literal {name}: 你好', { $context: 'example' }); const empty = msg('', { $context: 'empty' }); @@ -175,6 +200,32 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('prefers the documented context prop over the internal $context alias', async () => { + const source: JsxChildren = { t: 'p', i: 1, c: 'Hello' }; + const plugin = createGT({ + loadTranslations: async () => ({ + [jsxHash(source, 'friendly')]: { t: 'p', i: 1, c: 'Friendly' }, + [jsxHash(source, 'formal')]: { t: 'p', i: 1, c: 'Formal' }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { $context: 'formal', context: 'friendly' }, + { default: () => h('p', 'Hello') } + ); + }, + }); + + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('

Friendly

'); + expect(html).not.toContain('Formal'); + }); + it('preserves component props while replacing translated slot children', async () => { const onNavigate = vi.fn(); const Link = defineComponent({ @@ -250,6 +301,144 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('keeps Vue-compiled scoped slots opaque and supplies real props in SSR', async () => { + const ScopedCard = defineComponent({ + name: 'ScopedCard', + setup(_props, { slots }) { + return () => h('article', slots.default?.({ label: 'Runtime label' })); + }, + }); + const plugin = createGT({ + loadTranslations: async () => ({ + scoped: { + t: 'ScopedCard', + i: 1, + c: 'A translated replacement must not consume a scoped slot', + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + components: { Card: ScopedCard, T }, + render: compileSfcTemplate( + '{{ label }}' + ), + }); + + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('
Runtime label
'); + expect(html).not.toContain('translated replacement'); + }); + + it('omits Vue-compiled scoped named slots safely during branch discovery', async () => { + const ScopedBranch = Object.assign( + defineComponent({ + name: 'ScopedBranch', + setup(_props, { slots }) { + return () => slots.one?.({ label: 'Runtime label' }); + }, + }), + { _gtt: 'branch-client' } + ); + const plugin = createGT({ + loadTranslations: async () => ({ + scopedBranch: { + t: 'ScopedBranch', + i: 1, + d: { b: { one: 'Translated branch' }, t: 'b' }, + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + components: { ScopedBranch, T }, + render: compileSfcTemplate( + '' + ), + }); + + await expect(renderWithPlugin(Root, plugin)).resolves.toContain( + 'Translated branch' + ); + }); + + it('reuses explicit source element IDs repeated by a translation', async () => { + const target: JsxChildren = [ + { t: 'a', i: 1, c: 'Premier' }, + { t: 'a', i: 1, c: 'Encore' }, + ]; + const plugin = createGT({ + loadTranslations: async () => ({ repeated: target }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'repeated' }, + { + default: () => [ + h('a', null, 'First'), + h('strong', null, 'Second'), + ], + } + ); + }, + }); + + const html = stripFragmentMarkers(await renderWithPlugin(Root, plugin)); + + expect(html).toContain('PremierEncore'); + expect(html).not.toContain(''); + }); + + it('selects source plural values with the source locale and target branches with the active locale', async () => { + const variable = { i: 2, k: '_gt_value_2', v: 'v' } as const; + const target: JsxChildren = { + t: 'Plural', + i: 1, + d: { + b: { + one: ['Cible ', variable], + other: ['Cibles ', variable], + }, + t: 'p', + }, + }; + const plugin = createGT({ + defaultLocale: 'en', + loadTranslations: async () => ({ pluralLocale: target }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'pluralLocale' }, + { + default: () => + h( + Plural, + { locales: ['en'], n: 0 }, + { + one: () => h(Var, null, { default: () => 'ONE' }), + other: () => h(Var, null, { default: () => 'OTHER' }), + } + ), + } + ); + }, + }); + + const html = stripFragmentMarkers(await renderWithPlugin(Root, plugin)); + + expect(html).toContain('Cible OTHER'); + expect(html).not.toContain('Cible ONE'); + }); + it('updates multi-root rich children from arrays to scalar text on the client', async () => { const source: JsxChildren = [ { @@ -710,6 +899,20 @@ function jsxHash(source: JsxChildren, context?: string): string { return hashSource({ source, context, dataFormat: 'JSX' }); } +/** Compiles a template through the same SFC compiler used by Vue tooling. */ +function compileSfcTemplate(template: string): ReturnType { + const result = compileTemplate({ + compilerOptions: { mode: 'function' }, + filename: 'ScopedSlotFixture.vue', + id: 'scoped-slot-fixture', + source: template, + }); + expect(result.errors).toEqual([]); + return new Function('Vue', result.code)(Vue) as ReturnType< + typeof Vue.compile + >; +} + function stripFragmentMarkers(html: string): string { return html.replaceAll('', '').replaceAll('', ''); } diff --git a/packages/vue/src/__tests__/state.test.ts b/packages/vue/src/__tests__/state.test.ts new file mode 100644 index 0000000000..85f01fc864 --- /dev/null +++ b/packages/vue/src/__tests__/state.test.ts @@ -0,0 +1,161 @@ +import { + createRenderer, + defineComponent, + h, + nextTick, + type Component, +} from 'vue'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createGT, useGT, useLocale } from '../index'; +import type { GTPlugin, TranslationCatalog } from '../index'; + +describe('gt-vue runtime state', () => { + afterEach(() => vi.restoreAllMocks()); + + it('rejects failed locale changes, preserves the locale, and retries', async () => { + const error = new Error('catalog unavailable'); + const loadTranslations = vi.fn(async () => { + throw error; + }); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const plugin = createGT({ loadTranslations }); + + await expect(plugin.setLocale('fr')).rejects.toBe(error); + expect(plugin.getLocale()).toBe('en'); + await expect(plugin.setLocale('fr')).rejects.toBe(error); + + expect(loadTranslations).toHaveBeenCalledTimes(2); + expect(consoleError).toHaveBeenCalledTimes(2); + expect(String(consoleError.mock.calls[0]?.[0])).toContain( + 'Translations could not be loaded for "fr"' + ); + }); + + it('keeps the previous locale when the latest request rejects', async () => { + const pending = new Map< + string, + { + reject(error: unknown): void; + resolve(catalog: TranslationCatalog): void; + } + >(); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const plugin = createGT({ + loadTranslations: (locale) => + new Promise((resolve, reject) => + pending.set(locale, { reject, resolve }) + ), + }); + + const french = plugin.setLocale('fr'); + const chinese = plugin.setLocale('zh'); + await vi.waitFor(() => expect(pending.size).toBe(2)); + + pending.get('zh')?.reject(new Error('zh failed')); + await expect(chinese).rejects.toThrow('zh failed'); + pending.get('fr')?.resolve({}); + await expect(french).resolves.toBeUndefined(); + + expect(plugin.getLocale()).toBe('en'); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + + it('does not rerender active consumers when another locale is preloaded', async () => { + let renders = 0; + const plugin = createGT({ loadTranslations: async () => ({}) }); + const Root = defineComponent({ + setup() { + const gt = useGT(); + const locale = useLocale(); + return () => { + renders += 1; + return h('p', `${locale.value}:${gt('Hello')}`); + }; + }, + }); + const mounted = mount(Root, plugin); + + expect(renders).toBe(1); + await plugin.loadTranslations('fr'); + await nextTick(); + + expect(renders).toBe(1); + mounted.app.unmount(); + }); + + it('reports a missing plugin from composition functions', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const Root = defineComponent({ + setup() { + useGT(); + return () => null; + }, + }); + + expect(() => mount(Root)).toThrow('The GT Vue plugin is not installed'); + }); + + it('never calls the loader for the source locale', async () => { + const loadTranslations = vi.fn(async () => ({ translated: 'value' })); + const plugin = createGT({ defaultLocale: 'en', loadTranslations }); + + await expect(plugin.loadTranslations('en')).resolves.toEqual({}); + expect(loadTranslations).not.toHaveBeenCalled(); + }); +}); + +type HostNode = { + children: HostNode[]; + parent: HostNode | null; + props: Record; + text: string; + type: string; +}; + +function mount(rootComponent: Component, plugin?: GTPlugin) { + const renderer = createRenderer({ + createComment: (text) => createHostNode('comment', text), + createElement: (type) => createHostNode(type), + createText: (text) => createHostNode('text', text), + insert(child, parent, anchor) { + child.parent = parent; + const index = anchor ? parent.children.indexOf(anchor) : -1; + if (index >= 0) parent.children.splice(index, 0, child); + else parent.children.push(child); + }, + nextSibling(node) { + if (!node.parent) return null; + const index = node.parent.children.indexOf(node); + return node.parent.children[index + 1] ?? null; + }, + parentNode: (node) => node.parent, + patchProp(element, key, _previous, next) { + element.props[key] = next; + }, + remove(node) { + if (!node.parent) return; + const index = node.parent.children.indexOf(node); + if (index >= 0) node.parent.children.splice(index, 1); + }, + setElementText(element, text) { + element.text = text; + element.children = []; + }, + setText(node, text) { + node.text = text; + }, + }); + const app = renderer.createApp(rootComponent); + if (plugin) app.use(plugin); + const root = createHostNode('root'); + app.mount(root); + return { app, root }; +} + +function createHostNode(type: string, text = ''): HostNode { + return { children: [], parent: null, props: {}, text, type }; +} diff --git a/packages/vue/src/__tests__/strings.test.ts b/packages/vue/src/__tests__/strings.test.ts new file mode 100644 index 0000000000..430b14eaef --- /dev/null +++ b/packages/vue/src/__tests__/strings.test.ts @@ -0,0 +1,31 @@ +import { hashStringMessage } from 'gt-i18n/internal/string'; +import { createSSRApp, defineComponent, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it } from 'vitest'; +import { createGT, useGT, useMessages } from '../index'; + +describe('gt-vue string lookups', () => { + it('falls back to source text when a STRING hash resolves to rich data', async () => { + const source = 'Hello'; + const plugin = createGT({ + loadTranslations: async () => ({ + [hashStringMessage(source)]: { + c: 'Wrong catalog shape', + i: 1, + t: 'p', + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + const gt = useGT(); + const m = useMessages(); + return () => h('p', `${gt(source)}|${m(source)}`); + }, + }); + const app = createSSRApp(Root).use(plugin); + + expect(await renderToString(app)).toContain('Hello|Hello'); + }); +}); diff --git a/packages/vue/src/__tests__/variables.test.ts b/packages/vue/src/__tests__/variables.test.ts new file mode 100644 index 0000000000..7ae87d8836 --- /dev/null +++ b/packages/vue/src/__tests__/variables.test.ts @@ -0,0 +1,96 @@ +import { createSSRApp, defineComponent, h, type Component } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it } from 'vitest'; +import { Currency, DateTime, Num, createGT } from '../index'; + +describe('gt-vue formatting components', () => { + it('formats typed number, currency, Date, and epoch values', async () => { + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, { locales: ['en-US'], value: 1234.5 }), + '|', + h(Currency, { + currency: 'USD', + locales: ['en-US'], + value: 12, + }), + '|', + h(DateTime, { + locales: ['en-US'], + options: { timeZone: 'UTC', year: 'numeric' }, + value: new Date('2024-01-01T00:00:00.123Z'), + }), + '|', + h(DateTime, { + locales: ['en-US'], + options: { timeZone: 'UTC', year: 'numeric' }, + value: 1704067200000, + }), + ]); + }, + }); + + expect(await render(Root)).toContain('1,234.5|$12.00|2024|2024'); + }); + + it('gives value props precedence over static slot text', async () => { + const Root = defineComponent({ + setup() { + return () => + h(Num, { locales: ['en-US'], value: 2 }, { default: () => '999' }); + }, + }); + + expect(await render(Root)).toBe('2'); + }); + + it('returns partially parseable slot text unchanged', async () => { + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, { locales: ['en-US'] }, { default: () => '1,234.5' }), + '|', + h( + Currency, + { currency: 'USD', locales: ['en-US'] }, + { default: () => '12 dollars' } + ), + ]); + }, + }); + + expect(await render(Root)).toContain('1,234.5|12 dollars'); + }); + + it('returns invalid dates unchanged', async () => { + const Root = defineComponent({ + setup() { + return () => h(DateTime, { value: 'definitely-not-a-date' as string }); + }, + }); + + expect(await render(Root)).toContain('definitely-not-a-date'); + }); + + it('does not interpret whitespace-only slot text as zero or a date', async () => { + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, null, { default: () => ' ' }), + h(Currency, null, { default: () => '\n' }), + h(DateTime, null, { default: () => '\t' }), + ]); + }, + }); + + expect(await render(Root)).toBe('
'); + }); +}); + +async function render(root: Component): Promise { + return renderToString(createSSRApp(root).use(createGT())); +} diff --git a/packages/vue/src/components/T.ts b/packages/vue/src/components/T.ts index 661e09dd15..67ee793a78 100644 --- a/packages/vue/src/components/T.ts +++ b/packages/vue/src/components/T.ts @@ -6,7 +6,7 @@ import { withGTMetadata } from './utils'; type TProps = { /** @internal Compile-time hash inserted by GT tooling. */ _hash?: string; - /** Translation context using the API-parity `$context` spelling. */ + /** @internal React-compatible alias accepted for compiler output. */ $context?: string; /** Translation context using a Vue-template-friendly prop name. */ context?: string; diff --git a/packages/vue/src/components/utils.ts b/packages/vue/src/components/utils.ts index c097605187..862bdb72ed 100644 --- a/packages/vue/src/components/utils.ts +++ b/packages/vue/src/components/utils.ts @@ -6,6 +6,23 @@ import { type VNodeChild, } from 'vue'; +const NON_BRANCH_ATTRIBUTE_NAMES = new Set([ + 'branch', + 'class', + 'key', + 'locales', + 'n', + 'ref', + 'ref-for', + 'ref-key', + 'ref_for', + 'ref_key', + 'style', +]); + +/** Values that Vue and the extractor can represent as an attribute branch. */ +export type BranchAttributeValue = bigint | boolean | null | number | string; + /** @internal GT metadata attached to components for rich-content extraction. */ export type GTComponent = DefineComponent & { /** @internal */ @@ -33,6 +50,42 @@ export function readSlotText(slots: Slots): string { return (slots.default?.() ?? []).map(readVNodeText).join(''); } +/** + * Returns whether an inherited component attribute is translation content for + * `Branch` or `Plural`. + * + * Vue places presentation attributes, listeners, and arbitrary objects in the + * same `$attrs` object as explicit branch attributes. Treating all of them as + * content leaks class/style data and function source into translation hashes. + * This predicate is therefore shared by standalone branch selection and rich + * source serialization. Named slots are handled separately and take + * precedence over attributes with the same name. + * + * Static primitive attributes mirror what the extractor can publish. Strings, + * numbers, and bigints render as text; booleans and null are present branches + * with empty content. Undefined, objects, and functions are not branches. + */ +export function isBranchAttribute( + name: string, + value: unknown +): value is BranchAttributeValue { + if ( + NON_BRANCH_ATTRIBUTE_NAMES.has(name) || + name.startsWith('aria-') || + name.startsWith('data-') || + /^on[^a-z]/.test(name) + ) { + return false; + } + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'bigint' || + typeof value === 'boolean' + ); +} + function readVNodeText(node: VNodeChild): string { if (node == null || typeof node === 'boolean') return ''; if (Array.isArray(node)) return node.map(readVNodeText).join(''); @@ -51,7 +104,9 @@ export function getBranchNames( ): string[] { return [ ...new Set([ - ...Object.keys(attrs).filter((key) => !key.startsWith('data-')), + ...Object.entries(attrs) + .filter(([name, value]) => isBranchAttribute(name, value)) + .map(([name]) => name), ...Object.keys(slots).filter( (key) => key !== 'default' && !key.startsWith('_') ), @@ -67,13 +122,14 @@ export function getBranchContent( ) { if ( branch && - Object.hasOwn(slots, branch) && + Object.prototype.hasOwnProperty.call(slots, branch) && typeof slots[branch] === 'function' ) { return slots[branch](); } - if (branch && Object.hasOwn(attrs, branch) && attrs[branch] !== undefined) { - return String(attrs[branch]); + const value = branch ? attrs[branch] : undefined; + if (branch && isBranchAttribute(branch, value)) { + return value === null || typeof value === 'boolean' ? null : String(value); } return slots.default?.() ?? null; } diff --git a/packages/vue/src/components/variables.ts b/packages/vue/src/components/variables.ts index e0226592e1..c16635d1f6 100644 --- a/packages/vue/src/components/variables.ts +++ b/packages/vue/src/components/variables.ts @@ -7,6 +7,8 @@ type NumberFormatProps = { locales?: string[]; /** Options forwarded to `Intl.NumberFormat`. */ options?: Intl.NumberFormatOptions; + /** Runtime value. When provided, this takes precedence over slot text. */ + value?: number | string | null; }; type DateTimeProps = { @@ -14,6 +16,8 @@ type DateTimeProps = { locales?: string[]; /** Options forwarded to `Intl.DateTimeFormat`. */ options?: Intl.DateTimeFormatOptions; + /** Runtime value. When provided, this takes precedence over slot text. */ + value?: Date | number | string | null; }; type CurrencyProps = NumberFormatProps & { @@ -45,9 +49,10 @@ export const Var = withGTMetadata( ); /** - * Formats numeric default-slot text with `Intl.NumberFormat` for the active - * locale. Explicit `locales` are tried first. Slot text that - * `Number.parseFloat` cannot parse is returned unchanged. + * Formats a number with `Intl.NumberFormat` for the active locale. Pass + * runtime values through `value`; static default-slot text remains supported. + * Explicit `locales` are tried first, and text that is not an entire numeric + * value is returned unchanged. */ export const Num = withGTMetadata( defineComponent({ @@ -57,15 +62,23 @@ export const Num = withGTMetadata( locales: Array as PropType, /** Options forwarded to `Intl.NumberFormat`. */ options: Object as PropType, + /** Runtime value. When provided, this takes precedence over slot text. */ + value: [Number, String] as PropType, }, setup(props, { slots }) { const state = useGTState(); return () => { - const value = readSlotText(slots); - if (!value) return null; - const number = Number.parseFloat(value); + const value = + props.value !== undefined ? props.value : readSlotText(slots); + if ( + value == null || + (typeof value === 'string' && value.trim() === '') + ) { + return null; + } + const number = typeof value === 'number' ? value : Number(value); return Number.isNaN(number) - ? value + ? String(value) : new Intl.NumberFormat( getFormatLocales(props.locales, state.locale.value), props.options @@ -77,9 +90,10 @@ export const Num = withGTMetadata( ); /** - * Parses and formats default-slot text with `Intl.DateTimeFormat` for the - * active locale. Explicit `locales` are tried first, and invalid dates are - * returned unchanged. + * Formats a date with `Intl.DateTimeFormat` for the active locale. Pass + * `Date` objects and epoch numbers through `value`; static default-slot text + * remains supported. Explicit `locales` are tried first, and invalid values + * are returned unchanged. */ export const DateTime = withGTMetadata( defineComponent({ @@ -89,14 +103,22 @@ export const DateTime = withGTMetadata( locales: Array as PropType, /** Options forwarded to `Intl.DateTimeFormat`. */ options: Object as PropType, + /** Runtime value. When provided, this takes precedence over slot text. */ + value: [Date, Number, String] as PropType, }, setup(props, { slots }) { const state = useGTState(); return () => { - const value = readSlotText(slots); - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; + const value = + props.value !== undefined ? props.value : readSlotText(slots); + if ( + value == null || + (typeof value === 'string' && value.trim() === '') + ) { + return null; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); return new Intl.DateTimeFormat( getFormatLocales(props.locales, state.locale.value), props.options @@ -110,9 +132,10 @@ export const DateTime = withGTMetadata( ); /** - * Formats numeric default-slot text as currency for the active locale. - * `currency` defaults to `USD`. Slot text that `Number.parseFloat` cannot parse - * is returned unchanged. + * Formats a number as currency for the active locale. Pass runtime values + * through `value`; static default-slot text remains supported. `currency` + * defaults to `USD`, and text that is not an entire numeric value is returned + * unchanged. */ export const Currency = withGTMetadata( defineComponent({ @@ -127,15 +150,23 @@ export const Currency = withGTMetadata( locales: Array as PropType, /** Additional options forwarded to `Intl.NumberFormat`. */ options: Object as PropType, + /** Runtime value. When provided, this takes precedence over slot text. */ + value: [Number, String] as PropType, }, setup(props, { slots }) { const state = useGTState(); return () => { - const value = readSlotText(slots); - if (!value) return null; - const number = Number.parseFloat(value); + const value = + props.value !== undefined ? props.value : readSlotText(slots); + if ( + value == null || + (typeof value === 'string' && value.trim() === '') + ) { + return null; + } + const number = typeof value === 'number' ? value : Number(value); return Number.isNaN(number) - ? value + ? String(value) : new Intl.NumberFormat( getFormatLocales(props.locales, state.locale.value), { diff --git a/packages/vue/src/messages/msg.ts b/packages/vue/src/messages/msg.ts index 68ca55a571..12c273f0c4 100644 --- a/packages/vue/src/messages/msg.ts +++ b/packages/vue/src/messages/msg.ts @@ -1,4 +1,4 @@ -import { msg as registerMessage } from 'gt-i18n/internal/string'; +import { msgString as registerMessage } from 'gt-i18n/internal/string'; import type { GTStringOptions } from '../types'; /** diff --git a/packages/vue/src/messages/translation.ts b/packages/vue/src/messages/translation.ts index 687311834a..47713b913f 100644 --- a/packages/vue/src/messages/translation.ts +++ b/packages/vue/src/messages/translation.ts @@ -1,4 +1,4 @@ -import { hashSource } from 'generaltranslation/id'; +import { hashStringMessage } from 'gt-i18n/internal/string'; import type { GTState, GTStringOptions } from '../types'; /** @internal */ @@ -13,13 +13,7 @@ export function translateString( message: string, options: InternalStringOptions = {} ): string { - const hash = - options.$_hash ?? - hashSource({ - context: options.$context, - dataFormat: 'STRING', - source: message, - }); + const hash = hashStringMessage(message, options); const translation = state.getCatalog()[hash]; return typeof translation === 'string' ? translation : message; } diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 77745b4806..568462d00c 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -22,10 +22,12 @@ import { isVNode, mergeProps, type Component, + type Slot, type Slots, type VNode, type VNodeChild, } from 'vue'; +import { isBranchAttribute } from '../components/utils'; import type { GTState } from '../types'; const variableTypes = { @@ -41,6 +43,7 @@ type SourceElement = { branches: Record; children: SourceNode[]; id: number; + opaque: boolean; transformation: Transformation; variableName?: string; variableType?: VariableType; @@ -56,6 +59,7 @@ type ComponentWithGTMetadata = Component & { }; type RichTranslationOptions = { + /** @internal React-compatible alias accepted for compiler output. */ $context?: string; _hash?: string; context?: string; @@ -72,13 +76,15 @@ export function translateVueChildren( options: RichTranslationOptions ): VNodeChild { const source = createSourceNodes(children); - const serialized = serializeNodes(source); + if (state.locale.value === state.defaultLocale) { + return renderDefaultNodes(source, state, state.defaultLocale); + } const hash = options._hash ?? hashSource({ - context: options.$context ?? options.context, + context: options.context ?? options.$context, dataFormat: 'JSX', - source: serialized, + source: serializeNodes(source), }); const target = state.getCatalog()[hash]; if (target == null) { @@ -115,12 +121,17 @@ function visitChildren( const transformation = getTransformation(metadata); const variable = transformation === 'variable' ? getVariable(metadata, id) : undefined; + const defaultSlot = variable + ? { children: undefined, opaque: false } + : readDefaultSlot(children); const source: SourceElement = { branches: {}, - children: variable - ? [] - : visitChildren(getDefaultSlotChildren(children), index), + children: + variable || defaultSlot.opaque + ? [] + : visitChildren(defaultSlot.children, index), id, + opaque: defaultSlot.opaque, transformation, variableName: variable?.name, variableType: variable?.type, @@ -163,9 +174,54 @@ function getVariable( }; } -function getDefaultSlotChildren(vnode: VNode): unknown { - if (isSlots(vnode.children)) return vnode.children.default?.(); - return vnode.children; +const SCOPED_SLOT_ACCESS = Symbol('gt-vue scoped slot access'); + +/** + * Reads a default slot only while it behaves like source-owned static content. + * + * Vue's compiled scoped-slot wrappers erase the original function arity, so + * they cannot be identified from `slot.length`. A throwing proxy detects the + * first read of slot props without inventing runtime values. Such slots stay + * opaque and are later rendered by their owning component with real props. + */ +function readDefaultSlot(vnode: VNode): { + children: unknown; + opaque: boolean; +} { + if (!isSlots(vnode.children)) { + return { children: vnode.children, opaque: false }; + } + return readSlot(vnode.children.default); +} + +/** Safely probes one compiled slot without supplying invented slot props. */ +function readSlot(slot?: Slot): { children: unknown; opaque: boolean } { + if (!slot) return { children: undefined, opaque: false }; + + const access = { detected: false }; + const detectAccess = () => { + access.detected = true; + throw SCOPED_SLOT_ACCESS; + }; + const probe = new Proxy(Object.create(null) as object, { + get: detectAccess, + getOwnPropertyDescriptor: detectAccess, + getPrototypeOf: detectAccess, + has: detectAccess, + ownKeys: detectAccess, + }); + + try { + const slotChildren = slot(probe); + return access.detected + ? { children: undefined, opaque: true } + : { children: slotChildren, opaque: false }; + } catch (error) { + if (access.detected || error === SCOPED_SLOT_ACCESS) { + return { children: undefined, opaque: true }; + } + throw error; + } } function getBranches( @@ -181,24 +237,15 @@ function getBranches( !key.startsWith('_') && typeof slot === 'function' ) { - inputs[key] = slot(); + const branch = readSlot(slot); + if (!branch.opaque) inputs[key] = branch.children; } } } for (const [key, value] of Object.entries(vnode.props ?? {})) { if ( - key !== 'branch' && - key !== 'n' && - key !== 'locales' && - key !== 'key' && - key !== 'ref' && - key !== 'ref_for' && - key !== 'ref_key' && - key !== 'ref-for' && - key !== 'ref-key' && - !key.startsWith('onVnode') && - !key.startsWith('data-') && - !Object.hasOwn(inputs, key) + isBranchAttribute(key, value) && + !Object.prototype.hasOwnProperty.call(inputs, key) ) { inputs[key] = value; } @@ -259,21 +306,27 @@ function serializeNode(node: SourceNode): JsxChild { } return { - t: getElementName(node.vnode), + t: getElementName(node.vnode, node.id), i: node.id, ...(Object.keys(data).length && { d: data }), ...(node.children.length && { c: serializeNodes(node.children) }), }; } -function getElementName(vnode: VNode): string { +/** + * Returns a readable element label with a deterministic anonymous fallback. + * JSX hashing strips element names and IDs, so compiler or minifier naming + * differences cannot change the catalog key. + */ +function getElementName(vnode: VNode, id: number): string { + const fallback = `C${id}`; if (typeof vnode.type === 'string') return vnode.type; - if (typeof vnode.type === 'function') return vnode.type.name || 'function'; + if (typeof vnode.type === 'function') return vnode.type.name || fallback; if (typeof vnode.type === 'object') { const type = vnode.type as ComponentWithGTMetadata; - return type.name || type.__name || 'component'; + return type.name || type.__name || fallback; } - return 'component'; + return fallback; } function renderNodes( @@ -300,6 +353,8 @@ function renderNodes( const ordinary = sourceElements.filter( (node) => node.transformation !== 'variable' ); + const ordinaryById = new Map(ordinary.map((node) => [node.id, node])); + const fallback = [...ordinary]; return targets.map((targetNode) => { if (typeof targetNode === 'string') return targetNode; @@ -308,13 +363,12 @@ function renderNodes( return variable ? renderDefaultNode(variable, state) : null; } - const matchingIndex = ordinary.findIndex( - (sourceNode) => sourceNode.id === targetNode.i - ); + // An explicit target ID is a reusable reference, while order-based + // fallback consumes each source node once. Translations may intentionally + // repeat one source element without rebinding later copies to siblings. const sourceNode = - matchingIndex >= 0 - ? ordinary.splice(matchingIndex, 1)[0] - : ordinary.shift(); + (targetNode.i == null ? undefined : ordinaryById.get(targetNode.i)) ?? + fallback.shift(); return sourceNode ? renderElement(sourceNode, targetNode, state) : null; }); } @@ -339,7 +393,9 @@ function renderElement( n, Object.keys(source.branches), source, - state + state, + state.defaultLocale, + true ); const targetBranches = target.d?.b ?? {}; const targetBranch = getPluralKey( @@ -357,6 +413,12 @@ function renderElement( if (source.transformation === 'fragment') { return renderNodes(source.children, target.c, state); } + if (source.opaque) { + const translatedProps = getTranslatedProps(target); + return Object.keys(translatedProps).length + ? cloneWithProps(source.vnode, translatedProps) + : source.vnode; + } const translatedProps = getTranslatedProps(target); if (target.c == null) { return Object.keys(translatedProps).length @@ -383,17 +445,19 @@ function getPluralKey( branches: string[], source: SourceElement, state: GTState, - locale = state.locale.value + locale = state.locale.value, + includeSourceLocales = false ): string | undefined { const forms = branches.filter(isAcceptedPluralForm); if (!forms.length) return undefined; - const locales = Array.isArray(source.vnode.props?.locales) - ? source.vnode.props.locales.filter( - (locale): locale is string => typeof locale === 'string' - ) - : []; + const sourceLocales = + includeSourceLocales && Array.isArray(source.vnode.props?.locales) + ? source.vnode.props.locales.filter( + (locale): locale is string => typeof locale === 'string' + ) + : []; return ( - getPluralForm(n, forms, [...locales, locale, state.defaultLocale]) || + getPluralForm(n, forms, [...sourceLocales, locale, state.defaultLocale]) || undefined ); } @@ -484,7 +548,8 @@ function renderDefaultNode( Object.keys(node.branches), node, state, - locale + locale, + true ); return renderDefaultNodes( getSelectedSourceBranch(node, branch), diff --git a/packages/vue/src/runtime/state.ts b/packages/vue/src/runtime/state.ts index de990d36fd..d22804038a 100644 --- a/packages/vue/src/runtime/state.ts +++ b/packages/vue/src/runtime/state.ts @@ -1,6 +1,7 @@ import { inject, ref, type InjectionKey } from 'vue'; import { createDiagnosticMessage, + formatDiagnosticErrorDetails, libraryDefaultLocale, } from 'generaltranslation/internal'; import type { @@ -62,9 +63,21 @@ export function createGT({ .then(() => loadTranslations?.(targetLocale) ?? {}) .then((catalog) => { catalogs.set(targetLocale, catalog); - revision.value += 1; + if (targetLocale === locale.value) revision.value += 1; return catalog; }) + .catch((error: unknown) => { + const diagnostic = createDiagnosticMessage({ + source: 'gt-vue', + severity: 'Error', + whatHappened: `Translations could not be loaded for "${targetLocale}"`, + fix: 'Make sure loadTranslations() resolves to a translation catalog for the requested locale', + wayOut: 'Source content will render as a fallback', + details: formatDiagnosticErrorDetails(error), + }); + console.error(diagnostic); + throw error; + }) .finally(() => pending.delete(targetLocale)); pending.set(targetLocale, promise); diff --git a/packages/vue/src/types/index.ts b/packages/vue/src/types/index.ts index 1504054ccf..8338999dcc 100644 --- a/packages/vue/src/types/index.ts +++ b/packages/vue/src/types/index.ts @@ -54,7 +54,10 @@ export type MessagesFunction = ( /** Options used to create an isolated gt-vue plugin instance. */ export type CreateGTOptions = { - /** Source and fallback locale. Defaults to GT's library default locale. */ + /** + * Source and fallback locale. Defaults to GT's library default locale. + * Its source text is the catalog, so the loader is never called for it. + */ defaultLocale?: string; /** Async loader called once for each uncached locale. */ loadTranslations?: LoadTranslations; @@ -73,11 +76,15 @@ export type GTPlugin = { getLocale(): string; /** Provides the GT state to a Vue application. Usually called by `app.use`. */ install(app: App): void; - /** Preloads and caches a locale without changing the active locale. */ + /** + * Preloads and caches a locale without changing the active locale. The + * default locale is already represented by source text and is not loaded. + */ loadTranslations(locale: string): Promise; /** * Loads a locale when needed, then switches reactive consumers to it. - * Only the latest overlapping locale request is applied. + * Only the latest overlapping locale request is applied. Superseded calls + * still fulfill after their catalog loads, without changing the locale. */ setLocale(locale: string): Promise; }; diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 98f6ecd295..78336efcfe 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -15,6 +15,9 @@ "references": [ { "path": "../core" + }, + { + "path": "../i18n" } ] } diff --git a/packages/vue/tsdown.config.mts b/packages/vue/tsdown.config.mts index fd42ecb868..40f814f0d3 100644 --- a/packages/vue/tsdown.config.mts +++ b/packages/vue/tsdown.config.mts @@ -7,12 +7,16 @@ const deps = { /^vue\//, /^generaltranslation$/, /^generaltranslation\//, + /^gt-i18n$/, + /^gt-i18n\//, ], - alwaysBundle: [/^gt-i18n\/internal\/string$/], }; const configs = createTsdownConfig(['src/index.ts'], deps).map((config) => ({ ...config, + // Keep declared runtime dependencies external in both formats. The shared + // preset only applies this policy to CJS, while modern consumers load ESM. + deps: { onlyBundle: false, ...deps }, outputOptions: { comments: { annotation: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da34ac6e98..2b6c355af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1639,13 +1639,13 @@ importers: generaltranslation: specifier: workspace:* version: link:../core + gt-i18n: + specifier: workspace:* + version: link:../i18n devDependencies: '@types/node': specifier: 'catalog:' version: 22.13.10 - gt-i18n: - specifier: workspace:* - version: link:../i18n tsdown: specifier: 'catalog:' version: 0.21.10(synckit@0.11.11)(typescript@5.9.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0bee732685..d37e0908c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,7 @@ minimumReleaseAgeExclude: - '@generaltranslation/*' - gt-next - gt-react + - gt-vue - gt-sanity - '@generaltranslation/format' - gt-i18n diff --git a/test-fixtures/README.md b/test-fixtures/README.md new file mode 100644 index 0000000000..3fa7543249 --- /dev/null +++ b/test-fixtures/README.md @@ -0,0 +1,10 @@ +# Cross-framework translation fixtures + +`rich-content-wire-format.json` pins the shared rich-content wire format used +by React, Vue, and source extractors. Each hash is a persisted catalog key, so +changing a fixture requires an explicit compatibility decision. + +The corpus covers semantics the frameworks share. Framework-native tree +normalization still happens before serialization: Vue flattens fragments, +drops comments, and coalesces adjacent text nodes, while React follows +`React.Children`. Framework-specific tests pin those intentional differences. diff --git a/test-fixtures/rich-content-wire-format.json b/test-fixtures/rich-content-wire-format.json new file mode 100644 index 0000000000..22dd465df9 --- /dev/null +++ b/test-fixtures/rich-content-wire-format.json @@ -0,0 +1,129 @@ +[ + { + "id": "nested-element", + "description": "Nested elements keep depth-first identifiers.", + "source": [ + "Hello ", + { + "t": "strong", + "i": 1, + "c": [ + "wonderful ", + { + "t": "em", + "i": 2, + "c": "world" + } + ] + }, + "." + ], + "hash": "5353942e57d68988" + }, + { + "id": "typed-variables", + "description": "Variable identifiers and generated names follow source order.", + "source": [ + "Hello ", + { + "i": 1, + "k": "_gt_value_1", + "v": "v" + }, + ", you have ", + { + "i": 2, + "k": "_gt_n_2", + "v": "n" + }, + " messages." + ], + "hash": "d7e2eed57565bcd8" + }, + { + "id": "independent-branch-numbering", + "description": "Mutually exclusive Branch slots restart after the parent and do not shift following siblings.", + "source": [ + { + "t": "Branch", + "i": 1, + "d": { + "b": { + "formal": [ + { + "t": "strong", + "i": 2, + "c": "Hello" + }, + " ", + { + "i": 3, + "k": "_gt_value_3", + "v": "v" + } + ], + "casual": [ + { + "t": "em", + "i": 2, + "c": "Hi" + }, + " ", + { + "i": 3, + "k": "_gt_value_3", + "v": "v" + } + ] + }, + "t": "b" + }, + "c": "Fallback" + }, + { + "t": "span", + "i": 2, + "c": "After" + } + ], + "hash": "b2a3f3f7f2bdac6f" + }, + { + "id": "independent-plural-numbering", + "description": "Plural forms use the same independent numbering contract as Branch slots.", + "source": [ + { + "t": "Plural", + "i": 1, + "d": { + "b": { + "one": [ + "One ", + { + "i": 2, + "k": "_gt_n_2", + "v": "n" + } + ], + "other": [ + "Many ", + { + "i": 2, + "k": "_gt_n_2", + "v": "n" + } + ] + }, + "t": "p" + }, + "c": "Fallback" + }, + { + "t": "span", + "i": 2, + "c": "After" + } + ], + "hash": "6155c9e9b46b73ad" + } +] From 4cdb5c264a994291df442f75d9ad70a1f43bb069 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Fri, 31 Jul 2026 19:49:20 -0700 Subject: [PATCH 15/28] fix(vue): suppress variable fallthrough attrs --- packages/vue/src/__tests__/variables.test.ts | 52 +++++++++++++++++++- packages/vue/src/components/variables.ts | 4 ++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/__tests__/variables.test.ts b/packages/vue/src/__tests__/variables.test.ts index 7ae87d8836..123c056b22 100644 --- a/packages/vue/src/__tests__/variables.test.ts +++ b/packages/vue/src/__tests__/variables.test.ts @@ -1,7 +1,7 @@ import { createSSRApp, defineComponent, h, type Component } from 'vue'; import { renderToString } from 'vue/server-renderer'; import { describe, expect, it } from 'vitest'; -import { Currency, DateTime, Num, createGT } from '../index'; +import { Currency, DateTime, Num, Var, createGT } from '../index'; describe('gt-vue formatting components', () => { it('formats typed number, currency, Date, and epoch values', async () => { @@ -89,6 +89,56 @@ describe('gt-vue formatting components', () => { expect(await render(Root)).toBe('
'); }); + + it('ignores fallthrough attributes without warning for unwrapped values', async () => { + let listenerCalls = 0; + const fallthrough = { + class: 'ignored-class', + 'data-ignored': 'ignored-data', + onClick: () => { + listenerCalls += 1; + }, + title: 'ignored-title', + }; + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Var, fallthrough, { default: () => 'Ada' }), + h(Var, fallthrough, { + default: () => h('span', { id: 'inner' }, 'Grace'), + }), + h(Num, { ...fallthrough, locales: ['en-US'], value: 2 }), + h(Currency, { + ...fallthrough, + currency: 'USD', + locales: ['en-US'], + value: 3, + }), + h(DateTime, { + ...fallthrough, + locales: ['en-US'], + options: { timeZone: 'UTC', year: 'numeric' }, + value: 1704067200000, + }), + ]); + }, + }); + const warnings: string[] = []; + const app = createSSRApp(Root).use(createGT()); + app.config.warnHandler = (message) => warnings.push(message); + + const html = await renderToString(app); + + expect(html).toContain('Ada'); + expect(html).toContain('Grace'); + expect(html).toContain('2'); + expect(html).toContain('$3.00'); + expect(html).toContain('2024'); + expect(html).not.toContain('ignored-'); + expect(listenerCalls).toBe(0); + expect(warnings).toEqual([]); + }); }); async function render(root: Component): Promise { diff --git a/packages/vue/src/components/variables.ts b/packages/vue/src/components/variables.ts index c16635d1f6..7de32c70c2 100644 --- a/packages/vue/src/components/variables.ts +++ b/packages/vue/src/components/variables.ts @@ -40,6 +40,7 @@ type CurrencyProps = NumberFormatProps & { */ export const Var = withGTMetadata( defineComponent({ + inheritAttrs: false, name: 'Var', setup(_props, { slots }) { return () => slots.default?.() ?? null; @@ -56,6 +57,7 @@ export const Var = withGTMetadata( */ export const Num = withGTMetadata( defineComponent({ + inheritAttrs: false, name: 'Num', props: { /** Locale preferences tried before the active GT locale. */ @@ -97,6 +99,7 @@ export const Num = withGTMetadata( */ export const DateTime = withGTMetadata( defineComponent({ + inheritAttrs: false, name: 'DateTime', props: { /** Locale preferences tried before the active GT locale. */ @@ -139,6 +142,7 @@ export const DateTime = withGTMetadata( */ export const Currency = withGTMetadata( defineComponent({ + inheritAttrs: false, name: 'Currency', props: { /** ISO 4217 currency code. Defaults to `USD`. */ From 4e01819c27b5af0f3e89dfa01cf100001412a65b Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Tue, 4 Aug 2026 19:42:23 -0700 Subject: [PATCH 16/28] fix(vue): preserve translated vnode semantics --- packages/vue/README.md | 12 +- packages/vue/src/__tests__/runtime.test.ts | 407 +++++++++++++++++- packages/vue/src/components/T.ts | 20 +- .../vue/src/rendering/translateVueChildren.ts | 275 ++++++++---- 4 files changed, 603 insertions(+), 111 deletions(-) diff --git a/packages/vue/README.md b/packages/vue/README.md index 25159e9dd9..52824837d9 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -84,11 +84,13 @@ const setLocale = useSetLocale(); `$context`; braces are literal text and no ICU formatting or interpolation is applied. -Components whose slots read scoped props are treated as opaque when they are -placed inside ``. The component and its real runtime slot props are -preserved, but that scoped-slot content is not part of the surrounding rich -translation. To translate it, place `` inside the scoped slot and wrap -runtime values in ``. +Arbitrary component slots are opaque when placed inside ``. Vue does not +expose a reliable way to inspect a component slot without executing user code, +so the component and its real runtime slots are preserved, but their content is +not part of the surrounding rich translation. To translate slot content, place +`` inside the slot and wrap runtime values in ``. Native elements and +the slots owned by GT's `` and `` components remain part of the +surrounding translation. ## Registered Messages diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index a1ab7e3cc5..8d7a9def37 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -3,6 +3,7 @@ import { hashSource } from 'generaltranslation/id'; import * as Vue from 'vue'; import { Fragment, + Suspense, createCommentVNode, createRenderer, createSSRApp, @@ -101,7 +102,15 @@ describe('gt-vue runtime', () => { const source = h('span'); Object.defineProperty(source, 'props', { value: new Proxy(Object.create(null) as object, { - get() { + get(_target, property) { + if ( + property === 'key' || + property === 'ref' || + property === 'ref_for' || + property === 'ref_key' + ) { + return undefined; + } throw new Error('default-locale source was serialized'); }, }), @@ -114,7 +123,10 @@ describe('gt-vue runtime', () => { locale: ref('en'), } as unknown as Parameters[1]; - expect(translateVueChildren([source], state, {})).toEqual([source]); + const rendered = translateVueChildren([source], state, {}); + expect(Array.isArray(rendered) && rendered[0]).toMatchObject({ + type: 'span', + }); expect(state.getCatalog).not.toHaveBeenCalled(); }); @@ -226,7 +238,7 @@ describe('gt-vue runtime', () => { expect(html).not.toContain('Formal'); }); - it('preserves component props while replacing translated slot children', async () => { + it('translates supported component props while preserving opaque slots', async () => { const onNavigate = vi.fn(); const Link = defineComponent({ emits: ['navigate'], @@ -293,7 +305,7 @@ describe('gt-vue runtime', () => { id: 'docs-link', title: 'Titre traduit', }); - expect(textContent(mounted.root)).toBe('Lien traduit'); + expect(textContent(mounted.root)).toBe('Source link'); const onClick = anchor?.props.onClick; expect(onClick).toBeTypeOf('function'); (onClick as () => void)(); @@ -331,22 +343,106 @@ describe('gt-vue runtime', () => { expect(html).not.toContain('translated replacement'); }); - it('omits Vue-compiled scoped named slots safely during branch discovery', async () => { - const ScopedBranch = Object.assign( - defineComponent({ - name: 'ScopedBranch', - setup(_props, { slots }) { - return () => slots.one?.({ label: 'Runtime label' }); - }, + it('never probes direct, ignored, or forwarded custom component slots', async () => { + const directCalls = vi.fn(); + const ignoredCalls = vi.fn(); + const forwardedCalls = vi.fn(); + const scope = { label: 'Runtime label' }; + const DeferredReader = defineComponent({ + name: 'DeferredReader', + props: { payload: { required: true, type: Object } }, + setup(props) { + return () => h('span', String(props.payload.label)); + }, + }); + const DirectOwner = defineComponent({ + name: 'DirectOwner', + setup(_props, { slots }) { + return () => h('section', slots.default?.(scope)); + }, + }); + const IgnoredOwner = defineComponent({ + name: 'IgnoredOwner', + setup() { + return () => h('aside', 'Ignored safely'); + }, + }); + const ForwardingOwner = defineComponent({ + name: 'ForwardingOwner', + setup(_props, { slots }) { + return () => h('div', slots.default?.(scope)); + }, + }); + const plugin = createGT({ + loadTranslations: async () => ({ + opaqueSlots: [ + { t: 'DirectOwner', i: 1, c: 'Wrong direct replacement' }, + { t: 'IgnoredOwner', i: 2, c: 'Wrong ignored replacement' }, + { t: 'ForwardingOwner', i: 3, c: 'Wrong forwarded replacement' }, + ], }), - { _gtt: 'branch-client' } - ); + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'opaqueSlots' }, + { + default: () => [ + h(DirectOwner, null, { + default: (slotScope: typeof scope) => { + directCalls(slotScope); + return slotScope === scope + ? 'Exact scope object' + : 'Synthetic scope object'; + }, + }), + h(IgnoredOwner, null, { + default: () => { + ignoredCalls(); + return 'Never rendered'; + }, + }), + h(ForwardingOwner, null, { + default: (slotScope: typeof scope) => { + forwardedCalls(slotScope); + return h(DeferredReader, { payload: slotScope }); + }, + }), + ], + } + ); + }, + }); + + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('Exact scope object'); + expect(html).toContain('Ignored safely'); + expect(html).toContain('Runtime label'); + expect(html).not.toContain('Wrong'); + expect(directCalls).toHaveBeenCalledOnce(); + expect(directCalls).toHaveBeenCalledWith(scope); + expect(ignoredCalls).not.toHaveBeenCalled(); + expect(forwardedCalls).toHaveBeenCalledOnce(); + expect(forwardedCalls).toHaveBeenCalledWith(scope); + }); + + it('preserves arbitrary scoped named slots without invoking them', async () => { + const ScopedBranch = defineComponent({ + name: 'ScopedBranch', + setup(_props, { slots }) { + return () => slots.one?.({ label: 'Runtime label' }); + }, + }); const plugin = createGT({ loadTranslations: async () => ({ scopedBranch: { t: 'ScopedBranch', i: 1, - d: { b: { one: 'Translated branch' }, t: 'b' }, + c: 'Translated replacement', }, }), }); @@ -354,13 +450,14 @@ describe('gt-vue runtime', () => { const Root = defineComponent({ components: { ScopedBranch, T }, render: compileSfcTemplate( - '' + '' ), }); - await expect(renderWithPlugin(Root, plugin)).resolves.toContain( - 'Translated branch' - ); + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('Runtime label'); + expect(html).not.toContain('Translated replacement'); }); it('reuses explicit source element IDs repeated by a translation', async () => { @@ -394,6 +491,274 @@ describe('gt-vue runtime', () => { expect(html).not.toContain(''); }); + it('keeps stateful component identity through translated reorder and repetition', async () => { + let setupCount = 0; + const Stateful = defineComponent({ + name: 'Stateful', + props: { label: { required: true, type: String } }, + setup(props) { + const initialLabel = props.label; + const instance = ++setupCount; + return () => h('span', `${props.label}:${initialLabel}:${instance}|`); + }, + }); + const plugin = createGT({ + loadTranslations: async (locale) => ({ + identity: + locale === 'fr' + ? [ + { t: 'Stateful', i: 2 }, + { t: 'Stateful', i: 1 }, + ] + : [ + { t: 'Stateful', i: 1 }, + { t: 'Stateful', i: 1 }, + { t: 'Stateful', i: 2 }, + ], + }), + }); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'identity' }, + { + default: () => [ + h(Stateful, { label: 'a' }), + h(Stateful, { label: 'b' }), + ], + } + ); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('a:a:1|b:b:2|'); + await plugin.setLocale('fr'); + await nextTick(); + expect(textContent(mounted.root)).toBe('b:b:2|a:a:1|'); + expect(setupCount).toBe(2); + + await plugin.setLocale('de'); + await nextTick(); + expect(textContent(mounted.root)).toBe('a:a:1|a:a:3|b:b:2|'); + expect(setupCount).toBe(3); + + await plugin.setLocale('fr'); + await nextTick(); + expect(textContent(mounted.root)).toBe('b:b:2|a:a:1|'); + + await plugin.setLocale('en'); + await nextTick(); + expect(textContent(mounted.root)).toBe('a:a:1|b:b:2|'); + mounted.app.unmount(); + }); + + it('preserves explicit string, number, and symbol keys during translation reorder', async () => { + let setupCount = 0; + const symbolKey = Symbol('source-key'); + const Stateful = defineComponent({ + name: 'ExplicitlyKeyedStateful', + props: { label: { required: true, type: String } }, + setup(props) { + const instance = ++setupCount; + return () => h('span', `${props.label}:${instance}|`); + }, + }); + const plugin = createGT({ + loadTranslations: async () => ({ + explicitKeys: [ + { t: 'Stateful', i: 3 }, + { t: 'Stateful', i: 1 }, + { t: 'Stateful', i: 2 }, + ], + }), + }); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'explicitKeys' }, + { + default: () => [ + h(Stateful, { key: 'alpha', label: 'a' }), + h(Stateful, { key: 2, label: 'b' }), + h(Stateful, { key: symbolKey, label: 'c' }), + ], + } + ); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('a:1|b:2|c:3|'); + await plugin.setLocale('fr'); + await nextTick(); + expect(textContent(mounted.root)).toBe('c:3|a:1|b:2|'); + expect(setupCount).toBe(3); + mounted.app.unmount(); + }); + + it('keeps Branch and Plural descendants isolated by named slot', async () => { + let setupCount = 0; + const branch = ref('formal'); + const n = ref(1); + const Stateful = defineComponent({ + name: 'BranchStateful', + props: { label: { required: true, type: String } }, + setup(props) { + const initialLabel = props.label; + const instance = ++setupCount; + return () => h('span', `${props.label}:${initialLabel}:${instance}|`); + }, + }); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => [ + h( + Branch, + { branch: branch.value }, + { + casual: () => h(Stateful, { label: 'casual' }), + formal: () => h(Stateful, { label: 'formal' }), + } + ), + h( + Plural, + { n: n.value }, + { + one: () => h(Stateful, { label: 'one' }), + other: () => h(Stateful, { label: 'other' }), + } + ), + ], + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('formal:formal:1|one:one:2|'); + branch.value = 'casual'; + n.value = 2; + await nextTick(); + expect(textContent(mounted.root)).toBe('casual:casual:3|other:other:4|'); + expect(setupCount).toBe(4); + mounted.app.unmount(); + }); + + it('rebuilds Suspense content instead of retaining stale normalized children', async () => { + const fallbackCalls = vi.fn(); + const plugin = createGT({ + loadTranslations: async () => ({ + suspense: { + t: 'Suspense', + i: 1, + c: { t: 'span', i: 2, c: 'TARGET' }, + }, + }), + }); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'suspense' }, + { + default: () => + h(Suspense, null, { + default: () => h('span', 'SOURCE'), + fallback: () => { + fallbackCalls(); + return h('span', 'FALLBACK'); + }, + }), + } + ); + }, + }); + + const sourceHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(sourceHtml).toContain('SOURCE'); + expect(fallbackCalls).toHaveBeenCalledTimes(1); + + await plugin.setLocale('fr'); + const targetHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + + expect(targetHtml).toContain('TARGET'); + expect(targetHtml).not.toContain('SOURCE'); + expect(fallbackCalls).toHaveBeenCalledTimes(2); + }); + + it('preserves an async Suspense fallback before rendering translated content', async () => { + const fallbackCalls = vi.fn(); + let resolveGate!: () => void; + const gate = new Promise((resolve) => { + resolveGate = resolve; + }); + const AsyncGate = defineComponent({ + name: 'AsyncGate', + async setup() { + await gate; + return () => h('i', 'READY'); + }, + }); + const plugin = createGT({ + loadTranslations: async () => ({ + asyncSuspense: { + t: 'Suspense', + i: 1, + c: { + t: 'div', + i: 2, + c: [ + { t: 'AsyncGate', i: 3 }, + { t: 'span', i: 4, c: 'TARGET' }, + ], + }, + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'asyncSuspense' }, + { + default: () => + h(Suspense, null, { + default: () => h('div', [h(AsyncGate), h('span', 'SOURCE')]), + fallback: () => { + fallbackCalls(); + return h('span', 'FALLBACK'); + }, + }), + } + ); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('FALLBACK'); + expect(fallbackCalls).toHaveBeenCalledOnce(); + resolveGate(); + await gate; + await nextTick(); + await nextTick(); + expect(textContent(mounted.root)).toBe('READYTARGET'); + mounted.app.unmount(); + }); + it('selects source plural values with the source locale and target branches with the active locale', async () => { const variable = { i: 2, k: '_gt_value_2', v: 'v' } as const; const target: JsxChildren = { @@ -939,6 +1304,10 @@ const renderer = createRenderer({ createElement: (type) => createHostNode(type), createText: (text) => createHostNode('#text', text), insert(child, parent, anchor) { + if (child.parent) { + const previousIndex = child.parent.children.indexOf(child); + if (previousIndex >= 0) child.parent.children.splice(previousIndex, 1); + } child.parent = parent; const index = anchor ? parent.children.indexOf(anchor) : -1; if (index < 0) parent.children.push(child); @@ -957,6 +1326,7 @@ const renderer = createRenderer({ if (!child.parent) return; const index = child.parent.children.indexOf(child); if (index >= 0) child.parent.children.splice(index, 1); + child.parent = undefined; }, setElementText(element, text) { const child = createHostNode('#text', text); @@ -984,6 +1354,7 @@ function mount( } function textContent(node: HostNode): string { + if (node.type === '#comment') return ''; return node.text ?? node.children.map(textContent).join(''); } diff --git a/packages/vue/src/components/T.ts b/packages/vue/src/components/T.ts index 67ee793a78..433b46e1c2 100644 --- a/packages/vue/src/components/T.ts +++ b/packages/vue/src/components/T.ts @@ -39,13 +39,21 @@ export const T = withGTMetadata( }, setup(props, { attrs, slots }) { const state = useGTState(); + // Translation IDs can reorder or repeat source VNodes. Stable Symbols + // preserve component identity without colliding with user-provided keys. + const identityCache = new Map(); return () => - translateVueChildren(slots.default?.() ?? [], state, { - ...props, - ...(typeof attrs.$context === 'string' && { - $context: attrs.$context, - }), - }); + translateVueChildren( + slots.default?.() ?? [], + state, + { + ...props, + ...(typeof attrs.$context === 'string' && { + $context: attrs.$context, + }), + }, + identityCache + ); }, }), 'translate-client' diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 568462d00c..4ea52aa865 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -16,13 +16,13 @@ import { hashSource } from 'generaltranslation/id'; import { Comment, Fragment, + Suspense, Text, cloneVNode, h, isVNode, mergeProps, type Component, - type Slot, type Slots, type VNode, type VNodeChild, @@ -43,6 +43,7 @@ type SourceElement = { branches: Record; children: SourceNode[]; id: number; + identity: string; opaque: boolean; transformation: Transformation; variableName?: string; @@ -65,19 +66,29 @@ type RichTranslationOptions = { context?: string; }; +type TranslationIdentityCache = Map; + type VNodeWithRenderMetadata = VNode & { ctx?: unknown; slotScopeIds?: string[] | null; + ssContent?: VNode; + ssFallback?: VNode; }; export function translateVueChildren( children: VNode[], state: GTState, - options: RichTranslationOptions + options: RichTranslationOptions, + identityCache: TranslationIdentityCache = new Map() ): VNodeChild { const source = createSourceNodes(children); if (state.locale.value === state.defaultLocale) { - return renderDefaultNodes(source, state, state.defaultLocale); + return renderDefaultNodes( + source, + state, + identityCache, + state.defaultLocale + ); } const hash = options._hash ?? @@ -88,23 +99,29 @@ export function translateVueChildren( }); const target = state.getCatalog()[hash]; if (target == null) { - return renderDefaultNodes(source, state, state.defaultLocale); + return renderDefaultNodes( + source, + state, + identityCache, + state.defaultLocale + ); } - return renderNodes(source, target, state); + return renderNodes(source, target, state, identityCache); } function createSourceNodes(children: unknown): SourceNode[] { const index = { value: 0 }; - return visitChildren(children, index); + return visitChildren(children, index, 'root'); } function visitChildren( children: unknown, - index: { value: number } + index: { value: number }, + identityScope: string ): SourceNode[] { if (Array.isArray(children)) { return mergeAdjacentStrings( - children.flatMap((child) => visitChildren(child, index)) + children.flatMap((child) => visitChildren(child, index, identityScope)) ); } if (children == null || typeof children === 'boolean') return []; @@ -112,25 +129,33 @@ function visitChildren( if (children.type === Comment) return []; if (children.type === Text) return [String(children.children ?? '')]; if (children.type === Fragment) { - return visitChildren(children.children, index); + return visitChildren(children.children, index, identityScope); } index.value += 1; const id = index.value; + const identity = `${identityScope}/e:${id}`; const metadata = getGTMetadata(children); const transformation = getTransformation(metadata); const variable = transformation === 'variable' ? getVariable(metadata, id) : undefined; const defaultSlot = variable ? { children: undefined, opaque: false } - : readDefaultSlot(children); + : readDefaultSlot(children, transformation); const source: SourceElement = { branches: {}, children: variable || defaultSlot.opaque ? [] - : visitChildren(defaultSlot.children, index), + : visitChildren( + defaultSlot.children, + index, + transformation === 'branch' || transformation === 'plural' + ? `${identity}/default` + : identity + ), id, + identity, opaque: defaultSlot.opaque, transformation, variableName: variable?.name, @@ -139,7 +164,7 @@ function visitChildren( }; if (transformation === 'branch' || transformation === 'plural') { - source.branches = getBranches(children, transformation, id); + source.branches = getBranches(children, transformation, id, identity); } return [source]; } @@ -174,60 +199,43 @@ function getVariable( }; } -const SCOPED_SLOT_ACCESS = Symbol('gt-vue scoped slot access'); - /** - * Reads a default slot only while it behaves like source-owned static content. + * Reads source-owned content without speculatively invoking user components. * - * Vue's compiled scoped-slot wrappers erase the original function arity, so - * they cannot be identified from `slot.length`. A throwing proxy detects the - * first read of slot props without inventing runtime values. Such slots stay - * opaque and are later rendered by their owning component with real props. + * Vue represents both scoped and unscoped component slots as indistinguishable + * functions. Calling an arbitrary slot to discover which kind it is can run + * ignored slots, duplicate side effects, or let synthetic props escape. Keep + * custom component slots opaque and traverse only GT-owned slots whose + * no-argument contract is known. Suspense is read from Vue's already-normalized + * content so its slot is not invoked a second time. */ -function readDefaultSlot(vnode: VNode): { +function readDefaultSlot( + vnode: VNode, + transformation: Transformation +): { children: unknown; opaque: boolean; } { + if (vnode.type === Suspense) { + return { + children: (vnode as VNodeWithRenderMetadata).ssContent, + opaque: false, + }; + } + if (transformation === undefined && typeof vnode.type !== 'string') { + return { children: undefined, opaque: true }; + } if (!isSlots(vnode.children)) { return { children: vnode.children, opaque: false }; } - return readSlot(vnode.children.default); -} - -/** Safely probes one compiled slot without supplying invented slot props. */ -function readSlot(slot?: Slot): { children: unknown; opaque: boolean } { - if (!slot) return { children: undefined, opaque: false }; - - const access = { detected: false }; - const detectAccess = () => { - access.detected = true; - throw SCOPED_SLOT_ACCESS; - }; - const probe = new Proxy(Object.create(null) as object, { - get: detectAccess, - getOwnPropertyDescriptor: detectAccess, - getPrototypeOf: detectAccess, - has: detectAccess, - ownKeys: detectAccess, - }); - - try { - const slotChildren = slot(probe); - return access.detected - ? { children: undefined, opaque: true } - : { children: slotChildren, opaque: false }; - } catch (error) { - if (access.detected || error === SCOPED_SLOT_ACCESS) { - return { children: undefined, opaque: true }; - } - throw error; - } + return { children: vnode.children.default?.(), opaque: false }; } function getBranches( vnode: VNode, transformation: 'branch' | 'plural', - branchElementId: number + branchElementId: number, + identity: string ): Record { const inputs = Object.create(null) as Record; if (isSlots(vnode.children)) { @@ -237,8 +245,7 @@ function getBranches( !key.startsWith('_') && typeof slot === 'function' ) { - const branch = readSlot(slot); - if (!branch.opaque) inputs[key] = branch.children; + inputs[key] = slot(); } } } @@ -261,7 +268,11 @@ function getBranches( // siblings, matching the React renderer. .map(([key, value]) => [ key, - visitChildren(value, { value: branchElementId }), + visitChildren( + value, + { value: branchElementId }, + `${identity}/branch:${key.length}:${key}` + ), ]) ); } @@ -332,12 +343,13 @@ function getElementName(vnode: VNode, id: number): string { function renderNodes( source: SourceNode[], target: JsxChildren | undefined, - state: GTState + state: GTState, + identityCache: TranslationIdentityCache ): VNodeChild { if (target == null) { // A partial translated tree falls back within the active locale. A wholly // missing catalog entry is handled above using the source/default locale. - return renderDefaultNodes(source, state, state.locale.value); + return renderDefaultNodes(source, state, identityCache, state.locale.value); } if (typeof target === 'string') return target; @@ -355,12 +367,20 @@ function renderNodes( ); const ordinaryById = new Map(ordinary.map((node) => [node.id, node])); const fallback = [...ordinary]; + const occurrences = new Map(); return targets.map((targetNode) => { if (typeof targetNode === 'string') return targetNode; if (isVariable(targetNode)) { const variable = variables.get(targetNode.k); - return variable ? renderDefaultNode(variable, state) : null; + return variable + ? keySourceResult( + variable, + renderDefaultNode(variable, state, identityCache), + occurrences, + identityCache + ) + : null; } // An explicit target ID is a reusable reference, while order-based @@ -369,26 +389,76 @@ function renderNodes( const sourceNode = (targetNode.i == null ? undefined : ordinaryById.get(targetNode.i)) ?? fallback.shift(); - return sourceNode ? renderElement(sourceNode, targetNode, state) : null; + return sourceNode + ? keySourceResult( + sourceNode, + renderElement(sourceNode, targetNode, state, identityCache), + occurrences, + identityCache + ) + : null; }); } +/** + * Gives every source-backed sibling a stable reconciliation identity. + * + * Catalogs may reorder, omit, or repeat a source ID. The occurrence suffix + * keeps repeated references unique, while the first occurrence retains the + * same key as the default tree. Explicit user keys remain authoritative; + * generated Symbols cannot collide with them. + */ +function keySourceResult( + source: SourceElement, + rendered: VNodeChild, + occurrences: Map, + identityCache: TranslationIdentityCache +): VNode { + const occurrence = occurrences.get(source) ?? 0; + occurrences.set(source, occurrence + 1); + + if (occurrence === 0 && isVNode(rendered) && rendered.key != null) { + return rendered; + } + + const cacheKey = `${source.identity}/occurrence:${occurrence}`; + let key = identityCache.get(cacheKey); + if (!key) { + key = Symbol(cacheKey); + identityCache.set(cacheKey, key); + } + + if (isVNode(rendered)) { + const cloned = cloneVNode(rendered); + cloned.key = key; + return cloned; + } + + const children = + rendered == null ? [] : Array.isArray(rendered) ? rendered : [rendered]; + return h(Fragment, { key }, children); +} + function renderElement( source: SourceElement, target: JsxElement, - state: GTState + state: GTState, + identityCache: TranslationIdentityCache ): VNodeChild { if (source.transformation === 'branch') { const branch = getBranchKey(source.vnode); return renderNodes( getSelectedSourceBranch(source, branch), getSelectedTargetBranch(target, branch), - state + state, + identityCache ); } if (source.transformation === 'plural') { const n = source.vnode.props?.n; - if (typeof n !== 'number') return renderDefaultNode(source, state); + if (typeof n !== 'number') { + return renderDefaultNode(source, state, identityCache); + } const sourceBranch = getPluralKey( n, Object.keys(source.branches), @@ -407,11 +477,12 @@ function renderElement( return renderNodes( getSelectedSourceBranch(source, sourceBranch), (targetBranch && targetBranches[targetBranch]) ?? target.c, - state + state, + identityCache ); } if (source.transformation === 'fragment') { - return renderNodes(source.children, target.c, state); + return renderNodes(source.children, target.c, state, identityCache); } if (source.opaque) { const translatedProps = getTranslatedProps(target); @@ -423,12 +494,12 @@ function renderElement( if (target.c == null) { return Object.keys(translatedProps).length ? cloneWithProps(source.vnode, translatedProps) - : renderDefaultNode(source, state); + : renderDefaultNode(source, state, identityCache); } return cloneWithChildren( source.vnode, - renderNodes(source.children, target.c, state), + renderNodes(source.children, target.c, state, identityCache), translatedProps ); } @@ -505,14 +576,26 @@ function getTranslatedProps(target: JsxElement): Record { function renderDefaultNodes( nodes: SourceNode[], state: GTState, + identityCache: TranslationIdentityCache, locale = state.defaultLocale ): VNodeChild[] { - return nodes.map((node) => renderDefaultNode(node, state, locale)); + const occurrences = new Map(); + return nodes.map((node) => + typeof node === 'string' + ? node + : keySourceResult( + node, + renderDefaultNode(node, state, identityCache, locale), + occurrences, + identityCache + ) + ); } function renderDefaultNode( node: SourceNode, state: GTState, + identityCache: TranslationIdentityCache, locale?: string ): VNodeChild { if (typeof node === 'string') return node; @@ -529,19 +612,20 @@ function renderDefaultNode( : node.vnode; } if (node.transformation === 'fragment') { - return renderDefaultNodes(node.children, state, locale); + return renderDefaultNodes(node.children, state, identityCache, locale); } if (node.transformation === 'branch') { return renderDefaultNodes( getSelectedSourceBranch(node, getBranchKey(node.vnode)), state, + identityCache, locale ); } if (node.transformation === 'plural') { const n = node.vnode.props?.n; if (typeof n !== 'number') { - return renderDefaultNodes(node.children, state, locale); + return renderDefaultNodes(node.children, state, identityCache, locale); } const branch = getPluralKey( n, @@ -554,13 +638,14 @@ function renderDefaultNode( return renderDefaultNodes( getSelectedSourceBranch(node, branch), state, + identityCache, locale ); } if (!node.children.length) return node.vnode; return cloneWithChildren( node.vnode, - renderDefaultNodes(node.children, state, locale) + renderDefaultNodes(node.children, state, identityCache, locale) ); } @@ -580,18 +665,28 @@ function cloneWithChildren( // change an element from array children to scalar text, so create a fresh // element with normalized children and copy only the render metadata that // must survive reconstruction. - cloned.appContext = vnode.appContext; - (cloned as VNodeWithRenderMetadata).ctx = ( - vnode as VNodeWithRenderMetadata - ).ctx; - cloned.dirs = vnode.dirs; - cloned.ref = vnode.ref; - cloned.scopeId = vnode.scopeId; - (cloned as VNodeWithRenderMetadata).slotScopeIds = ( - vnode as VNodeWithRenderMetadata - ).slotScopeIds; - cloned.transition = vnode.transition; - return cloned; + return copyRenderMetadata(cloned, vnode); + } + + if (vnode.type === Suspense) { + const props = Object.keys(extraProps).length + ? mergeProps(vnode.props ?? {}, extraProps) + : vnode.props; + const slots = isSlots(vnode.children) ? vnode.children : {}; + const normalizedFallback = (vnode as VNodeWithRenderMetadata).ssFallback; + const cloned = h(vnode.type, props, { + ...slots, + default: () => children, + // Vue already invoked and normalized the fallback when it created the + // source Suspense VNode. Reuse that VNode instead of running user slot + // code a second time while rebuilding the translated boundary. + ...(normalizedFallback && { fallback: () => normalizedFallback }), + }); + + // Vue's VNode clone path preserves already-normalized ssContent and + // ssFallback from the source. Reconstructing from the public Suspense type + // recomputes both branches from the replacement slots. + return copyRenderMetadata(cloned, vnode); } // Components need their original slot set and identity. cloneVNode cannot @@ -609,6 +704,22 @@ function cloneWithChildren( }); } +/** Copies render metadata without retaining mounted or normalized child state. */ +function copyRenderMetadata(cloned: VNode, source: VNode): VNode { + cloned.appContext = source.appContext; + (cloned as VNodeWithRenderMetadata).ctx = ( + source as VNodeWithRenderMetadata + ).ctx; + cloned.dirs = source.dirs; + cloned.ref = source.ref; + cloned.scopeId = source.scopeId; + (cloned as VNodeWithRenderMetadata).slotScopeIds = ( + source as VNodeWithRenderMetadata + ).slotScopeIds; + cloned.transition = source.transition; + return cloned; +} + function cloneWithProps( vnode: VNode, extraProps: Record From 40905c61077f6742065d1b9b0c77fc08dafe3110 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Tue, 4 Aug 2026 19:52:37 -0700 Subject: [PATCH 17/28] fix(vue): preserve keyed fragment identity --- packages/vue/src/__tests__/runtime.test.ts | 175 ++++++++++++++++++ packages/vue/src/components/T.ts | 7 +- .../vue/src/rendering/translateVueChildren.ts | 154 ++++++++++++--- 3 files changed, 313 insertions(+), 23 deletions(-) diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 8d7a9def37..6d2a9438ed 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -601,6 +601,181 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('preserves keys on GT transformations that render fragment children', async () => { + let setupCount = 0; + const order = ref(['a', 'b']); + const Stateful = defineComponent({ + name: 'KeyedBranchStateful', + props: { label: { required: true, type: String } }, + setup(props) { + const initialLabel = props.label; + const instance = ++setupCount; + return () => h('span', `${props.label}:${initialLabel}:${instance}|`); + }, + }); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => + order.value.map((label) => + h( + Branch, + { key: label, branch: 'show' }, + { show: () => h(Stateful, { label }) } + ) + ), + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('a:a:1|b:b:2|'); + order.value = ['b', 'a']; + await nextTick(); + expect(textContent(mounted.root)).toBe('b:b:2|a:a:1|'); + expect(setupCount).toBe(2); + mounted.app.unmount(); + }); + + it('anchors descendant identity to keyed native containers', async () => { + let setupCount = 0; + const order = ref(['a', 'b']); + const Stateful = defineComponent({ + name: 'KeyedContainerStateful', + props: { label: { required: true, type: String } }, + setup(props) { + const initialLabel = props.label; + const instance = ++setupCount; + return () => h('span', `${props.label}:${initialLabel}:${instance}|`); + }, + }); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => + order.value.map((label) => + h('section', { key: label }, [h(Stateful, { label })]) + ), + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('a:a:1|b:b:2|'); + order.value = ['b', 'a']; + await nextTick(); + expect(textContent(mounted.root)).toBe('b:b:2|a:a:1|'); + expect(setupCount).toBe(2); + mounted.app.unmount(); + }); + + it('does not let keyed siblings shift unkeyed component identity', async () => { + let setupCount = 0; + const showKeyedSibling = ref(true); + const Stateful = defineComponent({ + name: 'UnkeyedSiblingStateful', + setup() { + const instance = ++setupCount; + return () => h('span', `${instance}|`); + }, + }); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => [ + ...(showKeyedSibling.value + ? [h('i', { key: 'fixed' }, 'keyed')] + : []), + h(Stateful), + ], + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('keyed1|'); + showKeyedSibling.value = false; + await nextTick(); + expect(textContent(mounted.root)).toBe('1|'); + expect(setupCount).toBe(1); + mounted.app.unmount(); + }); + + it('matches unkeyed components by VNode type across unrelated siblings', async () => { + let setupCount = 0; + const showUnrelatedSibling = ref(true); + const Stateful = defineComponent({ + name: 'TypeMatchedStateful', + setup() { + const instance = ++setupCount; + return () => h('span', `${instance}|`); + }, + }); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => [ + ...(showUnrelatedSibling.value ? [h('i', 'unrelated')] : []), + h(Stateful), + ], + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('unrelated1|'); + showUnrelatedSibling.value = false; + await nextTick(); + expect(textContent(mounted.root)).toBe('1|'); + expect(setupCount).toBe(1); + mounted.app.unmount(); + }); + + it('retains keyed Fragment scopes while keeping their wire shape flat', async () => { + let setupCount = 0; + const order = ref(['a', 'b']); + const Stateful = defineComponent({ + name: 'KeyedFragmentStateful', + props: { label: { required: true, type: String } }, + setup(props) { + const initialLabel = props.label; + const instance = ++setupCount; + return () => h('span', `${props.label}:${initialLabel}:${instance}|`); + }, + }); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + return () => + h(T, null, { + default: () => + order.value.map((label) => + h(Fragment, { key: label }, [ + h(Stateful, { key: 'shared-child-key', label }), + h('i', `${label}!`), + ]) + ), + }); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('a:a:1|a!b:b:2|b!'); + order.value = ['b', 'a']; + await nextTick(); + expect(textContent(mounted.root)).toBe('b:b:2|b!a:a:1|a!'); + expect(setupCount).toBe(2); + mounted.app.unmount(); + }); + it('keeps Branch and Plural descendants isolated by named slot', async () => { let setupCount = 0; const branch = ref('formal'); diff --git a/packages/vue/src/components/T.ts b/packages/vue/src/components/T.ts index 433b46e1c2..5a78cf12a0 100644 --- a/packages/vue/src/components/T.ts +++ b/packages/vue/src/components/T.ts @@ -1,5 +1,8 @@ import { defineComponent } from 'vue'; -import { translateVueChildren } from '../rendering/translateVueChildren'; +import { + createTranslationIdentityCache, + translateVueChildren, +} from '../rendering/translateVueChildren'; import { useGTState } from '../runtime/state'; import { withGTMetadata } from './utils'; @@ -41,7 +44,7 @@ export const T = withGTMetadata( const state = useGTState(); // Translation IDs can reorder or repeat source VNodes. Stable Symbols // preserve component identity without colliding with user-provided keys. - const identityCache = new Map(); + const identityCache = createTranslationIdentityCache(); return () => translateVueChildren( slots.default?.() ?? [], diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 4ea52aa865..a495161684 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -45,6 +45,7 @@ type SourceElement = { id: number; identity: string; opaque: boolean; + preserveExplicitKey: boolean; transformation: Transformation; variableName?: string; variableType?: VariableType; @@ -66,7 +67,19 @@ type RichTranslationOptions = { context?: string; }; -type TranslationIdentityCache = Map; +/** Runtime-only reconciliation state owned by one mounted `T` instance. */ +export type TranslationIdentityCache = { + /** Stable scope tokens for user-authored Vue keys, without coercion. */ + explicitScopes: Map; + /** Stable Vue keys generated for source identities and repeated references. */ + generatedKeys: Map; + /** Monotonic token source for explicit keys first observed by this `T`. */ + nextExplicitScope: number; + /** Monotonic token source for VNode types first observed by this `T`. */ + nextTypeScope: number; + /** Stable scope tokens for component objects, functions, and native tags. */ + typeScopes: Map; +}; type VNodeWithRenderMetadata = VNode & { ctx?: unknown; @@ -79,9 +92,9 @@ export function translateVueChildren( children: VNode[], state: GTState, options: RichTranslationOptions, - identityCache: TranslationIdentityCache = new Map() + identityCache: TranslationIdentityCache = createTranslationIdentityCache() ): VNodeChild { - const source = createSourceNodes(children); + const source = createSourceNodes(children, identityCache); if (state.locale.value === state.defaultLocale) { return renderDefaultNodes( source, @@ -109,19 +122,45 @@ export function translateVueChildren( return renderNodes(source, target, state, identityCache); } -function createSourceNodes(children: unknown): SourceNode[] { +/** Creates the per-T reconciliation cache shared by each reactive render. */ +export function createTranslationIdentityCache(): TranslationIdentityCache { + return { + explicitScopes: new Map(), + generatedKeys: new Map(), + nextExplicitScope: 0, + nextTypeScope: 0, + typeScopes: new Map(), + }; +} + +function createSourceNodes( + children: unknown, + identityCache: TranslationIdentityCache +): SourceNode[] { const index = { value: 0 }; - return visitChildren(children, index, 'root'); + return visitChildren(children, index, 'root', identityCache); } function visitChildren( children: unknown, index: { value: number }, - identityScope: string + identityScope: string, + identityCache: TranslationIdentityCache, + identityOccurrences: Map = new Map(), + transparentKeyScope = false ): SourceNode[] { if (Array.isArray(children)) { return mergeAdjacentStrings( - children.flatMap((child) => visitChildren(child, index, identityScope)) + children.flatMap((child) => + visitChildren( + child, + index, + identityScope, + identityCache, + identityOccurrences, + transparentKeyScope + ) + ) ); } if (children == null || typeof children === 'boolean') return []; @@ -129,12 +168,41 @@ function visitChildren( if (children.type === Comment) return []; if (children.type === Text) return [String(children.children ?? '')]; if (children.type === Fragment) { - return visitChildren(children.children, index, identityScope); + if (children.key != null) { + return visitChildren( + children.children, + index, + getExplicitIdentityScope(identityScope, children.key, identityCache), + identityCache, + new Map(), + true + ); + } + return visitChildren( + children.children, + index, + identityScope, + identityCache, + identityOccurrences, + transparentKeyScope + ); } index.value += 1; const id = index.value; - const identity = `${identityScope}/e:${id}`; + let identity: string; + if (children.key == null) { + const typeScope = getVNodeTypeScope(children.type, identityCache); + const occurrence = (identityOccurrences.get(typeScope) ?? 0) + 1; + identityOccurrences.set(typeScope, occurrence); + identity = `${identityScope}/${typeScope}/o:${occurrence}`; + } else { + identity = getExplicitIdentityScope( + identityScope, + children.key, + identityCache + ); + } const metadata = getGTMetadata(children); const transformation = getTransformation(metadata); const variable = @@ -152,11 +220,13 @@ function visitChildren( index, transformation === 'branch' || transformation === 'plural' ? `${identity}/default` - : identity + : identity, + identityCache ), id, identity, opaque: defaultSlot.opaque, + preserveExplicitKey: !transparentKeyScope, transformation, variableName: variable?.name, variableType: variable?.type, @@ -164,11 +234,46 @@ function visitChildren( }; if (transformation === 'branch' || transformation === 'plural') { - source.branches = getBranches(children, transformation, id, identity); + source.branches = getBranches( + children, + transformation, + id, + identity, + identityCache + ); } return [source]; } +/** Gives each distinct Vue VNode type a stable per-T identity token. */ +function getVNodeTypeScope( + type: unknown, + identityCache: TranslationIdentityCache +): string { + let scope = identityCache.typeScopes.get(type); + if (!scope) { + identityCache.nextTypeScope += 1; + scope = `t:${identityCache.nextTypeScope}`; + identityCache.typeScopes.set(type, scope); + } + return scope; +} + +/** Anchors descendant identity to an explicit Vue key without string coercion. */ +function getExplicitIdentityScope( + parentScope: string, + key: PropertyKey, + identityCache: TranslationIdentityCache +): string { + let scope = identityCache.explicitScopes.get(key); + if (!scope) { + identityCache.nextExplicitScope += 1; + scope = `k:${identityCache.nextExplicitScope}`; + identityCache.explicitScopes.set(key, scope); + } + return `${parentScope}/${scope}`; +} + function getGTMetadata(vnode: VNode): string | undefined { if (typeof vnode.type !== 'function' && typeof vnode.type !== 'object') { return undefined; @@ -235,7 +340,8 @@ function getBranches( vnode: VNode, transformation: 'branch' | 'plural', branchElementId: number, - identity: string + identity: string, + identityCache: TranslationIdentityCache ): Record { const inputs = Object.create(null) as Record; if (isSlots(vnode.children)) { @@ -271,7 +377,8 @@ function getBranches( visitChildren( value, { value: branchElementId }, - `${identity}/branch:${key.length}:${key}` + `${identity}/branch:${key.length}:${key}`, + identityCache ), ]) ); @@ -417,18 +524,23 @@ function keySourceResult( const occurrence = occurrences.get(source) ?? 0; occurrences.set(source, occurrence + 1); - if (occurrence === 0 && isVNode(rendered) && rendered.key != null) { - return rendered; - } - + const explicitKey = + occurrence === 0 && source.preserveExplicitKey ? source.vnode.key : null; const cacheKey = `${source.identity}/occurrence:${occurrence}`; - let key = identityCache.get(cacheKey); - if (!key) { - key = Symbol(cacheKey); - identityCache.set(cacheKey, key); + let key: PropertyKey; + if (explicitKey != null) { + key = explicitKey; + } else { + let generatedKey = identityCache.generatedKeys.get(cacheKey); + if (!generatedKey) { + generatedKey = Symbol(cacheKey); + identityCache.generatedKeys.set(cacheKey, generatedKey); + } + key = generatedKey; } if (isVNode(rendered)) { + if (rendered.key === key) return rendered; const cloned = cloneVNode(rendered); cloned.key = key; return cloned; From 2a72169a586a7a70613470d90a88783757322302 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Tue, 4 Aug 2026 20:48:47 -0700 Subject: [PATCH 18/28] fix(vue): render text Suspense roots --- packages/vue/src/__tests__/runtime.test.ts | 90 +++++++++++++++++++ .../vue/src/rendering/translateVueChildren.ts | 17 +++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 6d2a9438ed..1ca33bf0d3 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -873,6 +873,96 @@ describe('gt-vue runtime', () => { expect(fallbackCalls).toHaveBeenCalledTimes(2); }); + it('renders Vue-compiled text-only Suspense source content in SSR', async () => { + const plugin = createGT({ loadTranslations: async () => ({}) }); + const Root = defineComponent({ + components: { T }, + render: compileSfcTemplate('Hello world'), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const defaultHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(defaultHtml).toContain('Hello world'); + + await plugin.setLocale('fr'); + const fallbackHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(fallbackHtml).toContain('Hello world'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('renders translated text-only Suspense content in SSR', async () => { + const plugin = createGT({ + loadTranslations: async () => ({ + textSuspense: { + t: 'Suspense', + i: 1, + c: ['Bonjour le monde'], + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + components: { T }, + render: compileSfcTemplate( + 'Hello world' + ), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const html = stripFragmentMarkers(await renderWithPlugin(Root, plugin)); + expect(html).toContain('Bonjour le monde'); + expect(html).not.toContain('Hello world'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('updates Vue-compiled text-only Suspense content on the client', async () => { + const plugin = createGT({ + loadTranslations: async () => ({ + textSuspense: { + t: 'Suspense', + i: 1, + c: ['Bonjour le monde'], + }, + }), + }); + const Root = defineComponent({ + components: { T }, + render: compileSfcTemplate( + 'Hello world' + ), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const mounted = mount(Root, plugin); + + try { + expect(textContent(mounted.root)).toBe('Hello world'); + + await plugin.setLocale('fr'); + await nextTick(); + expect(textContent(mounted.root)).toBe('Bonjour le monde'); + + await plugin.setLocale('en'); + await nextTick(); + expect(textContent(mounted.root)).toBe('Hello world'); + expect(warn).not.toHaveBeenCalled(); + } finally { + mounted.app.unmount(); + warn.mockRestore(); + } + }); + it('preserves an async Suspense fallback before rendering translated content', async () => { const fallbackCalls = vi.fn(); let resolveGate!: () => void; diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index a495161684..338fdeea90 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -788,7 +788,7 @@ function cloneWithChildren( const normalizedFallback = (vnode as VNodeWithRenderMetadata).ssFallback; const cloned = h(vnode.type, props, { ...slots, - default: () => children, + default: () => unwrapSingleSuspenseChild(children), // Vue already invoked and normalized the fallback when it created the // source Suspense VNode. Reuse that VNode instead of running user slot // code a second time while rebuilding the translated boundary. @@ -816,6 +816,21 @@ function cloneWithChildren( }); } +/** + * Returns a single rebuilt Suspense root without an artificial array wrapper. + * + * Vue accepts a primitive returned directly from a Suspense slot and + * normalizes it into a Text VNode. Inside an array, however, that same + * primitive fails Vue's single-root check and becomes an empty comment. Rich + * rendering naturally produces child arrays, so unwrap only the singleton + * case and leave genuinely multi-root content for Vue to validate. + */ +function unwrapSingleSuspenseChild(children: VNodeChild): VNodeChild { + return Array.isArray(children) && children.length === 1 + ? children[0] + : children; +} + /** Copies render metadata without retaining mounted or normalized child state. */ function copyRenderMetadata(cloned: VNode, source: VNode): VNode { cloned.appContext = source.appContext; From bf0c006db4900f555d33ba3e22e11655a4f8428b Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Tue, 4 Aug 2026 21:00:31 -0700 Subject: [PATCH 19/28] fix(vue): stabilize translated Suspense roots --- packages/vue/src/__tests__/runtime.test.ts | 208 ++++++++++++++++++ .../vue/src/rendering/translateVueChildren.ts | 31 ++- 2 files changed, 228 insertions(+), 11 deletions(-) diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 1ca33bf0d3..c16d405fb5 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -963,6 +963,214 @@ describe('gt-vue runtime', () => { } }); + it('preserves Vue-compiled Fragment roots while rebuilding Suspense', async () => { + const source: JsxChildren = { + t: 'Suspense', + i: 1, + c: [ + { t: 'span', i: 2, c: 'A' }, + { t: 'span', i: 3, c: 'B' }, + ], + }; + const plugin = createGT({ + loadTranslations: async () => ({ + [jsxHash(source)]: { + t: 'Suspense', + i: 1, + c: [ + { t: 'span', i: 2, c: 'C' }, + { t: 'span', i: 3, c: 'D' }, + ], + }, + }), + }); + const Root = defineComponent({ + components: { T }, + render: compileSfcTemplate( + '' + ), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const sourceHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(sourceHtml).toContain('AB'); + + await plugin.setLocale('fr'); + const translatedHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(translatedHtml).toContain('CD'); + expect(translatedHtml).not.toContain('A'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('rebuilds nested Suspense roots without rerunning fallback slots', async () => { + const outerFallback = vi.fn(() => h('b', 'Outer fallback')); + const innerFallback = vi.fn(() => h('i', 'Inner fallback')); + const plugin = createGT({ + loadTranslations: async () => ({ + nestedSuspense: { + t: 'Suspense', + i: 1, + c: { + t: 'Suspense', + i: 2, + c: [ + { t: 'span', i: 3, c: 'C' }, + { t: 'span', i: 4, c: 'D' }, + ], + }, + }, + }), + }); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'nestedSuspense' }, + { + default: () => + h(Suspense, null, { + default: () => + h(Suspense, null, { + default: () => + h(Fragment, null, [h('span', 'A'), h('span', 'B')]), + fallback: innerFallback, + }), + fallback: outerFallback, + }), + } + ); + }, + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const sourceHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(sourceHtml).toContain('AB'); + expect(outerFallback).toHaveBeenCalledTimes(1); + expect(innerFallback).toHaveBeenCalledTimes(1); + + await plugin.setLocale('fr'); + const translatedHtml = stripFragmentMarkers( + await renderWithPlugin(Root, plugin) + ); + expect(translatedHtml).toContain('CD'); + expect(outerFallback).toHaveBeenCalledTimes(2); + expect(innerFallback).toHaveBeenCalledTimes(2); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('renders repeated translated Suspense roots through a Fragment', async () => { + const plugin = createGT({ + loadTranslations: async () => ({ + repeatedSuspense: { + t: 'Suspense', + i: 1, + c: [ + { t: 'span', i: 2, c: 'Premier' }, + { t: 'span', i: 2, c: 'Deuxième' }, + ], + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + components: { T }, + render: compileSfcTemplate( + 'Source' + ), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + let mounted: ReturnType | undefined; + + try { + const html = stripFragmentMarkers(await renderWithPlugin(Root, plugin)); + expect(html).toContain('PremierDeuxième'); + + mounted = mount(Root, plugin); + expect(textContent(mounted.root)).toBe('PremierDeuxième'); + await plugin.setLocale('en'); + await nextTick(); + expect(textContent(mounted.root)).toBe('Source'); + await plugin.setLocale('fr'); + await nextTick(); + expect(textContent(mounted.root)).toBe('PremierDeuxième'); + expect(warn).not.toHaveBeenCalled(); + } finally { + mounted?.app.unmount(); + warn.mockRestore(); + } + }); + + it('preserves the first repeated Suspense root across locale transitions', async () => { + let setupCount = 0; + const Stateful = defineComponent({ + name: 'SuspenseStateful', + props: { title: { required: true, type: String } }, + setup(props) { + const instance = ++setupCount; + return () => h('span', `${props.title}:${instance}|`); + }, + }); + const plugin = createGT({ + loadTranslations: async () => ({ + statefulSuspense: { + t: 'Suspense', + i: 1, + c: [ + { t: 'SuspenseStateful', i: 2, d: { ti: 'Premier' } }, + { t: 'SuspenseStateful', i: 2, d: { ti: 'Deuxième' } }, + ], + }, + }), + }); + const Root = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'statefulSuspense' }, + { + default: () => + h(Suspense, null, { + default: () => h(Stateful, { title: 'Source' }), + }), + } + ); + }, + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const mounted = mount(Root, plugin); + + try { + expect(textContent(mounted.root)).toBe('Source:1|'); + await plugin.setLocale('fr'); + await nextTick(); + expect(textContent(mounted.root)).toBe('Premier:1|Deuxième:2|'); + await plugin.setLocale('en'); + await nextTick(); + expect(textContent(mounted.root)).toBe('Source:1|'); + expect(setupCount).toBe(2); + expect(warn).not.toHaveBeenCalled(); + } finally { + mounted.app.unmount(); + warn.mockRestore(); + } + }); + it('preserves an async Suspense fallback before rendering translated content', async () => { const fallbackCalls = vi.fn(); let resolveGate!: () => void; diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 338fdeea90..e3a7ca2b81 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -785,10 +785,11 @@ function cloneWithChildren( ? mergeProps(vnode.props ?? {}, extraProps) : vnode.props; const slots = isSlots(vnode.children) ? vnode.children : {}; + const normalizedContent = (vnode as VNodeWithRenderMetadata).ssContent; const normalizedFallback = (vnode as VNodeWithRenderMetadata).ssFallback; const cloned = h(vnode.type, props, { ...slots, - default: () => unwrapSingleSuspenseChild(children), + default: () => rebuildSuspenseContent(children, normalizedContent), // Vue already invoked and normalized the fallback when it created the // source Suspense VNode. Reuse that VNode instead of running user slot // code a second time while rebuilding the translated boundary. @@ -817,18 +818,26 @@ function cloneWithChildren( } /** - * Returns a single rebuilt Suspense root without an artificial array wrapper. + * Rebuilds translated Suspense content with its normalized source root shape. * - * Vue accepts a primitive returned directly from a Suspense slot and - * normalizes it into a Text VNode. Inside an array, however, that same - * primitive fails Vue's single-root check and becomes an empty comment. Rich - * rendering naturally produces child arrays, so unwrap only the singleton - * case and leave genuinely multi-root content for Vue to validate. + * Vue rejects raw primitives inside a slot array, while translations may + * change a singleton source root into repeated siblings. Use one invisible + * Fragment for every rebuilt shape so Vue always receives a valid root and + * keyed source children retain their component identity across locale + * transitions. When Vue already normalized the source to a Fragment (for + * example, a multi-node `template v-if`), preserve its render metadata. */ -function unwrapSingleSuspenseChild(children: VNodeChild): VNodeChild { - return Array.isArray(children) && children.length === 1 - ? children[0] - : children; +function rebuildSuspenseContent( + children: VNodeChild, + source?: VNode +): VNodeChild { + const sourceFragment = source?.type === Fragment ? source : undefined; + const fragmentChildren = + children == null ? [] : Array.isArray(children) ? children : [children]; + const fragment = h(Fragment, sourceFragment?.props, fragmentChildren); + return sourceFragment + ? copyRenderMetadata(fragment, sourceFragment) + : fragment; } /** Copies render metadata without retaining mounted or normalized child state. */ From 277452c58dcba107c9a465ef96301ca6164124c3 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Wed, 5 Aug 2026 09:26:06 -0700 Subject: [PATCH 20/28] docs(vue): document opaque slot boundaries --- packages/vue/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/vue/README.md b/packages/vue/README.md index 52824837d9..96446bd84c 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -90,7 +90,24 @@ so the component and its real runtime slots are preserved, but their content is not part of the surrounding rich translation. To translate slot content, place `` inside the slot and wrap runtime values in ``. Native elements and the slots owned by GT's `` and `` components remain part of the -surrounding translation. +surrounding translation. Component tags inside `` must resolve at runtime; +an unresolved component warning from Vue is a configuration error and is not a +supported translation source. + +Vue `` is the one built-in whose default content participates in an +outer ``. Prefer literal `` and use a single default root. Immutable +aliases that the extractor can trace directly to `vue` are also supported; the +fallback slot is preserved but excluded from the outer translation. Re-exported, +globally registered, ref/computed-held, and other runtime-wrapped Suspense +aliases are not supported inside an outer ``. Put `` inside those +boundaries instead: + +```vue + + Translatable content + + +``` ## Registered Messages From 74f5a400eb547f788997b0a654912af7462d5cd8 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Wed, 5 Aug 2026 16:08:50 -0700 Subject: [PATCH 21/28] fix(vue): serialize Fragment default slots --- packages/vue/src/__tests__/runtime.test.ts | 23 ++++++++++++++++++- .../vue/src/rendering/translateVueChildren.ts | 20 ++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index c16d405fb5..0813aaa8b9 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -19,7 +19,10 @@ import { compileTemplate } from 'vue/compiler-sfc'; import { renderToString } from 'vue/server-renderer'; import { describe, expect, it, vi } from 'vitest'; import { getBranchNames } from '../components/utils'; -import { translateVueChildren } from '../rendering/translateVueChildren'; +import { + serializeVueChildren, + translateVueChildren, +} from '../rendering/translateVueChildren'; import { Branch, Currency, @@ -37,6 +40,24 @@ import { import type { TranslationCatalog } from '../index'; describe('gt-vue runtime', () => { + it('flattens only the default slot of a compiled Vue Fragment', () => { + const defaultSlot = vi.fn(() => [h('span', 'Fragment child')]); + const ignoredSlot = vi.fn(() => [h('span', 'Ignored child')]); + const fragment = h(Fragment, null, { + _: 1, + default: defaultSlot, + ignored: ignoredSlot, + }); + + expect(serializeVueChildren([fragment])).toEqual({ + t: 'span', + i: 1, + c: 'Fragment child', + }); + expect(defaultSlot).toHaveBeenCalledOnce(); + expect(ignoredSlot).not.toHaveBeenCalled(); + }); + it('deduplicates branch names shared by attrs and slots', () => { const slots = { default: () => [], diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index e3a7ca2b81..dd6c0b9e7a 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -133,6 +133,19 @@ export function createTranslationIdentityCache(): TranslationIdentityCache { }; } +/** + * Serializes compiled Vue slot children into the complete persisted GT source. + * + * This internal seam exists so compiler/extractor parity tests can compare + * element IDs, variable names, and branches before `hashSource()` deliberately + * removes identity-only fields. + */ +export function serializeVueChildren(children: VNode[]): JsxChildren { + return serializeNodes( + createSourceNodes(children, createTranslationIdentityCache()) + ); +} + function createSourceNodes( children: unknown, identityCache: TranslationIdentityCache @@ -168,9 +181,12 @@ function visitChildren( if (children.type === Comment) return []; if (children.type === Text) return [String(children.children ?? '')]; if (children.type === Fragment) { + const fragmentChildren = isSlots(children.children) + ? children.children.default?.() + : children.children; if (children.key != null) { return visitChildren( - children.children, + fragmentChildren, index, getExplicitIdentityScope(identityScope, children.key, identityCache), identityCache, @@ -179,7 +195,7 @@ function visitChildren( ); } return visitChildren( - children.children, + fragmentChildren, index, identityScope, identityCache, From 2ab4c077028f6e7229915f3ddfa2568ce92f7e7e Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Wed, 5 Aug 2026 16:18:57 -0700 Subject: [PATCH 22/28] fix(vue): enforce runtime release contracts --- packages/vue/README.md | 11 +- packages/vue/src/__tests__/branches.test.ts | 28 +++- packages/vue/src/__tests__/runtime.test.ts | 113 +++++++++++---- packages/vue/src/__tests__/variables.test.ts | 107 +++++++++++--- packages/vue/src/components/T.ts | 24 ++-- packages/vue/src/components/branches.ts | 26 ++-- packages/vue/src/components/utils.ts | 48 ++++--- packages/vue/src/components/variables.ts | 130 +++++++++++------- .../vue/src/rendering/translateVueChildren.ts | 9 +- 9 files changed, 356 insertions(+), 140 deletions(-) diff --git a/packages/vue/README.md b/packages/vue/README.md index 96446bd84c..0b298388c4 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -131,13 +131,12 @@ const m = useMessages(); - `` translates rich slot content. - `` preserves a dynamic slot value inside ``. -- ``, ``, and `` accept typed runtime values through - `:value` and also format static slot text for the active locale. +- ``, ``, and `` require typed runtime values through + `:value`; formatter slot children are not supported. - `` selects named slots such as `#one` and `#other`. - `` selects an arbitrary named slot. -Use typed bindings for dynamic formatting values. Slot text is intended for -static literals. +Use the required `value` prop for every formatting value. ```vue @@ -145,6 +144,10 @@ static literals. ``` +When the active locale is the configured default, formatting ignores explicit +`locales` and uses only that default locale. Otherwise, an explicit `locales` +list is tried first, followed by the active locale and then the default locale. + `setLocale()` loads a missing catalog, switches the reactive locale, and rerenders consumers. Locale persistence and development hot reload are outside this package; applications can persist their chosen locale separately. diff --git a/packages/vue/src/__tests__/branches.test.ts b/packages/vue/src/__tests__/branches.test.ts index 6d6bf0d5e8..392a8f5339 100644 --- a/packages/vue/src/__tests__/branches.test.ts +++ b/packages/vue/src/__tests__/branches.test.ts @@ -123,7 +123,7 @@ describe('Branch and Plural attributes', () => { `
beforeFallbackafter
` ); - expect(html).toContain('beforeafter'); + expect(html).toContain('beforeafter'); expect(html).not.toContain('Fallback'); expect(html).not.toContain(`>${label}<`); } @@ -211,7 +211,7 @@ describe('Branch and Plural attributes', () => { `
beforeFallbackafter
` ); - expect(html).toContain('beforeafter'); + expect(html).toContain('beforeafter'); expect(html).not.toContain('Fallback'); expect(html).not.toContain(`>${label}<`); } @@ -227,6 +227,30 @@ describe('Branch and Plural attributes', () => { expect(html).not.toContain('Fallback'); }); + it('uses the default plural rules when the default locale is active', async () => { + const html = await renderTemplate( + '', + () => ({}), + createGT({ defaultLocale: 'en' }) + ); + + expect(html).toContain('English other'); + expect(html).not.toContain('French one'); + }); + + it('uses the active locale plural rules before the default locale', async () => { + const plugin = createGT({ defaultLocale: 'en' }); + await plugin.setLocale('fr'); + const html = await renderTemplate( + '', + () => ({}), + plugin + ); + + expect(html).toContain('French one'); + expect(html).not.toContain('English other'); + }); + it('uses the same filtered inputs for a rich Plural hash', async () => { const source: JsxChildren = { t: 'Plural', diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 0813aaa8b9..4149fc8683 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -1521,28 +1521,21 @@ describe('gt-vue runtime', () => { expect(await renderWithPlugin(Root, plugin)).toContain('Bonjour'); }); - it('formats slot children and renders standalone branch components', async () => { + it('formats required values and renders standalone branch components', async () => { const plugin = createGT(); const Root = defineComponent({ setup() { return () => h('div', [ - h(Num, { locales: ['en-US'] }, { default: () => '1234.5' }), + h(Num, { locales: ['en-US'], value: '1234.5' }), '|', - h( - Currency, - { currency: 'USD', locales: ['en-US'] }, - { default: () => '12' } - ), + h(Currency, { currency: 'USD', locales: ['en-US'], value: '12' }), '|', - h( - DateTime, - { - locales: ['en-US'], - options: { timeZone: 'UTC', year: 'numeric' }, - }, - { default: () => '2024-01-01T00:00:00.000Z' } - ), + h(DateTime, { + locales: ['en-US'], + options: { timeZone: 'UTC', year: 'numeric' }, + value: '2024-01-01T00:00:00.000Z', + }), '|', h( Plural, @@ -1560,7 +1553,54 @@ describe('gt-vue runtime', () => { }); const html = await renderWithPlugin(Root, plugin); - expect(html).toContain('1,234.5|$12.00|2024|items|Welcome'); + expect(stripFragmentMarkers(html)).toContain( + '1,234.5|$12.00|2024|items|Welcome' + ); + }); + + it('emits stable SSR boundaries for every scalar GT component root', async () => { + const plugin = createGT({ + loadTranslations: async () => ({ translated: 'target' }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + setup() { + return () => + h('div', [ + 'A', + h(T, { _hash: 'translated' }, { default: () => 'source' }), + 'B', + h(T, { _hash: 'missing' }, { default: () => 'source' }), + 'C', + h(Num, { value: 'number' }), + 'D', + h(Num, { value: null }), + 'E', + h(Currency, { value: 'currency' }), + 'F', + h(Currency, { value: null }), + 'G', + h(DateTime, { value: 'date' }), + 'H', + h(DateTime, { value: null }), + 'I', + h(Branch, { branch: 'yes', yes: 'branch' }), + 'J', + h(Branch, { branch: 'empty', empty: null }), + 'K', + h(Plural, { n: 2, other: 'plural' }), + 'L', + h(Plural, { n: 2, other: null }), + 'M', + h(Var, null, { default: () => 'variable' }), + 'N', + ]); + }, + }); + + expect(await renderWithPlugin(Root, plugin)).toBe( + '
AtargetBsourceCnumberDEcurrencyFGdateHIbranchJKpluralLMvariableN
' + ); }); it('falls back safely for inherited object-property branch names', async () => { @@ -1660,7 +1700,7 @@ describe('gt-vue runtime', () => { } ), '|', - h(Num, null, { default: () => '1234.5' }), + h(Num, { value: '1234.5' }), ], }); }, @@ -1698,19 +1738,40 @@ describe('gt-vue runtime', () => { expect(await renderWithPlugin(Root, plugin)).toContain('Bonjour'); }); - it('keeps app caches isolated during concurrent SSR', async () => { + it('keeps locale and catalog state isolated during interleaved SSR requests', async () => { const source = 'Hello'; + const richSource: JsxChildren = { t: 'span', i: 1, c: 'World' }; + const pending = new Map void>(); const french = createGT({ - loadTranslations: async () => ({ [stringHash(source)]: 'Bonjour' }), + loadTranslations: (locale) => + new Promise((resolve) => pending.set(`fr:${locale}`, resolve)), }); const chinese = createGT({ - loadTranslations: async () => ({ [stringHash(source)]: '你好' }), + loadTranslations: (locale) => + new Promise((resolve) => pending.set(`zh:${locale}`, resolve)), + }); + const frenchReady = french.setLocale('fr'); + const chineseReady = chinese.setLocale('zh'); + await vi.waitFor(() => expect(pending.size).toBe(2)); + pending.get('zh:zh')?.({ + [jsxHash(richSource)]: { t: 'span', i: 1, c: '世界' }, + [stringHash(source)]: '你好', }); - await Promise.all([french.setLocale('fr'), chinese.setLocale('zh')]); + await chineseReady; + pending.get('fr:fr')?.({ + [jsxHash(richSource)]: { t: 'span', i: 1, c: 'Monde' }, + [stringHash(source)]: 'Bonjour', + }); + await frenchReady; const Root = defineComponent({ setup() { const gt = useGT(); - return () => h('p', gt(source)); + const locale = useLocale(); + return () => + h('p', [ + `${locale.value}|${gt(source)}|`, + h(T, null, { default: () => h('span', 'World') }), + ]); }, }); @@ -1718,8 +1779,12 @@ describe('gt-vue runtime', () => { renderWithPlugin(Root, french), renderWithPlugin(Root, chinese), ]); - expect(fr).toContain('Bonjour'); - expect(zh).toContain('你好'); + expect(stripFragmentMarkers(fr)).toContain( + '

fr|Bonjour|Monde

' + ); + expect(stripFragmentMarkers(zh)).toContain( + '

zh|你好|世界

' + ); }); it('applies only the latest concurrent locale request', async () => { diff --git a/packages/vue/src/__tests__/variables.test.ts b/packages/vue/src/__tests__/variables.test.ts index 123c056b22..d59c20a6ef 100644 --- a/packages/vue/src/__tests__/variables.test.ts +++ b/packages/vue/src/__tests__/variables.test.ts @@ -1,6 +1,8 @@ +import { libraryDefaultLocale } from 'generaltranslation/internal'; import { createSSRApp, defineComponent, h, type Component } from 'vue'; import { renderToString } from 'vue/server-renderer'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { getFormatLocales } from '../components/utils'; import { Currency, DateTime, Num, Var, createGT } from '../index'; describe('gt-vue formatting components', () => { @@ -32,37 +34,55 @@ describe('gt-vue formatting components', () => { }, }); - expect(await render(Root)).toContain('1,234.5|$12.00|2024|2024'); + expect(stripFragmentMarkers(await render(Root))).toContain( + '1,234.5|$12.00|2024|2024' + ); }); - it('gives value props precedence over static slot text', async () => { + it('does not treat formatter slot children as values', async () => { + const slot = vi.fn(() => '999'); const Root = defineComponent({ setup() { return () => - h(Num, { locales: ['en-US'], value: 2 }, { default: () => '999' }); + h('div', [ + h(Num, { locales: ['en-US'], value: 2 }, { default: slot }), + h(Currency, { locales: ['en-US'], value: 3 }, { default: slot }), + h( + DateTime, + { + locales: ['en-US'], + options: { timeZone: 'UTC', year: 'numeric' }, + value: 1704067200000, + }, + { default: slot } + ), + ]); }, }); - expect(await render(Root)).toBe('2'); + expect(stripFragmentMarkers(await render(Root))).toContain('2$3.002024'); + expect(slot).not.toHaveBeenCalled(); }); - it('returns partially parseable slot text unchanged', async () => { + it('returns partially parseable value strings unchanged', async () => { const Root = defineComponent({ setup() { return () => h('div', [ - h(Num, { locales: ['en-US'] }, { default: () => '1,234.5' }), + h(Num, { locales: ['en-US'], value: '1,234.5' }), '|', - h( - Currency, - { currency: 'USD', locales: ['en-US'] }, - { default: () => '12 dollars' } - ), + h(Currency, { + currency: 'USD', + locales: ['en-US'], + value: '12 dollars', + }), ]); }, }); - expect(await render(Root)).toContain('1,234.5|12 dollars'); + expect(stripFragmentMarkers(await render(Root))).toContain( + '1,234.5|12 dollars' + ); }); it('returns invalid dates unchanged', async () => { @@ -75,19 +95,66 @@ describe('gt-vue formatting components', () => { expect(await render(Root)).toContain('definitely-not-a-date'); }); - it('does not interpret whitespace-only slot text as zero or a date', async () => { + it('does not interpret nullish or whitespace-only values as zero or a date', async () => { const Root = defineComponent({ setup() { return () => h('div', [ - h(Num, null, { default: () => ' ' }), - h(Currency, null, { default: () => '\n' }), - h(DateTime, null, { default: () => '\t' }), + h(Num, { value: ' ' }), + h(Currency, { value: '\n' }), + h(DateTime, { value: '\t' }), + h(Num, { value: null }), + h(Currency, { value: null }), + h(DateTime, { value: null }), ]); }, }); - expect(await render(Root)).toBe('
'); + expect(stripFragmentMarkers(await render(Root))).toBe('
'); + }); + + it('accepts explicit null values without required or type warnings', async () => { + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, { value: null }), + h(Currency, { value: null }), + h(DateTime, { value: null }), + ]); + }, + }); + const warnings: string[] = []; + const app = createSSRApp(Root).use(createGT()); + app.config.warnHandler = (message) => warnings.push(message); + + expect(stripFragmentMarkers(await renderToString(app))).toBe('
'); + expect(warnings).toEqual([]); + }); + + it('uses only the default locale when it is active', () => { + expect(getFormatLocales(['fr-CA'], 'en', 'en')).toEqual(['en']); + expect(getFormatLocales(['fr-CA'], libraryDefaultLocale)).toEqual([ + libraryDefaultLocale, + ]); + }); + + it('ignores explicit formatter locales when the default locale is active', async () => { + const Root = defineComponent({ + setup() { + return () => h(Num, { locales: ['de-DE'], value: 1234.5 }); + }, + }); + + expect(stripFragmentMarkers(await render(Root))).toBe('1,234.5'); + }); + + it('tries explicit, active, and default locales once while translating', () => { + expect(getFormatLocales(['fr-CA', 'fr', 'en'], 'fr', 'en')).toEqual([ + 'fr-CA', + 'fr', + 'en', + ]); }); it('ignores fallthrough attributes without warning for unwrapped values', async () => { @@ -144,3 +211,7 @@ describe('gt-vue formatting components', () => { async function render(root: Component): Promise { return renderToString(createSSRApp(root).use(createGT())); } + +function stripFragmentMarkers(html: string): string { + return html.replaceAll('', '').replaceAll('', ''); +} diff --git a/packages/vue/src/components/T.ts b/packages/vue/src/components/T.ts index 5a78cf12a0..42e5bb165e 100644 --- a/packages/vue/src/components/T.ts +++ b/packages/vue/src/components/T.ts @@ -4,7 +4,7 @@ import { translateVueChildren, } from '../rendering/translateVueChildren'; import { useGTState } from '../runtime/state'; -import { withGTMetadata } from './utils'; +import { asFragmentRoot, withGTMetadata } from './utils'; type TProps = { /** @internal Compile-time hash inserted by GT tooling. */ @@ -46,16 +46,18 @@ export const T = withGTMetadata( // preserve component identity without colliding with user-provided keys. const identityCache = createTranslationIdentityCache(); return () => - translateVueChildren( - slots.default?.() ?? [], - state, - { - ...props, - ...(typeof attrs.$context === 'string' && { - $context: attrs.$context, - }), - }, - identityCache + asFragmentRoot( + translateVueChildren( + slots.default?.() ?? [], + state, + { + ...props, + ...(typeof attrs.$context === 'string' && { + $context: attrs.$context, + }), + }, + identityCache + ) ); }, }), diff --git a/packages/vue/src/components/branches.ts b/packages/vue/src/components/branches.ts index 2cdc741147..5f2f5cf3a8 100644 --- a/packages/vue/src/components/branches.ts +++ b/packages/vue/src/components/branches.ts @@ -5,6 +5,7 @@ import { import { defineComponent, type PropType } from 'vue'; import { useGTState } from '../runtime/state'; import { + asFragmentRoot, getBranchContent, getBranchNames, getFormatLocales, @@ -12,7 +13,7 @@ import { } from './utils'; type PluralProps = { - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Numeric value used to select a plural category. */ n: number; @@ -27,14 +28,15 @@ type BranchProps = { * Selects a named plural slot such as `one` or `other` from `n` and the active * locale's plural rules. Missing categories fall back to the default slot. * - * Explicit `locales` are tried before the active GT locale. + * At the default locale, explicit `locales` are ignored. Otherwise, they are + * tried before the active and default GT locales. */ export const Plural = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'Plural', props: { - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales: Array as PropType, /** Numeric value used to select a plural category. */ n: { @@ -51,9 +53,13 @@ export const Plural = withGTMetadata( const branch = getPluralForm( props.n, branches, - getFormatLocales(props.locales, state.locale.value) + getFormatLocales( + props.locales, + state.locale.value, + state.defaultLocale + ) ); - return getBranchContent(branch, attrs, slots); + return asFragmentRoot(getBranchContent(branch, attrs, slots)); }; }, }), @@ -78,10 +84,12 @@ export const Branch = withGTMetadata( setup(props, { attrs, slots }) { return () => { const branch = props.branch?.toString(); - return getBranchContent( - branch && !branch.startsWith('data-') ? branch : undefined, - attrs, - slots + return asFragmentRoot( + getBranchContent( + branch && !branch.startsWith('data-') ? branch : undefined, + attrs, + slots + ) ); }; }, diff --git a/packages/vue/src/components/utils.ts b/packages/vue/src/components/utils.ts index 862bdb72ed..ed8d41c060 100644 --- a/packages/vue/src/components/utils.ts +++ b/packages/vue/src/components/utils.ts @@ -1,5 +1,5 @@ +import { libraryDefaultLocale } from 'generaltranslation/internal'; import { - isVNode, type Component, type DefineComponent, type Slots, @@ -37,17 +37,40 @@ export function withGTMetadata( return Object.assign(component, { _gtt: metadata }) as GTComponent; } -/** @internal */ +/** + * Builds the locale fallback list used by formatters and plural selection. + * + * When the active locale equals the configured default locale, explicit + * preferences are intentionally ignored and only the default locale is used, + * matching the React runtime. Otherwise, explicit preferences are tried first, + * then the active locale, and finally the default locale. Duplicate entries + * are removed without changing that order. + * + * @internal + */ export function getFormatLocales( locales: string[] | undefined, - locale: string + locale: string, + defaultLocale: string = libraryDefaultLocale ): string[] { - return [...(locales ?? []), locale]; + if (locale === defaultLocale) return [defaultLocale]; + return [...new Set([...(locales ?? []), locale, defaultLocale])]; } -/** @internal */ -export function readSlotText(slots: Slots): string { - return (slots.default?.() ?? []).map(readVNodeText).join(''); +/** + * Normalizes a render result to a Fragment component root. + * + * Vue's server renderer concatenates adjacent scalar component roots into one + * text node. Returning an array makes Vue emit Fragment anchors, so hydration + * can recover each GT-owned boundary even when several components render next + * to plain text or change between source and translated content. + * + * @internal + */ +export function asFragmentRoot(children: VNodeChild): VNodeChild[] { + if (Array.isArray(children)) return children; + if (children == null || typeof children === 'boolean') return []; + return [children]; } /** @@ -86,17 +109,6 @@ export function isBranchAttribute( ); } -function readVNodeText(node: VNodeChild): string { - if (node == null || typeof node === 'boolean') return ''; - if (Array.isArray(node)) return node.map(readVNodeText).join(''); - if (!isVNode(node)) return String(node); - if (typeof node.children === 'string') return node.children; - if (Array.isArray(node.children)) { - return node.children.map(readVNodeText).join(''); - } - return ''; -} - /** @internal */ export function getBranchNames( attrs: Record, diff --git a/packages/vue/src/components/variables.ts b/packages/vue/src/components/variables.ts index 7de32c70c2..6add18fb5c 100644 --- a/packages/vue/src/components/variables.ts +++ b/packages/vue/src/components/variables.ts @@ -1,23 +1,23 @@ import { defineComponent, type PropType } from 'vue'; import { useGTState } from '../runtime/state'; -import { getFormatLocales, readSlotText, withGTMetadata } from './utils'; +import { asFragmentRoot, getFormatLocales, withGTMetadata } from './utils'; type NumberFormatProps = { - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Options forwarded to `Intl.NumberFormat`. */ options?: Intl.NumberFormatOptions; - /** Runtime value. When provided, this takes precedence over slot text. */ - value?: number | string | null; + /** Runtime value to format. */ + value: number | string | null; }; type DateTimeProps = { - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Options forwarded to `Intl.DateTimeFormat`. */ options?: Intl.DateTimeFormatOptions; - /** Runtime value. When provided, this takes precedence over slot text. */ - value?: Date | number | string | null; + /** Runtime value to format. */ + value: Date | number | string | null; }; type CurrencyProps = NumberFormatProps & { @@ -43,48 +43,57 @@ export const Var = withGTMetadata( inheritAttrs: false, name: 'Var', setup(_props, { slots }) { - return () => slots.default?.() ?? null; + return () => asFragmentRoot(slots.default?.() ?? null); }, }), 'variable-variable' ); /** - * Formats a number with `Intl.NumberFormat` for the active locale. Pass - * runtime values through `value`; static default-slot text remains supported. - * Explicit `locales` are tried first, and text that is not an entire numeric - * value is returned unchanged. + * Formats the required `value` prop with `Intl.NumberFormat`. Explicit + * `locales` are tried before the active and default GT locales while + * translating. Text that is not an entire numeric value is returned + * unchanged. */ export const Num = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'Num', props: { - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales: Array as PropType, /** Options forwarded to `Intl.NumberFormat`. */ options: Object as PropType, - /** Runtime value. When provided, this takes precedence over slot text. */ - value: [Number, String] as PropType, + /** Runtime value to format. */ + value: { + required: true, + type: [Number, String, null] as unknown as PropType< + number | string | null + >, + }, }, - setup(props, { slots }) { + setup(props) { const state = useGTState(); return () => { - const value = - props.value !== undefined ? props.value : readSlotText(slots); + const value = props.value; if ( value == null || (typeof value === 'string' && value.trim() === '') ) { - return null; + return asFragmentRoot(null); } const number = typeof value === 'number' ? value : Number(value); - return Number.isNaN(number) + const formatted = Number.isNaN(number) ? String(value) : new Intl.NumberFormat( - getFormatLocales(props.locales, state.locale.value), + getFormatLocales( + props.locales, + state.locale.value, + state.defaultLocale + ), props.options ).format(number); + return asFragmentRoot(formatted); }; }, }), @@ -92,42 +101,52 @@ export const Num = withGTMetadata( ); /** - * Formats a date with `Intl.DateTimeFormat` for the active locale. Pass - * `Date` objects and epoch numbers through `value`; static default-slot text - * remains supported. Explicit `locales` are tried first, and invalid values - * are returned unchanged. + * Formats the required `value` prop with `Intl.DateTimeFormat`. `Date` + * objects, epoch numbers, and date strings are supported. Explicit `locales` + * are tried before the active and default GT locales while translating, and + * invalid values are returned unchanged. */ export const DateTime = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'DateTime', props: { - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales: Array as PropType, /** Options forwarded to `Intl.DateTimeFormat`. */ options: Object as PropType, - /** Runtime value. When provided, this takes precedence over slot text. */ - value: [Date, Number, String] as PropType, + /** Runtime value to format. */ + value: { + required: true, + type: [Date, Number, String, null] as unknown as PropType< + Date | number | string | null + >, + }, }, - setup(props, { slots }) { + setup(props) { const state = useGTState(); return () => { - const value = - props.value !== undefined ? props.value : readSlotText(slots); + const value = props.value; if ( value == null || (typeof value === 'string' && value.trim() === '') ) { - return null; + return asFragmentRoot(null); } const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return String(value); - return new Intl.DateTimeFormat( - getFormatLocales(props.locales, state.locale.value), - props.options - ) - .format(date) - .replace(/[\u200F\u202B\u202E]/g, ''); + const formatted = Number.isNaN(date.getTime()) + ? String(value) + : new Intl.DateTimeFormat( + getFormatLocales( + props.locales, + state.locale.value, + state.defaultLocale + ), + props.options + ) + .format(date) + .replace(/[\u200F\u202B\u202E]/g, ''); + return asFragmentRoot(formatted); }; }, }), @@ -135,9 +154,9 @@ export const DateTime = withGTMetadata( ); /** - * Formats a number as currency for the active locale. Pass runtime values - * through `value`; static default-slot text remains supported. `currency` - * defaults to `USD`, and text that is not an entire numeric value is returned + * Formats the required `value` prop as currency. `currency` defaults to + * `USD`. Explicit `locales` are tried before the active and default GT locales + * while translating, and text that is not an entire numeric value is returned * unchanged. */ export const Currency = withGTMetadata( @@ -150,35 +169,44 @@ export const Currency = withGTMetadata( default: 'USD', type: String, }, - /** Locale preferences tried before the active GT locale. */ + /** Locale preferences tried before active and default locales in translation. */ locales: Array as PropType, /** Additional options forwarded to `Intl.NumberFormat`. */ options: Object as PropType, - /** Runtime value. When provided, this takes precedence over slot text. */ - value: [Number, String] as PropType, + /** Runtime value to format. */ + value: { + required: true, + type: [Number, String, null] as unknown as PropType< + number | string | null + >, + }, }, - setup(props, { slots }) { + setup(props) { const state = useGTState(); return () => { - const value = - props.value !== undefined ? props.value : readSlotText(slots); + const value = props.value; if ( value == null || (typeof value === 'string' && value.trim() === '') ) { - return null; + return asFragmentRoot(null); } const number = typeof value === 'number' ? value : Number(value); - return Number.isNaN(number) + const formatted = Number.isNaN(number) ? String(value) : new Intl.NumberFormat( - getFormatLocales(props.locales, state.locale.value), + getFormatLocales( + props.locales, + state.locale.value, + state.defaultLocale + ), { ...props.options, currency: props.currency, style: 'currency', } ).format(number); + return asFragmentRoot(formatted); }; }, }), diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index dd6c0b9e7a..5ac40d5c9a 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -27,7 +27,7 @@ import { type VNode, type VNodeChild, } from 'vue'; -import { isBranchAttribute } from '../components/utils'; +import { getFormatLocales, isBranchAttribute } from '../components/utils'; import type { GTState } from '../types'; const variableTypes = { @@ -656,8 +656,11 @@ function getPluralKey( ) : []; return ( - getPluralForm(n, forms, [...sourceLocales, locale, state.defaultLocale]) || - undefined + getPluralForm( + n, + forms, + getFormatLocales(sourceLocales, locale, state.defaultLocale) + ) || undefined ); } From a9a5e3ed9d3a3f4dc0159bc34b1151e9bc596346 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Wed, 5 Aug 2026 16:20:00 -0700 Subject: [PATCH 23/28] chore(vue): test on maintained Node --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b58ff0db8f..01b245c9ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: if: steps.gt_vue_changed.outputs.changed == 'true' uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: 'pnpm' - name: Install dependencies From a5f38714f8b552b13f505c9e775a8354b5a16121 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Wed, 5 Aug 2026 16:26:42 -0700 Subject: [PATCH 24/28] fix(vue): align rich formatter locales --- packages/vue/README.md | 5 +- packages/vue/src/__tests__/runtime.test.ts | 123 +++++++++++++++--- packages/vue/src/components/variables.ts | 70 +++++----- .../vue/src/rendering/translateVueChildren.ts | 46 ++++--- 4 files changed, 175 insertions(+), 69 deletions(-) diff --git a/packages/vue/README.md b/packages/vue/README.md index 0b298388c4..6c3343d25c 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -146,7 +146,10 @@ Use the required `value` prop for every formatting value. When the active locale is the configured default, formatting ignores explicit `locales` and uses only that default locale. Otherwise, an explicit `locales` -list is tried first, followed by the active locale and then the default locale. +list on a standalone formatter is tried first, followed by the active locale +and then the default locale. Inside ``, the rich translation pipeline owns +formatting locales: source fallbacks use the default locale, while translated +content uses the active locale followed by the default. `setLocale()` loads a missing catalog, switches the reactive locale, and rerenders consumers. Locale persistence and development hot reload are outside diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 4149fc8683..f134934cf4 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -1680,35 +1680,118 @@ describe('gt-vue runtime', () => { expect(html).not.toContain('alt="Source portrait"'); }); - it('uses the default locale for untranslated rich source fallbacks', async () => { - const plugin = createGT({ + it('uses pipeline locales instead of public preferences for rich formatters', async () => { + const date = new Date('2024-01-02T00:00:00.000Z'); + const dateOptions = { + day: 'numeric', + month: 'long', + timeZone: 'UTC', + year: 'numeric', + } as const; + const renderFormatters = () => [ + h(Num, { locales: ['de-DE'], value: 1234.5 }), + '|', + h(Currency, { + currency: 'EUR', + locales: ['de-DE'], + value: 1234.5, + }), + '|', + h(DateTime, { + locales: ['de-DE'], + options: dateOptions, + value: date, + }), + ]; + const expected = (locale: string) => + [ + new Intl.NumberFormat(locale).format(1234.5), + new Intl.NumberFormat(locale, { + currency: 'EUR', + style: 'currency', + }).format(1234.5), + new Intl.DateTimeFormat(locale, dateOptions) + .format(date) + .replace(/[\u200F\u202B\u202E]/g, ''), + ].join('|'); + + const missingPlugin = createGT({ defaultLocale: 'en-US', loadTranslations: async () => ({}), }); - await plugin.setLocale('fr-FR'); - const Root = defineComponent({ + await missingPlugin.setLocale('fr-FR'); + const MissingRoot = defineComponent({ setup() { return () => - h(T, null, { - default: () => [ - h( - Plural, - { n: 0 }, - { - one: () => 'one', - other: () => 'other', - } - ), - '|', - h(Num, { value: '1234.5' }), - ], - }); + h( + T, + { _hash: 'missing' }, + { + default: () => [ + h( + Plural, + { n: 0 }, + { + one: () => 'one', + other: () => 'other', + } + ), + '|', + ...renderFormatters(), + ], + } + ); }, }); expect( - stripFragmentMarkers(await renderWithPlugin(Root, plugin)) - ).toContain('other|1,234.5'); + stripFragmentMarkers(await renderWithPlugin(MissingRoot, missingPlugin)) + ).toBe(`other|${expected('en-US')}`); + + const translatedPlugin = createGT({ + defaultLocale: 'en-US', + loadTranslations: async () => ({ + formatters: [ + { i: 1, k: '_gt_n_1', v: 'n' }, + '|', + { i: 2, k: '_gt_cost_2', v: 'c' }, + '|', + { i: 3, k: '_gt_date_3', v: 'd' }, + ], + partial: { d: { ti: 'Titre' }, i: 1, t: 'span' }, + }), + }); + await translatedPlugin.setLocale('fr-FR'); + const TranslatedRoot = defineComponent({ + setup() { + return () => + h(T, { _hash: 'formatters' }, { default: renderFormatters }); + }, + }); + const PartialRoot = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'partial' }, + { + default: () => + h('span', { title: 'Source title' }, renderFormatters()), + } + ); + }, + }); + + expect( + stripFragmentMarkers( + await renderWithPlugin(TranslatedRoot, translatedPlugin) + ) + ).toBe(expected('fr-FR')); + expect( + stripFragmentMarkers( + await renderWithPlugin(PartialRoot, translatedPlugin) + ) + ).toBe(`${expected('fr-FR')}`); }); it('requires explicit preloading for an asynchronous SSR locale', async () => { diff --git a/packages/vue/src/components/variables.ts b/packages/vue/src/components/variables.ts index 6add18fb5c..44a9f6b2fd 100644 --- a/packages/vue/src/components/variables.ts +++ b/packages/vue/src/components/variables.ts @@ -3,6 +3,8 @@ import { useGTState } from '../runtime/state'; import { asFragmentRoot, getFormatLocales, withGTMetadata } from './utils'; type NumberFormatProps = { + /** @internal Locale selected by an owning rich translation pipeline. */ + _locale?: string; /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Options forwarded to `Intl.NumberFormat`. */ @@ -12,6 +14,8 @@ type NumberFormatProps = { }; type DateTimeProps = { + /** @internal Locale selected by an owning rich translation pipeline. */ + _locale?: string; /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Options forwarded to `Intl.DateTimeFormat`. */ @@ -50,16 +54,18 @@ export const Var = withGTMetadata( ); /** - * Formats the required `value` prop with `Intl.NumberFormat`. Explicit - * `locales` are tried before the active and default GT locales while - * translating. Text that is not an entire numeric value is returned - * unchanged. + * Formats the required `value` prop with `Intl.NumberFormat`. When rendered + * outside {@link T}, explicit `locales` are tried before the + * active and default GT locales while translating. Text that is not an entire + * numeric value is returned unchanged. */ export const Num = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'Num', props: { + /** @internal Locale selected by an owning rich translation pipeline. */ + _locale: String, /** Locale preferences tried before active and default locales in translation. */ locales: Array as PropType, /** Options forwarded to `Intl.NumberFormat`. */ @@ -86,11 +92,7 @@ export const Num = withGTMetadata( const formatted = Number.isNaN(number) ? String(value) : new Intl.NumberFormat( - getFormatLocales( - props.locales, - state.locale.value, - state.defaultLocale - ), + getVariableFormatLocales(props, state), props.options ).format(number); return asFragmentRoot(formatted); @@ -102,15 +104,18 @@ export const Num = withGTMetadata( /** * Formats the required `value` prop with `Intl.DateTimeFormat`. `Date` - * objects, epoch numbers, and date strings are supported. Explicit `locales` - * are tried before the active and default GT locales while translating, and - * invalid values are returned unchanged. + * objects, epoch numbers, and date strings are supported. When rendered + * outside {@link T}, explicit `locales` are tried before the active and + * default GT locales while translating. Invalid values are returned + * unchanged. */ export const DateTime = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'DateTime', props: { + /** @internal Locale selected by an owning rich translation pipeline. */ + _locale: String, /** Locale preferences tried before active and default locales in translation. */ locales: Array as PropType, /** Options forwarded to `Intl.DateTimeFormat`. */ @@ -137,11 +142,7 @@ export const DateTime = withGTMetadata( const formatted = Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat( - getFormatLocales( - props.locales, - state.locale.value, - state.defaultLocale - ), + getVariableFormatLocales(props, state), props.options ) .format(date) @@ -155,15 +156,17 @@ export const DateTime = withGTMetadata( /** * Formats the required `value` prop as currency. `currency` defaults to - * `USD`. Explicit `locales` are tried before the active and default GT locales - * while translating, and text that is not an entire numeric value is returned - * unchanged. + * `USD`. When rendered outside {@link T}, explicit `locales` are tried before + * the active and default GT locales while translating. Text that is not an + * entire numeric value is returned unchanged. */ export const Currency = withGTMetadata( defineComponent({ inheritAttrs: false, name: 'Currency', props: { + /** @internal Locale selected by an owning rich translation pipeline. */ + _locale: String, /** ISO 4217 currency code. Defaults to `USD`. */ currency: { default: 'USD', @@ -194,21 +197,24 @@ export const Currency = withGTMetadata( const number = typeof value === 'number' ? value : Number(value); const formatted = Number.isNaN(number) ? String(value) - : new Intl.NumberFormat( - getFormatLocales( - props.locales, - state.locale.value, - state.defaultLocale - ), - { - ...props.options, - currency: props.currency, - style: 'currency', - } - ).format(number); + : new Intl.NumberFormat(getVariableFormatLocales(props, state), { + ...props.options, + currency: props.currency, + style: 'currency', + }).format(number); return asFragmentRoot(formatted); }; }, }), 'variable-currency' ); + +/** Uses a rich pipeline override without changing standalone locale options. */ +function getVariableFormatLocales( + props: { _locale?: string; locales?: string[] }, + state: ReturnType +): string[] { + return props._locale === undefined + ? getFormatLocales(props.locales, state.locale.value, state.defaultLocale) + : getFormatLocales(undefined, props._locale, state.defaultLocale); +} diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 5ac40d5c9a..ef73f5f82b 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -499,7 +499,12 @@ function renderNodes( return variable ? keySourceResult( variable, - renderDefaultNode(variable, state, identityCache), + renderDefaultNode( + variable, + state, + identityCache, + state.locale.value + ), occurrences, identityCache ) @@ -585,7 +590,12 @@ function renderElement( if (source.transformation === 'plural') { const n = source.vnode.props?.n; if (typeof n !== 'number') { - return renderDefaultNode(source, state, identityCache); + return renderDefaultNode( + source, + state, + identityCache, + state.locale.value + ); } const sourceBranch = getPluralKey( n, @@ -620,9 +630,20 @@ function renderElement( } const translatedProps = getTranslatedProps(target); if (target.c == null) { - return Object.keys(translatedProps).length - ? cloneWithProps(source.vnode, translatedProps) - : renderDefaultNode(source, state, identityCache); + return source.children.length + ? cloneWithChildren( + source.vnode, + renderDefaultNodes( + source.children, + state, + identityCache, + state.locale.value + ), + translatedProps + ) + : Object.keys(translatedProps).length + ? cloneWithProps(source.vnode, translatedProps) + : source.vnode; } return cloneWithChildren( @@ -708,7 +729,7 @@ function renderDefaultNodes( nodes: SourceNode[], state: GTState, identityCache: TranslationIdentityCache, - locale = state.defaultLocale + locale: string ): VNodeChild[] { const occurrences = new Map(); return nodes.map((node) => @@ -727,19 +748,12 @@ function renderDefaultNode( node: SourceNode, state: GTState, identityCache: TranslationIdentityCache, - locale?: string + locale: string ): VNodeChild { if (typeof node === 'string') return node; if (node.transformation === 'variable') { - return locale && node.variableType !== 'v' - ? cloneWithProps(node.vnode, { - locales: [ - ...(Array.isArray(node.vnode.props?.locales) - ? node.vnode.props.locales - : []), - locale, - ], - }) + return node.variableType !== 'v' + ? cloneWithProps(node.vnode, { _locale: locale }) : node.vnode; } if (node.transformation === 'fragment') { From ffe595115abf97553d5b461f0d0aef5ce1a97bf6 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Wed, 5 Aug 2026 16:31:37 -0700 Subject: [PATCH 25/28] fix(vue): keep formatter locale override private --- packages/vue/src/__tests__/variables.test.ts | 15 +++++++++++++++ packages/vue/src/components/variables.ts | 4 ---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/vue/src/__tests__/variables.test.ts b/packages/vue/src/__tests__/variables.test.ts index d59c20a6ef..6a12839514 100644 --- a/packages/vue/src/__tests__/variables.test.ts +++ b/packages/vue/src/__tests__/variables.test.ts @@ -149,6 +149,21 @@ describe('gt-vue formatting components', () => { expect(stripFragmentMarkers(await render(Root))).toBe('1,234.5'); }); + it('prefers explicit locales for standalone formatters at a non-default locale', async () => { + const Root = defineComponent({ + setup() { + return () => h(Num, { locales: ['de'], value: 1234.5 }); + }, + }); + const app = createSSRApp(Root).use( + createGT({ defaultLocale: 'en', locale: 'fr' }) + ); + + expect(stripFragmentMarkers(await renderToString(app))).toBe( + new Intl.NumberFormat('de').format(1234.5) + ); + }); + it('tries explicit, active, and default locales once while translating', () => { expect(getFormatLocales(['fr-CA', 'fr', 'en'], 'fr', 'en')).toEqual([ 'fr-CA', diff --git a/packages/vue/src/components/variables.ts b/packages/vue/src/components/variables.ts index 44a9f6b2fd..bc8a9de6aa 100644 --- a/packages/vue/src/components/variables.ts +++ b/packages/vue/src/components/variables.ts @@ -3,8 +3,6 @@ import { useGTState } from '../runtime/state'; import { asFragmentRoot, getFormatLocales, withGTMetadata } from './utils'; type NumberFormatProps = { - /** @internal Locale selected by an owning rich translation pipeline. */ - _locale?: string; /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Options forwarded to `Intl.NumberFormat`. */ @@ -14,8 +12,6 @@ type NumberFormatProps = { }; type DateTimeProps = { - /** @internal Locale selected by an owning rich translation pipeline. */ - _locale?: string; /** Locale preferences tried before active and default locales in translation. */ locales?: string[]; /** Options forwarded to `Intl.DateTimeFormat`. */ From 5e59882e2ce34d2b713ed958e172329b25d16097 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 6 Aug 2026 09:57:30 -0700 Subject: [PATCH 26/28] fix(vue): bound reconciliation identities --- packages/vue/src/__tests__/branches.test.ts | 50 ++++ packages/vue/src/__tests__/runtime.test.ts | 166 +++++++++++- packages/vue/src/components/branches.ts | 8 +- .../vue/src/rendering/translateVueChildren.ts | 242 ++++++++++++------ 4 files changed, 381 insertions(+), 85 deletions(-) diff --git a/packages/vue/src/__tests__/branches.test.ts b/packages/vue/src/__tests__/branches.test.ts index 392a8f5339..588d706e7f 100644 --- a/packages/vue/src/__tests__/branches.test.ts +++ b/packages/vue/src/__tests__/branches.test.ts @@ -139,6 +139,56 @@ describe('Branch and Plural attributes', () => { expect(html).not.toContain('Fallback'); }); + it('selects a data-* named slot without treating the matching attribute as content', async () => { + const html = await renderTemplate( + 'Fallback' + ); + + expect(html).toContain('Named slot'); + expect(html).not.toContain('Attribute'); + expect(html).not.toContain('Fallback'); + }); + + it('selects a data-* named slot inside default-locale rich content', async () => { + const html = await renderTemplate( + 'Source fallback' + ); + + expect(html).toContain('Source named slot'); + expect(html).not.toContain('Attribute'); + expect(html).not.toContain('Source fallback'); + }); + + it('selects a translated data-* named slot inside rich content', async () => { + const source: JsxChildren = { + t: 'Branch', + i: 1, + d: { b: { 'data-note': 'Source named slot' }, t: 'b' }, + c: 'Source fallback', + }; + const target: JsxChildren = { + t: 'Branch', + i: 1, + d: { b: { 'data-note': 'Translated named slot' }, t: 'b' }, + c: 'Translated fallback', + }; + const plugin = createGT({ + loadTranslations: async () => ({ [jsxHash(source)]: target }), + }); + await plugin.setLocale('fr'); + + const html = await renderTemplate( + 'Source fallback', + undefined, + plugin + ); + + expect(html).toContain('Translated named slot'); + expect(html).not.toContain('Attribute'); + expect(html).not.toContain('Source named slot'); + expect(html).not.toContain('Translated fallback'); + }); + it('uses the same filtered inputs for a rich Branch hash', async () => { const source: JsxChildren = { t: 'Branch', diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index f134934cf4..47bf5e4bb2 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -20,6 +20,7 @@ import { renderToString } from 'vue/server-renderer'; import { describe, expect, it, vi } from 'vitest'; import { getBranchNames } from '../components/utils'; import { + createTranslationIdentityCache, serializeVueChildren, translateVueChildren, } from '../rendering/translateVueChildren'; @@ -576,6 +577,156 @@ describe('gt-vue runtime', () => { mounted.app.unmount(); }); + it('bounds reconciliation identities to the current keyed source tree', () => { + const identityCache = createTranslationIdentityCache(); + const state = { + defaultLocale: 'en', + getCatalog: () => ({}), + locale: ref('en'), + } as unknown as Parameters[1]; + const Child = defineComponent({ + name: 'ChurnedKeyChild', + setup: () => () => h('span', 'child'), + }); + + for (let index = 0; index < 128; index += 1) { + translateVueChildren( + [index, index + 1].map((key) => + h(Fragment, { key: `key-${key}` }, [h(Child)]) + ), + state, + {}, + identityCache + ); + + expect(identityCache.explicitScopes.size).toBe(2); + expect(identityCache.generatedKeys.size).toBe(2); + expect(identityCache.typeScopes.size).toBe(1); + } + + expect(identityCache.nextExplicitScope).toBe(129); + expect(identityCache.nextTypeScope).toBe(1); + }); + + it('releases component types removed from the current source tree', () => { + const identityCache = createTranslationIdentityCache(); + const state = { + defaultLocale: 'en', + getCatalog: () => ({}), + locale: ref('en'), + } as unknown as Parameters[1]; + const Stable = defineComponent({ + name: 'StableType', + setup: () => () => h('span', 'stable'), + }); + let previousChangingType: ReturnType | undefined; + + for (let index = 0; index < 128; index += 1) { + const Changing = defineComponent({ + name: `ChangingType${index}`, + setup: () => () => h('span', 'changing'), + }); + translateVueChildren([h(Stable), h(Changing)], state, {}, identityCache); + + expect(identityCache.typeScopes.size).toBe(2); + expect(identityCache.generatedKeys.size).toBe(2); + expect(identityCache.typeScopes.has(Stable)).toBe(true); + expect(identityCache.typeScopes.has(Changing)).toBe(true); + if (previousChangingType) { + expect(identityCache.typeScopes.has(previousChangingType)).toBe(false); + } + previousChangingType = Changing; + } + + expect(identityCache.nextTypeScope).toBe(129); + }); + + it('rolls back identities allocated by an incomplete render', () => { + const identityCache = createTranslationIdentityCache(); + const locale = ref('en'); + let target: JsxChildren = 'Unused'; + const state = { + defaultLocale: 'en', + getCatalog: () => ({ broken: target }), + locale, + } as unknown as Parameters[1]; + const Stable = defineComponent({ + name: 'StableCompletedType', + setup: () => () => h('span', 'stable'), + }); + + translateVueChildren( + [h(Fragment, { key: 'stable-key' }, [h(Stable)])], + state, + {}, + identityCache + ); + const stableGeneratedKeys = [...identityCache.generatedKeys.keys()]; + locale.value = 'fr'; + + for (let index = 0; index < 32; index += 1) { + const Changing = defineComponent({ + name: `IncompleteType${index}`, + setup: () => () => h('span', 'changing'), + }); + const throwingTarget = Object.defineProperty( + { t: 'IncompleteType' }, + 'i', + { + get() { + throw new Error('incomplete target'); + }, + } + ); + target = [{ t: 'IncompleteType', i: 1 }, throwingTarget] as JsxChildren; + + expect(() => + translateVueChildren( + [h(Fragment, { key: `incomplete-key-${index}` }, [h(Changing)])], + state, + { _hash: 'broken' }, + identityCache + ) + ).toThrow('incomplete target'); + expect([...identityCache.explicitScopes.keys()]).toEqual(['stable-key']); + expect([...identityCache.generatedKeys.keys()]).toEqual( + stableGeneratedKeys + ); + expect([...identityCache.typeScopes.keys()]).toEqual([Stable]); + } + }); + + it('releases generated identities when a translation stops repeating an element', () => { + const identityCache = createTranslationIdentityCache(); + let target: JsxChildren = [ + { t: 'span', i: 1, c: 'First' }, + { t: 'span', i: 1, c: 'Second' }, + { t: 'span', i: 1, c: 'Third' }, + ]; + const state = { + defaultLocale: 'en', + getCatalog: () => ({ repeated: target }), + locale: ref('fr'), + } as unknown as Parameters[1]; + + translateVueChildren( + [h('span', 'Source')], + state, + { _hash: 'repeated' }, + identityCache + ); + expect(identityCache.generatedKeys.size).toBe(3); + + target = { t: 'span', i: 1, c: 'Only' }; + translateVueChildren( + [h('span', 'Source')], + state, + { _hash: 'repeated' }, + identityCache + ); + expect(identityCache.generatedKeys.size).toBe(1); + }); + it('preserves explicit string, number, and symbol keys during translation reorder', async () => { let setupCount = 0; const symbolKey = Symbol('source-key'); @@ -687,10 +838,13 @@ describe('gt-vue runtime', () => { const mounted = mount(Root, plugin); expect(textContent(mounted.root)).toBe('a:a:1|b:b:2|'); - order.value = ['b', 'a']; + order.value = ['b', 'c']; await nextTick(); - expect(textContent(mounted.root)).toBe('b:b:2|a:a:1|'); - expect(setupCount).toBe(2); + expect(textContent(mounted.root)).toBe('b:b:2|c:c:3|'); + order.value = ['c', 'a']; + await nextTick(); + expect(textContent(mounted.root)).toBe('c:c:3|a:a:4|'); + expect(setupCount).toBe(4); mounted.app.unmount(); }); @@ -724,6 +878,9 @@ describe('gt-vue runtime', () => { showKeyedSibling.value = false; await nextTick(); expect(textContent(mounted.root)).toBe('1|'); + showKeyedSibling.value = true; + await nextTick(); + expect(textContent(mounted.root)).toBe('keyed1|'); expect(setupCount).toBe(1); mounted.app.unmount(); }); @@ -756,6 +913,9 @@ describe('gt-vue runtime', () => { showUnrelatedSibling.value = false; await nextTick(); expect(textContent(mounted.root)).toBe('1|'); + showUnrelatedSibling.value = true; + await nextTick(); + expect(textContent(mounted.root)).toBe('unrelated1|'); expect(setupCount).toBe(1); mounted.app.unmount(); }); diff --git a/packages/vue/src/components/branches.ts b/packages/vue/src/components/branches.ts index 5f2f5cf3a8..916110230f 100644 --- a/packages/vue/src/components/branches.ts +++ b/packages/vue/src/components/branches.ts @@ -84,13 +84,7 @@ export const Branch = withGTMetadata( setup(props, { attrs, slots }) { return () => { const branch = props.branch?.toString(); - return asFragmentRoot( - getBranchContent( - branch && !branch.startsWith('data-') ? branch : undefined, - attrs, - slots - ) - ); + return asFragmentRoot(getBranchContent(branch, attrs, slots)); }; }, }), diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index ef73f5f82b..0e661a0ee1 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -81,6 +81,17 @@ export type TranslationIdentityCache = { typeScopes: Map; }; +/** Identities observed while constructing one complete translated render. */ +type TranslationIdentityRender = { + cache: TranslationIdentityCache; + createdExplicitScopes?: PropertyKey[]; + createdGeneratedKeys?: string[]; + createdTypeScopes?: unknown[]; + explicitScopes: Set; + generatedKeys: Set; + typeScopes: Set; +}; + type VNodeWithRenderMetadata = VNode & { ctx?: unknown; slotScopeIds?: string[] | null; @@ -94,32 +105,42 @@ export function translateVueChildren( options: RichTranslationOptions, identityCache: TranslationIdentityCache = createTranslationIdentityCache() ): VNodeChild { - const source = createSourceNodes(children, identityCache); - if (state.locale.value === state.defaultLocale) { - return renderDefaultNodes( - source, - state, - identityCache, - state.defaultLocale - ); - } - const hash = - options._hash ?? - hashSource({ - context: options.context ?? options.$context, - dataFormat: 'JSX', - source: serializeNodes(source), - }); - const target = state.getCatalog()[hash]; - if (target == null) { - return renderDefaultNodes( - source, - state, - identityCache, - state.defaultLocale - ); + const identityRender = createTranslationIdentityRender(identityCache); + let rendered: VNodeChild; + try { + const source = createSourceNodes(children, identityRender); + if (state.locale.value === state.defaultLocale) { + rendered = renderDefaultNodes( + source, + state, + identityRender, + state.defaultLocale + ); + } else { + const hash = + options._hash ?? + hashSource({ + context: options.context ?? options.$context, + dataFormat: 'JSX', + source: serializeNodes(source), + }); + const target = state.getCatalog()[hash]; + rendered = + target == null + ? renderDefaultNodes( + source, + state, + identityRender, + state.defaultLocale + ) + : renderNodes(source, target, state, identityRender); + } + } catch (error) { + rollbackTranslationIdentityCache(identityRender); + throw error; } - return renderNodes(source, target, state, identityCache); + sweepTranslationIdentityCache(identityRender); + return rendered; } /** Creates the per-T reconciliation cache shared by each reactive render. */ @@ -133,6 +154,62 @@ export function createTranslationIdentityCache(): TranslationIdentityCache { }; } +/** Creates the usage ledger for one synchronous `T` render. */ +function createTranslationIdentityRender( + cache: TranslationIdentityCache +): TranslationIdentityRender { + return { + cache, + explicitScopes: new Set(), + generatedKeys: new Set(), + typeScopes: new Set(), + }; +} + +/** + * Drops reconciliation identities that were absent from a completed render. + * + * An absent VNode has already left the returned tree, so Vue will remount it + * if it later returns. Monotonic scope counters intentionally remain intact to + * prevent new identities from colliding with scopes retained by this render. + */ +function sweepTranslationIdentityCache( + identityRender: TranslationIdentityRender +): void { + const { cache } = identityRender; + for (const key of cache.explicitScopes.keys()) { + if (!identityRender.explicitScopes.has(key)) { + cache.explicitScopes.delete(key); + } + } + for (const key of cache.generatedKeys.keys()) { + if (!identityRender.generatedKeys.has(key)) { + cache.generatedKeys.delete(key); + } + } + for (const type of cache.typeScopes.keys()) { + if (!identityRender.typeScopes.has(type)) { + cache.typeScopes.delete(type); + } + } +} + +/** Removes only identities first allocated by a render that did not finish. */ +function rollbackTranslationIdentityCache( + identityRender: TranslationIdentityRender +): void { + const { cache } = identityRender; + for (const key of identityRender.createdExplicitScopes ?? []) { + cache.explicitScopes.delete(key); + } + for (const key of identityRender.createdGeneratedKeys ?? []) { + cache.generatedKeys.delete(key); + } + for (const type of identityRender.createdTypeScopes ?? []) { + cache.typeScopes.delete(type); + } +} + /** * Serializes compiled Vue slot children into the complete persisted GT source. * @@ -141,24 +218,25 @@ export function createTranslationIdentityCache(): TranslationIdentityCache { * removes identity-only fields. */ export function serializeVueChildren(children: VNode[]): JsxChildren { + const identityCache = createTranslationIdentityCache(); return serializeNodes( - createSourceNodes(children, createTranslationIdentityCache()) + createSourceNodes(children, createTranslationIdentityRender(identityCache)) ); } function createSourceNodes( children: unknown, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): SourceNode[] { const index = { value: 0 }; - return visitChildren(children, index, 'root', identityCache); + return visitChildren(children, index, 'root', identityRender); } function visitChildren( children: unknown, index: { value: number }, identityScope: string, - identityCache: TranslationIdentityCache, + identityRender: TranslationIdentityRender, identityOccurrences: Map = new Map(), transparentKeyScope = false ): SourceNode[] { @@ -169,7 +247,7 @@ function visitChildren( child, index, identityScope, - identityCache, + identityRender, identityOccurrences, transparentKeyScope ) @@ -188,8 +266,8 @@ function visitChildren( return visitChildren( fragmentChildren, index, - getExplicitIdentityScope(identityScope, children.key, identityCache), - identityCache, + getExplicitIdentityScope(identityScope, children.key, identityRender), + identityRender, new Map(), true ); @@ -198,7 +276,7 @@ function visitChildren( fragmentChildren, index, identityScope, - identityCache, + identityRender, identityOccurrences, transparentKeyScope ); @@ -208,7 +286,7 @@ function visitChildren( const id = index.value; let identity: string; if (children.key == null) { - const typeScope = getVNodeTypeScope(children.type, identityCache); + const typeScope = getVNodeTypeScope(children.type, identityRender); const occurrence = (identityOccurrences.get(typeScope) ?? 0) + 1; identityOccurrences.set(typeScope, occurrence); identity = `${identityScope}/${typeScope}/o:${occurrence}`; @@ -216,7 +294,7 @@ function visitChildren( identity = getExplicitIdentityScope( identityScope, children.key, - identityCache + identityRender ); } const metadata = getGTMetadata(children); @@ -237,7 +315,7 @@ function visitChildren( transformation === 'branch' || transformation === 'plural' ? `${identity}/default` : identity, - identityCache + identityRender ), id, identity, @@ -255,7 +333,7 @@ function visitChildren( transformation, id, identity, - identityCache + identityRender ); } return [source]; @@ -264,13 +342,16 @@ function visitChildren( /** Gives each distinct Vue VNode type a stable per-T identity token. */ function getVNodeTypeScope( type: unknown, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): string { - let scope = identityCache.typeScopes.get(type); + const { cache } = identityRender; + identityRender.typeScopes.add(type); + let scope = cache.typeScopes.get(type); if (!scope) { - identityCache.nextTypeScope += 1; - scope = `t:${identityCache.nextTypeScope}`; - identityCache.typeScopes.set(type, scope); + cache.nextTypeScope += 1; + scope = `t:${cache.nextTypeScope}`; + cache.typeScopes.set(type, scope); + (identityRender.createdTypeScopes ??= []).push(type); } return scope; } @@ -279,13 +360,16 @@ function getVNodeTypeScope( function getExplicitIdentityScope( parentScope: string, key: PropertyKey, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): string { - let scope = identityCache.explicitScopes.get(key); + const { cache } = identityRender; + identityRender.explicitScopes.add(key); + let scope = cache.explicitScopes.get(key); if (!scope) { - identityCache.nextExplicitScope += 1; - scope = `k:${identityCache.nextExplicitScope}`; - identityCache.explicitScopes.set(key, scope); + cache.nextExplicitScope += 1; + scope = `k:${cache.nextExplicitScope}`; + cache.explicitScopes.set(key, scope); + (identityRender.createdExplicitScopes ??= []).push(key); } return `${parentScope}/${scope}`; } @@ -357,7 +441,7 @@ function getBranches( transformation: 'branch' | 'plural', branchElementId: number, identity: string, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): Record { const inputs = Object.create(null) as Record; if (isSlots(vnode.children)) { @@ -394,7 +478,7 @@ function getBranches( value, { value: branchElementId }, `${identity}/branch:${key.length}:${key}`, - identityCache + identityRender ), ]) ); @@ -467,12 +551,17 @@ function renderNodes( source: SourceNode[], target: JsxChildren | undefined, state: GTState, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): VNodeChild { if (target == null) { // A partial translated tree falls back within the active locale. A wholly // missing catalog entry is handled above using the source/default locale. - return renderDefaultNodes(source, state, identityCache, state.locale.value); + return renderDefaultNodes( + source, + state, + identityRender, + state.locale.value + ); } if (typeof target === 'string') return target; @@ -502,11 +591,11 @@ function renderNodes( renderDefaultNode( variable, state, - identityCache, + identityRender, state.locale.value ), occurrences, - identityCache + identityRender ) : null; } @@ -520,9 +609,9 @@ function renderNodes( return sourceNode ? keySourceResult( sourceNode, - renderElement(sourceNode, targetNode, state, identityCache), + renderElement(sourceNode, targetNode, state, identityRender), occurrences, - identityCache + identityRender ) : null; }); @@ -540,7 +629,7 @@ function keySourceResult( source: SourceElement, rendered: VNodeChild, occurrences: Map, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): VNode { const occurrence = occurrences.get(source) ?? 0; occurrences.set(source, occurrence + 1); @@ -552,10 +641,13 @@ function keySourceResult( if (explicitKey != null) { key = explicitKey; } else { - let generatedKey = identityCache.generatedKeys.get(cacheKey); + const { cache } = identityRender; + identityRender.generatedKeys.add(cacheKey); + let generatedKey = cache.generatedKeys.get(cacheKey); if (!generatedKey) { generatedKey = Symbol(cacheKey); - identityCache.generatedKeys.set(cacheKey, generatedKey); + cache.generatedKeys.set(cacheKey, generatedKey); + (identityRender.createdGeneratedKeys ??= []).push(cacheKey); } key = generatedKey; } @@ -576,7 +668,7 @@ function renderElement( source: SourceElement, target: JsxElement, state: GTState, - identityCache: TranslationIdentityCache + identityRender: TranslationIdentityRender ): VNodeChild { if (source.transformation === 'branch') { const branch = getBranchKey(source.vnode); @@ -584,7 +676,7 @@ function renderElement( getSelectedSourceBranch(source, branch), getSelectedTargetBranch(target, branch), state, - identityCache + identityRender ); } if (source.transformation === 'plural') { @@ -593,7 +685,7 @@ function renderElement( return renderDefaultNode( source, state, - identityCache, + identityRender, state.locale.value ); } @@ -616,11 +708,11 @@ function renderElement( getSelectedSourceBranch(source, sourceBranch), (targetBranch && targetBranches[targetBranch]) ?? target.c, state, - identityCache + identityRender ); } if (source.transformation === 'fragment') { - return renderNodes(source.children, target.c, state, identityCache); + return renderNodes(source.children, target.c, state, identityRender); } if (source.opaque) { const translatedProps = getTranslatedProps(target); @@ -636,7 +728,7 @@ function renderElement( renderDefaultNodes( source.children, state, - identityCache, + identityRender, state.locale.value ), translatedProps @@ -648,7 +740,7 @@ function renderElement( return cloneWithChildren( source.vnode, - renderNodes(source.children, target.c, state, identityCache), + renderNodes(source.children, target.c, state, identityRender), translatedProps ); } @@ -657,7 +749,7 @@ function getBranchKey(source: VNode): string | undefined { const branch = source.props?.branch; if (branch == null) return undefined; const key = String(branch); - return key && !key.startsWith('data-') ? key : undefined; + return key || undefined; } function getPluralKey( @@ -728,7 +820,7 @@ function getTranslatedProps(target: JsxElement): Record { function renderDefaultNodes( nodes: SourceNode[], state: GTState, - identityCache: TranslationIdentityCache, + identityRender: TranslationIdentityRender, locale: string ): VNodeChild[] { const occurrences = new Map(); @@ -737,9 +829,9 @@ function renderDefaultNodes( ? node : keySourceResult( node, - renderDefaultNode(node, state, identityCache, locale), + renderDefaultNode(node, state, identityRender, locale), occurrences, - identityCache + identityRender ) ); } @@ -747,7 +839,7 @@ function renderDefaultNodes( function renderDefaultNode( node: SourceNode, state: GTState, - identityCache: TranslationIdentityCache, + identityRender: TranslationIdentityRender, locale: string ): VNodeChild { if (typeof node === 'string') return node; @@ -757,20 +849,20 @@ function renderDefaultNode( : node.vnode; } if (node.transformation === 'fragment') { - return renderDefaultNodes(node.children, state, identityCache, locale); + return renderDefaultNodes(node.children, state, identityRender, locale); } if (node.transformation === 'branch') { return renderDefaultNodes( getSelectedSourceBranch(node, getBranchKey(node.vnode)), state, - identityCache, + identityRender, locale ); } if (node.transformation === 'plural') { const n = node.vnode.props?.n; if (typeof n !== 'number') { - return renderDefaultNodes(node.children, state, identityCache, locale); + return renderDefaultNodes(node.children, state, identityRender, locale); } const branch = getPluralKey( n, @@ -783,14 +875,14 @@ function renderDefaultNode( return renderDefaultNodes( getSelectedSourceBranch(node, branch), state, - identityCache, + identityRender, locale ); } if (!node.children.length) return node.vnode; return cloneWithChildren( node.vnode, - renderDefaultNodes(node.children, state, identityCache, locale) + renderDefaultNodes(node.children, state, identityRender, locale) ); } From f00d89e3d498f959ad200dd5b3863cf47946e062 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 6 Aug 2026 10:13:59 -0700 Subject: [PATCH 27/28] chore(i18n): release string helpers as patch --- .changeset/tidy-vue-messages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-vue-messages.md b/.changeset/tidy-vue-messages.md index 912ca7342c..0bc595f51f 100644 --- a/.changeset/tidy-vue-messages.md +++ b/.changeset/tidy-vue-messages.md @@ -1,5 +1,5 @@ --- -'gt-i18n': minor +'gt-i18n': patch --- Make `msg(..., { $format: 'STRING' })` preserve source text literally instead From 8184c2faef61c97c6daef68508e69bf1ffb57fa4 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 6 Aug 2026 14:55:48 -0700 Subject: [PATCH 28/28] fix(vue): persist locale in browser cookie --- .changeset/calm-pandas-translate.md | 6 +- .changeset/tidy-vue-messages.md | 3 +- .claude/CLAUDE.md | 2 +- .size-limit.cjs | 1 + packages/i18n/package.json | 13 + packages/i18n/src/internal-cookies.ts | 11 + .../utils/__tests__/browserCookies.test.ts | 51 ++++ packages/i18n/src/utils/browserCookies.ts | 44 +++ packages/i18n/src/utils/cookieNames.ts | 11 + packages/i18n/tsdown.config.mts | 1 + packages/react-core/src/setup/cookieNames.ts | 31 +-- packages/vue/README.md | 28 +- packages/vue/src/__tests__/runtime.test.ts | 14 +- packages/vue/src/__tests__/state.test.ts | 260 +++++++++++++++++- packages/vue/src/components/branches.ts | 2 +- packages/vue/src/components/variables.ts | 2 +- packages/vue/src/composables/locale.ts | 7 +- .../vue/src/rendering/translateVueChildren.ts | 17 +- packages/vue/src/runtime/localeCookie.ts | 54 ++++ packages/vue/src/runtime/state.ts | 36 ++- packages/vue/src/types/index.ts | 15 +- scripts/check-library-defaults.mjs | 8 +- scripts/check-library-defaults.test.mjs | 2 +- 23 files changed, 545 insertions(+), 74 deletions(-) create mode 100644 packages/i18n/src/internal-cookies.ts create mode 100644 packages/i18n/src/utils/__tests__/browserCookies.test.ts create mode 100644 packages/i18n/src/utils/browserCookies.ts create mode 100644 packages/i18n/src/utils/cookieNames.ts create mode 100644 packages/vue/src/runtime/localeCookie.ts diff --git a/.changeset/calm-pandas-translate.md b/.changeset/calm-pandas-translate.md index dca99a35ec..877aff69c5 100644 --- a/.changeset/calm-pandas-translate.md +++ b/.changeset/calm-pandas-translate.md @@ -3,5 +3,7 @@ --- Add a lightweight Vue 3 runtime with catalog-backed string and rich-content -translation, reactive locale switching, child-only variables, and typed value -props for number, currency, and date formatting. +translation, cookie-backed reactive locale switching, child-only variables, +and typed value props for number, currency, and date formatting. Browser SPAs +restore the locale from a configurable cookie, while an explicit server locale +wins during SSR hydration. diff --git a/.changeset/tidy-vue-messages.md b/.changeset/tidy-vue-messages.md index 0bc595f51f..f60c2bbf3d 100644 --- a/.changeset/tidy-vue-messages.md +++ b/.changeset/tidy-vue-messages.md @@ -6,4 +6,5 @@ Make `msg(..., { $format: 'STRING' })` preserve source text literally instead of applying ICU interpolation. For example, `msg('Hello {name}', { $format: 'STRING', name: 'Ada' })` now encodes `Hello {name}` rather than `Hello Ada`. Add lightweight shared helpers for registering, hashing, and -decoding literal STRING messages, and validate encoded fields by type. +decoding literal STRING messages, browser cookie access, and canonical GT +cookie names, and validate encoded fields by type. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 07c70c12a7..54c4d34bd2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -74,7 +74,7 @@ Turbo tasks: `build`, `test`, `lint`, `lint:fix`, `format`, `format:fix`, `trans - Never repeat a library fallback as a literal in production source. Import the canonical constant so a default change propagates across every package. Before adding a fallback, search the owning package's settings and constants modules for an existing default. - The canonical locale is `libraryDefaultLocale`. Inside `packages/core`, import it from `src/settings/settings.ts`; inside `packages/format`, import its local copy from `src/settings/settings.ts` because core depends on format; everywhere else, import it from `generaltranslation/internal`. - Core's canonical request timeout is `defaultTimeout` in `packages/core/src/settings/settings.ts`. Its service endpoints are `defaultBaseUrl`, `defaultCacheUrl`, and `defaultRuntimeApiUrl` in `packages/core/src/settings/settingsUrls.ts`; other packages consume the endpoint defaults from `generaltranslation/internal`. -- The canonical locale, region, feature-flag, and reset cookie names live in `packages/react-core/src/setup/cookieNames.ts` and are available from `@generaltranslation/react-core/pure`. The Next.js routing cookie and locale header defaults live in `packages/next/src/utils/cookies.ts` and `packages/next/src/utils/headers.ts`; gt-next source should import those constants instead of repeating their values. +- The canonical locale, region, feature-flag, and reset cookie names live in `packages/i18n/src/utils/cookieNames.ts` and are available from `gt-i18n/internal/cookies`; React integrations also re-export them from `@generaltranslation/react-core/pure` for compatibility. The Next.js routing cookie and locale header defaults live in `packages/next/src/utils/cookies.ts` and `packages/next/src/utils/headers.ts`; gt-next source should import those constants instead of repeating their values. - `packages/core` and `packages/format` intentionally define matching locale and timeout defaults to preserve their dependency direction. Keep the copies synchronized. - Tests, fixtures, examples, and documentation may use explicit values when the value itself matters to the scenario. Prefer canonical constants when testing default behavior. - Run `pnpm check:library-defaults` after changing a canonical default or adding fallback behavior. If a literal matches a canonical value but has a distinct meaning, add only a narrow, documented exception to `scripts/check-library-defaults.mjs`. diff --git a/.size-limit.cjs b/.size-limit.cjs index 7274d74b32..062f0dfe8f 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -96,6 +96,7 @@ module.exports = [ i18n('gt-i18n', 'index'), i18n('gt-i18n/types', 'types'), i18n('gt-i18n/internal', 'internal'), + i18n('gt-i18n/internal/cookies', 'internal-cookies'), i18n('gt-i18n/internal/string', 'internal-string'), i18n('gt-i18n/internal/types', 'internal-types'), diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 9a9ba17daf..3e41266b29 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -86,6 +86,16 @@ "default": "./dist/internal.mjs" } }, + "./internal/cookies": { + "require": { + "types": "./dist/internal-cookies.d.cts", + "default": "./dist/internal-cookies.cjs" + }, + "import": { + "types": "./dist/internal-cookies.d.mts", + "default": "./dist/internal-cookies.mjs" + } + }, "./internal/string": { "require": { "types": "./dist/internal-string.d.cts", @@ -115,6 +125,9 @@ "internal": [ "./dist/internal.d.cts" ], + "internal/cookies": [ + "./dist/internal-cookies.d.cts" + ], "internal/string": [ "./dist/internal-string.d.cts" ], diff --git a/packages/i18n/src/internal-cookies.ts b/packages/i18n/src/internal-cookies.ts new file mode 100644 index 0000000000..b43ea4c697 --- /dev/null +++ b/packages/i18n/src/internal-cookies.ts @@ -0,0 +1,11 @@ +export { + defaultEnableI18nCookieName, + defaultLocaleCookieName, + defaultRegionCookieName, + defaultResetLocaleCookieName, +} from './utils/cookieNames'; +export { + getBrowserCookieValue, + setBrowserCookieValue, +} from './utils/browserCookies'; +export { getCookieValue } from './utils/request'; diff --git a/packages/i18n/src/utils/__tests__/browserCookies.test.ts b/packages/i18n/src/utils/__tests__/browserCookies.test.ts new file mode 100644 index 0000000000..eaef6491ed --- /dev/null +++ b/packages/i18n/src/utils/__tests__/browserCookies.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getBrowserCookieValue, + setBrowserCookieValue, +} from '../browserCookies'; +import { + defaultEnableI18nCookieName, + defaultLocaleCookieName, + defaultRegionCookieName, + defaultResetLocaleCookieName, +} from '../cookieNames'; + +describe('browser cookies', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('keeps the canonical GT cookie names in one dependency-free module', () => { + expect(defaultLocaleCookieName).toBe('generaltranslation.locale'); + expect(defaultRegionCookieName).toBe('generaltranslation.region'); + expect(defaultEnableI18nCookieName).toBe('generaltranslation.enable-i18n'); + expect(defaultResetLocaleCookieName).toBe( + 'generaltranslation.locale-reset' + ); + }); + + it('reads and decodes an exact browser cookie name', () => { + vi.stubGlobal('document', { + cookie: + 'other=value; generaltranslation.locale=brand%2Dfrench%3Dca; generaltranslation.locale-extra=es', + }); + + expect(getBrowserCookieValue(defaultLocaleCookieName)).toBe( + 'brand-french=ca' + ); + }); + + it('writes the path-wide session-cookie contract used by GT web runtimes', () => { + const cookieDocument = { cookie: '' }; + vi.stubGlobal('document', cookieDocument); + + setBrowserCookieValue('custom-locale', 'fr-CA'); + + expect(cookieDocument.cookie).toBe('custom-locale=fr-CA;path=/'); + }); + + it('does not access browser globals during SSR', () => { + expect(getBrowserCookieValue(defaultLocaleCookieName)).toBeUndefined(); + expect(() => + setBrowserCookieValue(defaultLocaleCookieName, 'fr') + ).not.toThrow(); + }); +}); diff --git a/packages/i18n/src/utils/browserCookies.ts b/packages/i18n/src/utils/browserCookies.ts new file mode 100644 index 0000000000..70af02cc12 --- /dev/null +++ b/packages/i18n/src/utils/browserCookies.ts @@ -0,0 +1,44 @@ +import { getCookieValue } from './request'; + +type CookieDocument = { + cookie: string; +}; + +/** + * Reads and decodes a browser cookie without accessing `document` during SSR. + * + * @param cookieName - Exact cookie name to read. + * @returns The decoded cookie value, or `undefined` outside a browser or when + * the cookie is absent. + */ +export function getBrowserCookieValue(cookieName: string): string | undefined { + const cookieDocument = getCookieDocument(); + return cookieDocument + ? getCookieValue(cookieDocument.cookie, cookieName) + : undefined; +} + +/** + * Writes a path-wide browser session cookie without accessing `document` + * during SSR. + * + * The serialized form intentionally matches the existing GT React browser + * store so all web runtimes share the same cookie contract. + * + * @param cookieName - Cookie name to write. + * @param value - Raw cookie value to persist. + */ +export function setBrowserCookieValue(cookieName: string, value: string): void { + const cookieDocument = getCookieDocument(); + if (cookieDocument) { + cookieDocument.cookie = `${cookieName}=${value};path=/`; + } +} + +function getCookieDocument(): CookieDocument | undefined { + return ( + globalThis as typeof globalThis & { + document?: CookieDocument; + } + ).document; +} diff --git a/packages/i18n/src/utils/cookieNames.ts b/packages/i18n/src/utils/cookieNames.ts new file mode 100644 index 0000000000..7eab8522a9 --- /dev/null +++ b/packages/i18n/src/utils/cookieNames.ts @@ -0,0 +1,11 @@ +/** Cookie name for tracking the user's selected locale. */ +export const defaultLocaleCookieName = 'generaltranslation.locale'; + +/** Cookie name for tracking the user's selected region. */ +export const defaultRegionCookieName = 'generaltranslation.region'; + +/** Cookie name for persisting the enable-i18n feature flag. */ +export const defaultEnableI18nCookieName = 'generaltranslation.enable-i18n'; + +/** Cookie name for tracking an explicit locale reset. */ +export const defaultResetLocaleCookieName = 'generaltranslation.locale-reset'; diff --git a/packages/i18n/tsdown.config.mts b/packages/i18n/tsdown.config.mts index f806040edc..4baf568b4a 100644 --- a/packages/i18n/tsdown.config.mts +++ b/packages/i18n/tsdown.config.mts @@ -4,6 +4,7 @@ import { createTsdownConfig } from '../../tsdown.preset.mts'; export default defineConfig( createTsdownConfig([ 'src/index.ts', + 'src/internal-cookies.ts', 'src/internal-string.ts', 'src/types.ts', 'src/internal.ts', diff --git a/packages/react-core/src/setup/cookieNames.ts b/packages/react-core/src/setup/cookieNames.ts index 48ab900b8f..30dfc9bab8 100644 --- a/packages/react-core/src/setup/cookieNames.ts +++ b/packages/react-core/src/setup/cookieNames.ts @@ -1,23 +1,8 @@ -// Dependency-free module: these constants are imported by size-constrained -// consumers (e.g. gt-next's edge middleware), which must not pull in the -// ReactI18nConfig class or its gt-i18n imports. - -/** - * Cookie name for tracking the user's selected locale. - */ -export const defaultLocaleCookieName = 'generaltranslation.locale'; - -/** - * Cookie name for tracking the user's selected region. - */ -export const defaultRegionCookieName = 'generaltranslation.region'; - -/** - * Cookie name for persisting the enableI18n feature flag. - */ -export const defaultEnableI18nCookieName = 'generaltranslation.enable-i18n'; - -/** - * Cookie name for tracking the locale reset. - */ -export const defaultResetLocaleCookieName = 'generaltranslation.locale-reset'; +// Preserve the established react-core export path while sharing the +// dependency-free cookie contract with non-React web runtimes. +export { + defaultEnableI18nCookieName, + defaultLocaleCookieName, + defaultRegionCookieName, + defaultResetLocaleCookieName, +} from 'gt-i18n/internal/cookies'; diff --git a/packages/vue/README.md b/packages/vue/README.md index 6c3343d25c..9d430a423b 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -151,11 +151,25 @@ and then the default locale. Inside ``, the rich translation pipeline owns formatting locales: source fallbacks use the default locale, while translated content uses the active locale followed by the default. -`setLocale()` loads a missing catalog, switches the reactive locale, and -rerenders consumers. Locale persistence and development hot reload are outside -this package; applications can persist their chosen locale separately. - -For SSR, call and await `plugin.loadTranslations(locale)` or -`plugin.setLocale(locale)` before rendering the app, and create a fresh -`createGT()` instance for each request so locale and catalog state stay +In a browser, gt-vue persists the active locale in the +`generaltranslation.locale` path-wide session cookie. When `locale` is omitted +from `createGT()`, that cookie wins over `defaultLocale`. Use +`localeCookieName` to share a different cookie with your routing or server +integration. + +`setLocale()` loads a missing catalog before writing the cookie and rerendering +consumers. A failed or superseded request leaves both the cookie and rendered +locale unchanged. Direct changes to `document.cookie` are reflected by +`plugin.getLocale()` and the next Vue render, but browsers do not emit cookie +change events, so they do not schedule a render by themselves. Use gt-vue's +setter for reactive locale changes. + +For SSR, resolve the request locale on the server and pass it as +`createGT({ locale })`. An explicit locale wins over a stale browser cookie, +which keeps hydration consistent and synchronizes the client cookie. Call and +await `plugin.loadTranslations(locale)` or `plugin.setLocale(locale)` before +server rendering. Create and preload the client plugin with the same locale +before hydrating; starting hydration before its asynchronous catalog is ready +can produce source text and a hydration mismatch. Create a fresh `createGT()` +instance for every server request so locale and catalog state remain request-scoped. diff --git a/packages/vue/src/__tests__/runtime.test.ts b/packages/vue/src/__tests__/runtime.test.ts index 47bf5e4bb2..12e51ce791 100644 --- a/packages/vue/src/__tests__/runtime.test.ts +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -142,7 +142,7 @@ describe('gt-vue runtime', () => { getCatalog: vi.fn(() => { throw new Error('default-locale catalog was read'); }), - locale: ref('en'), + getLocale: () => 'en', } as unknown as Parameters[1]; const rendered = translateVueChildren([source], state, {}); @@ -582,7 +582,7 @@ describe('gt-vue runtime', () => { const state = { defaultLocale: 'en', getCatalog: () => ({}), - locale: ref('en'), + getLocale: () => 'en', } as unknown as Parameters[1]; const Child = defineComponent({ name: 'ChurnedKeyChild', @@ -613,7 +613,7 @@ describe('gt-vue runtime', () => { const state = { defaultLocale: 'en', getCatalog: () => ({}), - locale: ref('en'), + getLocale: () => 'en', } as unknown as Parameters[1]; const Stable = defineComponent({ name: 'StableType', @@ -643,12 +643,12 @@ describe('gt-vue runtime', () => { it('rolls back identities allocated by an incomplete render', () => { const identityCache = createTranslationIdentityCache(); - const locale = ref('en'); + let locale = 'en'; let target: JsxChildren = 'Unused'; const state = { defaultLocale: 'en', getCatalog: () => ({ broken: target }), - locale, + getLocale: () => locale, } as unknown as Parameters[1]; const Stable = defineComponent({ name: 'StableCompletedType', @@ -662,7 +662,7 @@ describe('gt-vue runtime', () => { identityCache ); const stableGeneratedKeys = [...identityCache.generatedKeys.keys()]; - locale.value = 'fr'; + locale = 'fr'; for (let index = 0; index < 32; index += 1) { const Changing = defineComponent({ @@ -706,7 +706,7 @@ describe('gt-vue runtime', () => { const state = { defaultLocale: 'en', getCatalog: () => ({ repeated: target }), - locale: ref('fr'), + getLocale: () => 'fr', } as unknown as Parameters[1]; translateVueChildren( diff --git a/packages/vue/src/__tests__/state.test.ts b/packages/vue/src/__tests__/state.test.ts index 85f01fc864..f3f970c762 100644 --- a/packages/vue/src/__tests__/state.test.ts +++ b/packages/vue/src/__tests__/state.test.ts @@ -3,16 +3,24 @@ import { defineComponent, h, nextTick, + ref, type Component, } from 'vue'; +import { hashSource } from 'generaltranslation/id'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGT, useGT, useLocale } from '../index'; import type { GTPlugin, TranslationCatalog } from '../index'; describe('gt-vue runtime state', () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); it('rejects failed locale changes, preserves the locale, and retries', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=en' + ); const error = new Error('catalog unavailable'); const loadTranslations = vi.fn(async () => { throw error; @@ -24,6 +32,7 @@ describe('gt-vue runtime state', () => { await expect(plugin.setLocale('fr')).rejects.toBe(error); expect(plugin.getLocale()).toBe('en'); + expect(cookieDocument.get('generaltranslation.locale')).toBe('en'); await expect(plugin.setLocale('fr')).rejects.toBe(error); expect(loadTranslations).toHaveBeenCalledTimes(2); @@ -64,6 +73,204 @@ describe('gt-vue runtime state', () => { expect(consoleError).toHaveBeenCalledTimes(1); }); + it('uses a browser cookie before the default locale and persists it', () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=fr' + ); + + const firstPlugin = createGT({ defaultLocale: 'en' }); + expect(firstPlugin.getLocale()).toBe('fr'); + + cookieDocument.cookie = 'generaltranslation.locale=es;path=/'; + const secondPlugin = createGT({ defaultLocale: 'en' }); + expect(secondPlugin.getLocale()).toBe('es'); + }); + + it('persists the default locale when the browser has no locale cookie', () => { + const cookieDocument = installCookieDocument(); + + const plugin = createGT({ defaultLocale: 'en' }); + + expect(plugin.getLocale()).toBe('en'); + expect(cookieDocument.get('generaltranslation.locale')).toBe('en'); + }); + + it('falls back instead of retaining client state when the cookie is removed', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=fr' + ); + const plugin = createGT({ defaultLocale: 'en' }); + + expect(plugin.getLocale()).toBe('fr'); + cookieDocument.delete('generaltranslation.locale'); + expect(plugin.getLocale()).toBe('en'); + + await plugin.setLocale('de'); + expect(plugin.getLocale()).toBe('de'); + cookieDocument.delete('generaltranslation.locale'); + expect(plugin.getLocale()).toBe('en'); + }); + + it('uses an explicit hydration locale before a stale browser cookie', () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=fr' + ); + + const plugin = createGT({ defaultLocale: 'en', locale: 'de' }); + + expect(plugin.getLocale()).toBe('de'); + expect(cookieDocument.get('generaltranslation.locale')).toBe('de'); + }); + + it('supports a custom locale cookie name', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=fr; custom-locale=es' + ); + const plugin = createGT({ localeCookieName: 'custom-locale' }); + + expect(plugin.getLocale()).toBe('es'); + await plugin.setLocale('de'); + + expect(cookieDocument.get('custom-locale')).toBe('de'); + expect(cookieDocument.get('generaltranslation.locale')).toBe('fr'); + }); + + it('writes a loaded locale to the cookie and rerenders consumers', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=en' + ); + let resolveCatalog!: (catalog: TranslationCatalog) => void; + const loadTranslations = vi.fn( + () => + new Promise((resolve) => { + resolveCatalog = resolve; + }) + ); + const plugin = createGT({ loadTranslations }); + const Root = defineComponent({ + setup() { + const locale = useLocale(); + return () => h('p', locale.value); + }, + }); + const mounted = mount(Root, plugin); + cookieDocument.writes.length = 0; + + const switching = plugin.setLocale('fr'); + await vi.waitFor(() => expect(loadTranslations).toHaveBeenCalledOnce()); + + expect(plugin.getLocale()).toBe('en'); + expect(textContent(mounted.root)).toBe('en'); + expect(cookieDocument.writes).toEqual([]); + + resolveCatalog({}); + await switching; + await nextTick(); + + expect(plugin.getLocale()).toBe('fr'); + expect(textContent(mounted.root)).toBe('fr'); + expect(cookieDocument.writes).toEqual([ + 'generaltranslation.locale=fr;path=/', + ]); + mounted.app.unmount(); + }); + + it('keeps the cookie aligned with the latest concurrent locale request', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=en' + ); + const pending = new Map void>(); + const plugin = createGT({ + loadTranslations: (locale) => + new Promise((resolve) => pending.set(locale, resolve)), + }); + + const french = plugin.setLocale('fr'); + const chinese = plugin.setLocale('zh'); + await vi.waitFor(() => expect(pending.size).toBe(2)); + + pending.get('zh')?.({}); + await chinese; + expect(cookieDocument.get('generaltranslation.locale')).toBe('zh'); + + pending.get('fr')?.({}); + await french; + expect(plugin.getLocale()).toBe('zh'); + expect(cookieDocument.get('generaltranslation.locale')).toBe('zh'); + }); + + it('reads an external cookie write and rerenders when setLocale is called', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=en' + ); + const plugin = createGT(); + const Root = defineComponent({ + setup() { + const locale = useLocale(); + return () => h('p', locale.value); + }, + }); + const mounted = mount(Root, plugin); + + cookieDocument.cookie = 'generaltranslation.locale=fr;path=/'; + + expect(plugin.getLocale()).toBe('fr'); + expect(textContent(mounted.root)).toBe('en'); + + await plugin.setLocale('fr'); + await nextTick(); + + expect(textContent(mounted.root)).toBe('fr'); + mounted.app.unmount(); + }); + + it('keeps useLocale and translations aligned on unrelated rerenders', async () => { + const cookieDocument = installCookieDocument( + 'generaltranslation.locale=en' + ); + const source = 'Hello'; + const plugin = createGT({ + loadTranslations: async (locale) => + locale === 'fr' + ? { + [hashSource({ + dataFormat: 'STRING', + source, + })]: 'Bonjour', + } + : {}, + }); + await plugin.loadTranslations('fr'); + const counter = ref(0); + const Root = defineComponent({ + setup() { + const gt = useGT(); + const locale = useLocale(); + return () => h('p', `${locale.value}|${gt(source)}|${counter.value}`); + }, + }); + const mounted = mount(Root, plugin); + + expect(textContent(mounted.root)).toBe('en|Hello|0'); + cookieDocument.cookie = 'generaltranslation.locale=fr;path=/'; + counter.value += 1; + await nextTick(); + + expect(textContent(mounted.root)).toBe('fr|Bonjour|1'); + mounted.app.unmount(); + }); + + it('uses the explicit locale without browser globals during SSR', async () => { + const plugin = createGT({ + locale: 'fr', + loadTranslations: async () => ({}), + }); + + expect(plugin.getLocale()).toBe('fr'); + await plugin.setLocale('de'); + expect(plugin.getLocale()).toBe('de'); + }); + it('does not rerender active consumers when another locale is preloaded', async () => { let renders = 0; const plugin = createGT({ loadTranslations: async () => ({}) }); @@ -159,3 +366,54 @@ function mount(rootComponent: Component, plugin?: GTPlugin) { function createHostNode(type: string, text = ''): HostNode { return { children: [], parent: null, props: {}, text, type }; } + +function textContent(node: HostNode): string { + return node.text + node.children.map(textContent).join(''); +} + +class TestCookieDocument { + readonly writes: string[] = []; + private readonly values = new Map(); + + constructor(cookieHeader = '') { + for (const cookie of cookieHeader.split(';')) { + const separator = cookie.indexOf('='); + if (separator < 0) continue; + this.values.set( + cookie.slice(0, separator).trim(), + cookie.slice(separator + 1).trim() + ); + } + } + + get cookie(): string { + return [...this.values] + .map(([name, value]) => `${name}=${value}`) + .join('; '); + } + + set cookie(serializedCookie: string) { + this.writes.push(serializedCookie); + const [cookie = ''] = serializedCookie.split(';'); + const separator = cookie.indexOf('='); + if (separator < 0) return; + this.values.set( + cookie.slice(0, separator).trim(), + cookie.slice(separator + 1).trim() + ); + } + + get(cookieName: string): string | undefined { + return this.values.get(cookieName); + } + + delete(cookieName: string): void { + this.values.delete(cookieName); + } +} + +function installCookieDocument(cookieHeader = ''): TestCookieDocument { + const cookieDocument = new TestCookieDocument(cookieHeader); + vi.stubGlobal('document', cookieDocument); + return cookieDocument; +} diff --git a/packages/vue/src/components/branches.ts b/packages/vue/src/components/branches.ts index 916110230f..516eec2037 100644 --- a/packages/vue/src/components/branches.ts +++ b/packages/vue/src/components/branches.ts @@ -55,7 +55,7 @@ export const Plural = withGTMetadata( branches, getFormatLocales( props.locales, - state.locale.value, + state.getLocale(), state.defaultLocale ) ); diff --git a/packages/vue/src/components/variables.ts b/packages/vue/src/components/variables.ts index bc8a9de6aa..b18438dff5 100644 --- a/packages/vue/src/components/variables.ts +++ b/packages/vue/src/components/variables.ts @@ -211,6 +211,6 @@ function getVariableFormatLocales( state: ReturnType ): string[] { return props._locale === undefined - ? getFormatLocales(props.locales, state.locale.value, state.defaultLocale) + ? getFormatLocales(props.locales, state.getLocale(), state.defaultLocale) : getFormatLocales(undefined, props._locale, state.defaultLocale); } diff --git a/packages/vue/src/composables/locale.ts b/packages/vue/src/composables/locale.ts index a0a26799dc..e20ee49479 100644 --- a/packages/vue/src/composables/locale.ts +++ b/packages/vue/src/composables/locale.ts @@ -1,4 +1,4 @@ -import { readonly, type DeepReadonly, type Ref } from 'vue'; +import { toRef, type Ref } from 'vue'; import { useGTState } from '../runtime/state'; /** @@ -10,8 +10,9 @@ import { useGTState } from '../runtime/state'; * * @returns A readonly reactive locale ref. */ -export function useLocale(): DeepReadonly> { - return readonly(useGTState().locale); +export function useLocale(): Readonly> { + const state = useGTState(); + return toRef(state.getLocale); } /** diff --git a/packages/vue/src/rendering/translateVueChildren.ts b/packages/vue/src/rendering/translateVueChildren.ts index 0e661a0ee1..2692e70e5c 100644 --- a/packages/vue/src/rendering/translateVueChildren.ts +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -109,7 +109,7 @@ export function translateVueChildren( let rendered: VNodeChild; try { const source = createSourceNodes(children, identityRender); - if (state.locale.value === state.defaultLocale) { + if (state.getLocale() === state.defaultLocale) { rendered = renderDefaultNodes( source, state, @@ -556,12 +556,7 @@ function renderNodes( if (target == null) { // A partial translated tree falls back within the active locale. A wholly // missing catalog entry is handled above using the source/default locale. - return renderDefaultNodes( - source, - state, - identityRender, - state.locale.value - ); + return renderDefaultNodes(source, state, identityRender, state.getLocale()); } if (typeof target === 'string') return target; @@ -592,7 +587,7 @@ function renderNodes( variable, state, identityRender, - state.locale.value + state.getLocale() ), occurrences, identityRender @@ -686,7 +681,7 @@ function renderElement( source, state, identityRender, - state.locale.value + state.getLocale() ); } const sourceBranch = getPluralKey( @@ -729,7 +724,7 @@ function renderElement( source.children, state, identityRender, - state.locale.value + state.getLocale() ), translatedProps ) @@ -757,7 +752,7 @@ function getPluralKey( branches: string[], source: SourceElement, state: GTState, - locale = state.locale.value, + locale = state.getLocale(), includeSourceLocales = false ): string | undefined { const forms = branches.filter(isAcceptedPluralForm); diff --git a/packages/vue/src/runtime/localeCookie.ts b/packages/vue/src/runtime/localeCookie.ts new file mode 100644 index 0000000000..358f6f9905 --- /dev/null +++ b/packages/vue/src/runtime/localeCookie.ts @@ -0,0 +1,54 @@ +import { + getBrowserCookieValue, + setBrowserCookieValue, +} from 'gt-i18n/internal/cookies'; + +type CreateCookieBackedLocaleOptions = { + defaultLocale: string; + locale?: string; + localeCookieName: string; +}; + +type CookieBackedLocale = { + getLocale(): string; + setLocale(locale: string): void; +}; + +/** + * Creates a locale accessor backed by the browser locale cookie. + * + * An explicit locale is authoritative for SSR and hydration. Without one, a + * browser cookie wins over the configured default. Closure values provide + * request-local SSR state and the browser's explicit/default fallback; + * browser reads always consult the current cookie first. + * + * @param options - Initial locale inputs and the cookie name to use. + * @returns A cookie-backed browser accessor with an SSR fallback. + */ +export function createCookieBackedLocale({ + defaultLocale, + locale: explicitLocale, + localeCookieName, +}: CreateCookieBackedLocaleOptions): CookieBackedLocale { + const cookieLocale = getBrowserCookieValue(localeCookieName); + const resolvedLocale = explicitLocale ?? (cookieLocale || defaultLocale); + const browserFallbackLocale = explicitLocale ?? defaultLocale; + let serverLocale = resolvedLocale; + + // Match React's hydration contract: an explicit server locale wins over a + // stale browser cookie and becomes the persisted client value. + setBrowserCookieValue(localeCookieName, resolvedLocale); + + return { + getLocale() { + if (typeof document !== 'undefined') { + return getBrowserCookieValue(localeCookieName) || browserFallbackLocale; + } + return serverLocale; + }, + setLocale(nextLocale) { + serverLocale = nextLocale; + setBrowserCookieValue(localeCookieName, nextLocale); + }, + }; +} diff --git a/packages/vue/src/runtime/state.ts b/packages/vue/src/runtime/state.ts index d22804038a..8c54a9e573 100644 --- a/packages/vue/src/runtime/state.ts +++ b/packages/vue/src/runtime/state.ts @@ -1,4 +1,5 @@ import { inject, ref, type InjectionKey } from 'vue'; +import { defaultLocaleCookieName } from 'gt-i18n/internal/cookies'; import { createDiagnosticMessage, formatDiagnosticErrorDetails, @@ -10,6 +11,7 @@ import type { GTState, TranslationCatalog, } from '../types'; +import { createCookieBackedLocale } from './localeCookie'; const gtContextKey: InjectionKey = Symbol('gt-vue'); @@ -44,9 +46,14 @@ const gtContextKey: InjectionKey = Symbol('gt-vue'); export function createGT({ defaultLocale = libraryDefaultLocale, loadTranslations, - locale: initialLocale = defaultLocale, + locale: explicitLocale, + localeCookieName = defaultLocaleCookieName, }: CreateGTOptions = {}): GTPlugin { - const locale = ref(initialLocale); + const localeAccessor = createCookieBackedLocale({ + defaultLocale, + locale: explicitLocale, + localeCookieName, + }); const revision = ref(0); const catalogs = new Map([[defaultLocale, {}]]); const pending = new Map>(); @@ -63,7 +70,7 @@ export function createGT({ .then(() => loadTranslations?.(targetLocale) ?? {}) .then((catalog) => { catalogs.set(targetLocale, catalog); - if (targetLocale === locale.value) revision.value += 1; + if (targetLocale === getLocale()) revision.value += 1; return catalog; }) .catch((error: unknown) => { @@ -87,7 +94,20 @@ export function createGT({ const setLocale = async (targetLocale: string): Promise => { const request = ++localeRequest; await load(targetLocale); - if (request === localeRequest) locale.value = targetLocale; + if (request !== localeRequest) return; + + localeAccessor.setLocale(targetLocale); + // Cookie APIs have no reactive event, so every successful setter call + // explicitly invalidates consumers, including after an external cookie + // write that Vue could not observe. + revision.value += 1; + }; + + const getLocale = (): string => { + // Cookie APIs have no reactive event. This counter invalidates Vue + // consumers after this plugin writes a successfully loaded locale. + void revision.value; + return localeAccessor.getLocale(); }; const state: GTState = { @@ -96,21 +116,21 @@ export function createGT({ // The Map intentionally stays non-reactive. This counter makes newly // loaded catalogs invalidate any render that performed a lookup. void revision.value; - return catalogs.get(locale.value) ?? {}; + return catalogs.get(getLocale()) ?? {}; }, + getLocale, loadTranslations: load, - locale, revision, setLocale, }; return { - getLocale: () => locale.value, + getLocale, install(app) { app.provide(gtContextKey, state); // Client apps may mount immediately and render source content until the // initial asynchronous catalog arrives. Its revision update rerenders. - void load(initialLocale).catch(() => undefined); + void load(getLocale()).catch(() => undefined); }, loadTranslations: load, setLocale, diff --git a/packages/vue/src/types/index.ts b/packages/vue/src/types/index.ts index 8338999dcc..ff53d9109a 100644 --- a/packages/vue/src/types/index.ts +++ b/packages/vue/src/types/index.ts @@ -61,8 +61,17 @@ export type CreateGTOptions = { defaultLocale?: string; /** Async loader called once for each uncached locale. */ loadTranslations?: LoadTranslations; - /** Initial active locale. Defaults to `defaultLocale`. */ + /** + * Server-provided or explicit initial locale. It wins over the browser + * cookie during hydration. When omitted, the cookie wins over + * `defaultLocale`. + */ locale?: string; + /** + * Browser cookie used to persist the active locale. Defaults to + * `generaltranslation.locale`. + */ + localeCookieName?: string; }; /** @@ -72,7 +81,7 @@ export type CreateGTOptions = { * to that plugin instance. */ export type GTPlugin = { - /** Returns a non-reactive snapshot of the active locale. */ + /** Returns the active locale from its cookie-backed accessor. */ getLocale(): string; /** Provides the GT state to a Vue application. Usually called by `app.use`. */ install(app: App): void; @@ -93,8 +102,8 @@ export type GTPlugin = { export type GTState = { defaultLocale: string; getCatalog(): TranslationCatalog; + getLocale(): string; loadTranslations(locale: string): Promise; - locale: Ref; revision: Ref; setLocale(locale: string): Promise; }; diff --git a/scripts/check-library-defaults.mjs b/scripts/check-library-defaults.mjs index cd852ccda7..5ca78e58c8 100644 --- a/scripts/check-library-defaults.mjs +++ b/scripts/check-library-defaults.mjs @@ -114,22 +114,22 @@ export const defaultGroups = [ }, { name: 'defaultLocaleCookieName', - declarations: ['packages/react-core/src/setup/cookieNames.ts'], + declarations: ['packages/i18n/src/utils/cookieNames.ts'], exceptions: [], }, { name: 'defaultRegionCookieName', - declarations: ['packages/react-core/src/setup/cookieNames.ts'], + declarations: ['packages/i18n/src/utils/cookieNames.ts'], exceptions: [], }, { name: 'defaultEnableI18nCookieName', - declarations: ['packages/react-core/src/setup/cookieNames.ts'], + declarations: ['packages/i18n/src/utils/cookieNames.ts'], exceptions: [], }, { name: 'defaultResetLocaleCookieName', - declarations: ['packages/react-core/src/setup/cookieNames.ts'], + declarations: ['packages/i18n/src/utils/cookieNames.ts'], exceptions: [], }, { diff --git a/scripts/check-library-defaults.test.mjs b/scripts/check-library-defaults.test.mjs index 652fb38422..9d92c5e61a 100644 --- a/scripts/check-library-defaults.test.mjs +++ b/scripts/check-library-defaults.test.mjs @@ -121,7 +121,7 @@ describe('validateRepository', () => { expect(cookieGroup).toBeDefined(); const repositoryRoot = await createRepository({ - 'packages/react-core/src/setup/cookieNames.ts': + 'packages/i18n/src/utils/cookieNames.ts': "export const defaultLocaleCookieName = 'generaltranslation.locale';\n", 'packages/consumer/src/index.ts': "export const cookieName = 'generaltranslation.locale';\n",