diff --git a/.changeset/calm-pandas-translate.md b/.changeset/calm-pandas-translate.md new file mode 100644 index 0000000000..877aff69c5 --- /dev/null +++ b/.changeset/calm-pandas-translate.md @@ -0,0 +1,9 @@ +--- +'gt-vue': minor +--- + +Add a lightweight Vue 3 runtime with catalog-backed string and rich-content +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 new file mode 100644 index 0000000000..f60c2bbf3d --- /dev/null +++ b/.changeset/tidy-vue-messages.md @@ -0,0 +1,10 @@ +--- +'gt-i18n': patch +--- + +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, 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index eba82b302a..01b245c9ff 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: 24 + 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/.size-limit.cjs b/.size-limit.cjs index 362e592a90..062f0dfe8f 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, @@ -91,6 +96,8 @@ 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'), reactCore('@generaltranslation/react-core/pure', 'pure'), @@ -103,6 +110,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/i18n/package.json b/packages/i18n/package.json index abefc3dd76..3e41266b29 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -86,6 +86,26 @@ "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", + "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 +125,12 @@ "internal": [ "./dist/internal.d.cts" ], + "internal/cookies": [ + "./dist/internal-cookies.d.cts" + ], + "internal/string": [ + "./dist/internal-string.d.cts" + ], "internal/types": [ "./dist/internal-types.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/internal-string.ts b/packages/i18n/src/internal-string.ts new file mode 100644 index 0000000000..63ca72d343 --- /dev/null +++ b/packages/i18n/src/internal-string.ts @@ -0,0 +1,4 @@ +export { decodeOptions } from './translation-functions/msg/decodeOptions'; +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/__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..abe040c6a9 --- /dev/null +++ b/packages/i18n/src/translation-functions/msg/msgString.ts @@ -0,0 +1,42 @@ +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 }; + const $_hash = hashStringMessage(message, stringOptions); + return encodeMsg(message, { + ...stringOptions, + $_hash, + $_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/__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/__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/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/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, + }); +} diff --git a/packages/i18n/tsdown.config.mts b/packages/i18n/tsdown.config.mts index ee0536ed33..4baf568b4a 100644 --- a/packages/i18n/tsdown.config.mts +++ b/packages/i18n/tsdown.config.mts @@ -4,6 +4,8 @@ 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', 'src/internal-types.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/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/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/README.md b/packages/vue/README.md new file mode 100644 index 0000000000..9d430a423b --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,175 @@ +

+ + + + General Translation + + +

+ +

+ Documentation · Report Bug +

+ +# gt-vue + +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. Its 0.x releases +> are versioned independently from the stable React framework packages. + +## 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. The +`defaultLocale` uses source text as its catalog, so `loadTranslations` is never +called for that locale. + +```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. `` 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. + +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. 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 + +`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 `` 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 the required `value` prop for every formatting value. + +```vue + + + +``` + +When the active locale is the configured default, formatting ignores explicit +`locales` and uses only that default locale. Otherwise, an explicit `locales` +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. + +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/package.json b/packages/vue/package.json new file mode 100644 index 0000000000..78e536b64f --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,69 @@ +{ + "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 <4.0.0" + }, + "dependencies": { + "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" + }, + "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__/branches.test.ts b/packages/vue/src/__tests__/branches.test.ts new file mode 100644 index 0000000000..588d706e7f --- /dev/null +++ b/packages/vue/src/__tests__/branches.test.ts @@ -0,0 +1,360 @@ +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('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', + 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 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', + 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__/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/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 new file mode 100644 index 0000000000..12e51ce791 --- /dev/null +++ b/packages/vue/src/__tests__/runtime.test.ts @@ -0,0 +1,2166 @@ +import type { JsxChildren } from 'generaltranslation/types'; +import { hashSource } from 'generaltranslation/id'; +import * as Vue from 'vue'; +import { + Fragment, + Suspense, + createCommentVNode, + createRenderer, + createSSRApp, + defineComponent, + h, + nextTick, + ref, + vShow, + 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 { + createTranslationIdentityCache, + serializeVueChildren, + translateVueChildren, +} from '../rendering/translateVueChildren'; +import { + Branch, + Currency, + DateTime, + Num, + Plural, + T, + Var, + createGT, + msg, + useGT, + useLocale, + useMessages, +} from '../index'; +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: () => [], + 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' }); + 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('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(_target, property) { + if ( + property === 'key' || + property === 'ref' || + property === 'ref_for' || + property === 'ref_key' + ) { + return undefined; + } + throw new Error('default-locale source was serialized'); + }, + }), + }); + const state = { + defaultLocale: 'en', + getCatalog: vi.fn(() => { + throw new Error('default-locale catalog was read'); + }), + getLocale: () => 'en', + } as unknown as Parameters[1]; + + const rendered = translateVueChildren([source], state, {}); + expect(Array.isArray(rendered) && rendered[0]).toMatchObject({ + type: 'span', + }); + 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' }); + const messages: string[] = msg(['First', 'Second'] as const, { + $context: 'list', + }); + const plugin = createGT({ + loadTranslations: async () => ({ + [stringHash('Literal {name}: 你好', 'example')]: + 'Littéral {name} : 你好', + [stringHash('First', 'list')]: 'Premier', + [stringHash('Second', 'list')]: 'Deuxième', + [stringHash('', 'empty')]: 'Vide', + }), + }); + 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), '|', m(empty)]); + }, + }); + const html = await renderWithPlugin(Root, plugin); + + expect(html).toContain('Littéral {name} : 你好|PremierDeuxième|Vide'); + 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('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('translates supported component props while preserving opaque slots', 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('Source link'); + const onClick = anchor?.props.onClick; + expect(onClick).toBeTypeOf('function'); + (onClick as () => void)(); + expect(onNavigate).toHaveBeenCalledTimes(1); + 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('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' }, + ], + }), + }); + 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, + c: 'Translated replacement', + }, + }), + }); + await plugin.setLocale('fr'); + const Root = defineComponent({ + components: { ScopedBranch, T }, + render: compileSfcTemplate( + '' + ), + }); + + 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 () => { + 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('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('bounds reconciliation identities to the current keyed source tree', () => { + const identityCache = createTranslationIdentityCache(); + const state = { + defaultLocale: 'en', + getCatalog: () => ({}), + getLocale: () => '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: () => ({}), + getLocale: () => '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(); + let locale = 'en'; + let target: JsxChildren = 'Unused'; + const state = { + defaultLocale: 'en', + getCatalog: () => ({ broken: target }), + getLocale: () => 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 = '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 }), + getLocale: () => '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'); + 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('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', 'c']; + await nextTick(); + 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(); + }); + + 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|'); + showKeyedSibling.value = true; + await nextTick(); + expect(textContent(mounted.root)).toBe('keyed1|'); + 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|'); + showUnrelatedSibling.value = true; + await nextTick(); + expect(textContent(mounted.root)).toBe('unrelated1|'); + 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'); + 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('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 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; + 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 = { + 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 = [ + { + 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({ + 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', + 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('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 required values and renders standalone branch components', async () => { + const plugin = createGT(); + 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: '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(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 () => { + 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({ + 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 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 missingPlugin.setLocale('fr-FR'); + const MissingRoot = defineComponent({ + setup() { + return () => + h( + T, + { _hash: 'missing' }, + { + default: () => [ + h( + Plural, + { n: 0 }, + { + one: () => 'one', + other: () => 'other', + } + ), + '|', + ...renderFormatters(), + ], + } + ); + }, + }); + + expect( + 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 () => { + 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 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: (locale) => + new Promise((resolve) => pending.set(`fr:${locale}`, resolve)), + }); + const chinese = createGT({ + 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 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(); + const locale = useLocale(); + return () => + h('p', [ + `${locale.value}|${gt(source)}|`, + h(T, null, { default: () => h('span', 'World') }), + ]); + }, + }); + + const [fr, zh] = await Promise.all([ + renderWithPlugin(Root, french), + renderWithPlugin(Root, chinese), + ]); + expect(stripFragmentMarkers(fr)).toContain( + '

fr|Bonjour|Monde

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

zh|你好|世界

' + ); + }); + + 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' }); +} + +/** 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('', ''); +} + +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) { + 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); + 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); + child.parent = undefined; + }, + 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 { + if (node.type === '#comment') return ''; + 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/__tests__/state.test.ts b/packages/vue/src/__tests__/state.test.ts new file mode 100644 index 0000000000..f3f970c762 --- /dev/null +++ b/packages/vue/src/__tests__/state.test.ts @@ -0,0 +1,419 @@ +import { + createRenderer, + 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(); + 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; + }); + 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'); + expect(cookieDocument.get('generaltranslation.locale')).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('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 () => ({}) }); + 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 }; +} + +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/__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..6a12839514 --- /dev/null +++ b/packages/vue/src/__tests__/variables.test.ts @@ -0,0 +1,232 @@ +import { libraryDefaultLocale } from 'generaltranslation/internal'; +import { createSSRApp, defineComponent, h, type Component } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +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', () => { + 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(stripFragmentMarkers(await render(Root))).toContain( + '1,234.5|$12.00|2024|2024' + ); + }); + + it('does not treat formatter slot children as values', async () => { + const slot = vi.fn(() => '999'); + const Root = defineComponent({ + setup() { + return () => + 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(stripFragmentMarkers(await render(Root))).toContain('2$3.002024'); + expect(slot).not.toHaveBeenCalled(); + }); + + it('returns partially parseable value strings unchanged', async () => { + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, { locales: ['en-US'], value: '1,234.5' }), + '|', + h(Currency, { + currency: 'USD', + locales: ['en-US'], + value: '12 dollars', + }), + ]); + }, + }); + + expect(stripFragmentMarkers(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 nullish or whitespace-only values as zero or a date', async () => { + const Root = defineComponent({ + setup() { + return () => + h('div', [ + h(Num, { value: ' ' }), + h(Currency, { value: '\n' }), + h(DateTime, { value: '\t' }), + h(Num, { value: null }), + h(Currency, { value: null }), + h(DateTime, { value: null }), + ]); + }, + }); + + 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('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', + 'fr', + 'en', + ]); + }); + + 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 { + 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 new file mode 100644 index 0000000000..42e5bb165e --- /dev/null +++ b/packages/vue/src/components/T.ts @@ -0,0 +1,65 @@ +import { defineComponent } from 'vue'; +import { + createTranslationIdentityCache, + translateVueChildren, +} from '../rendering/translateVueChildren'; +import { useGTState } from '../runtime/state'; +import { asFragmentRoot, withGTMetadata } from './utils'; + +type TProps = { + /** @internal Compile-time hash inserted by GT tooling. */ + _hash?: string; + /** @internal React-compatible alias accepted for compiler output. */ + $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(); + // Translation IDs can reorder or repeat source VNodes. Stable Symbols + // preserve component identity without colliding with user-provided keys. + const identityCache = createTranslationIdentityCache(); + return () => + asFragmentRoot( + translateVueChildren( + slots.default?.() ?? [], + state, + { + ...props, + ...(typeof attrs.$context === 'string' && { + $context: attrs.$context, + }), + }, + identityCache + ) + ); + }, + }), + 'translate-client' +); diff --git a/packages/vue/src/components/branches.ts b/packages/vue/src/components/branches.ts new file mode 100644 index 0000000000..516eec2037 --- /dev/null +++ b/packages/vue/src/components/branches.ts @@ -0,0 +1,92 @@ +import { + getPluralForm, + isAcceptedPluralForm, +} from 'generaltranslation/internal'; +import { defineComponent, type PropType } from 'vue'; +import { useGTState } from '../runtime/state'; +import { + asFragmentRoot, + getBranchContent, + getBranchNames, + getFormatLocales, + withGTMetadata, +} from './utils'; + +type PluralProps = { + /** Locale preferences tried before active and default locales in translation. */ + 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. + * + * 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 active and default locales in translation. */ + 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.getLocale(), + state.defaultLocale + ) + ); + return asFragmentRoot(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 asFragmentRoot(getBranchContent(branch, attrs, slots)); + }; + }, + }), + 'branch' +); diff --git a/packages/vue/src/components/utils.ts b/packages/vue/src/components/utils.ts new file mode 100644 index 0000000000..ed8d41c060 --- /dev/null +++ b/packages/vue/src/components/utils.ts @@ -0,0 +1,147 @@ +import { libraryDefaultLocale } from 'generaltranslation/internal'; +import { + type Component, + type DefineComponent, + type Slots, + 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 */ + _gtt: string; +}; + +/** @internal */ +export function withGTMetadata( + component: Component, + metadata: string +): GTComponent { + return Object.assign(component, { _gtt: metadata }) as GTComponent; +} + +/** + * 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, + defaultLocale: string = libraryDefaultLocale +): string[] { + if (locale === defaultLocale) return [defaultLocale]; + return [...new Set([...(locales ?? []), locale, defaultLocale])]; +} + +/** + * 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]; +} + +/** + * 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' + ); +} + +/** @internal */ +export function getBranchNames( + attrs: Record, + slots: Slots +): string[] { + return [ + ...new Set([ + ...Object.entries(attrs) + .filter(([name, value]) => isBranchAttribute(name, value)) + .map(([name]) => name), + ...Object.keys(slots).filter( + (key) => key !== 'default' && !key.startsWith('_') + ), + ]), + ]; +} + +/** @internal */ +export function getBranchContent( + branch: string | undefined, + attrs: Record, + slots: Slots +) { + if ( + branch && + Object.prototype.hasOwnProperty.call(slots, branch) && + typeof slots[branch] === 'function' + ) { + return slots[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 new file mode 100644 index 0000000000..b18438dff5 --- /dev/null +++ b/packages/vue/src/components/variables.ts @@ -0,0 +1,216 @@ +import { defineComponent, type PropType } from 'vue'; +import { useGTState } from '../runtime/state'; +import { asFragmentRoot, getFormatLocales, withGTMetadata } from './utils'; + +type NumberFormatProps = { + /** Locale preferences tried before active and default locales in translation. */ + locales?: string[]; + /** Options forwarded to `Intl.NumberFormat`. */ + options?: Intl.NumberFormatOptions; + /** Runtime value to format. */ + value: number | string | null; +}; + +type DateTimeProps = { + /** Locale preferences tried before active and default locales in translation. */ + locales?: string[]; + /** Options forwarded to `Intl.DateTimeFormat`. */ + options?: Intl.DateTimeFormatOptions; + /** Runtime value to format. */ + value: Date | number | string | null; +}; + +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({ + inheritAttrs: false, + name: 'Var', + setup(_props, { slots }) { + return () => asFragmentRoot(slots.default?.() ?? null); + }, + }), + 'variable-variable' +); + +/** + * 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`. */ + options: Object as PropType, + /** Runtime value to format. */ + value: { + required: true, + type: [Number, String, null] as unknown as PropType< + number | string | null + >, + }, + }, + setup(props) { + const state = useGTState(); + return () => { + const value = props.value; + if ( + value == null || + (typeof value === 'string' && value.trim() === '') + ) { + return asFragmentRoot(null); + } + const number = typeof value === 'number' ? value : Number(value); + const formatted = Number.isNaN(number) + ? String(value) + : new Intl.NumberFormat( + getVariableFormatLocales(props, state), + props.options + ).format(number); + return asFragmentRoot(formatted); + }; + }, + }), + 'variable-number' +); + +/** + * Formats the required `value` prop with `Intl.DateTimeFormat`. `Date` + * 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`. */ + options: Object as PropType, + /** Runtime value to format. */ + value: { + required: true, + type: [Date, Number, String, null] as unknown as PropType< + Date | number | string | null + >, + }, + }, + setup(props) { + const state = useGTState(); + return () => { + const value = props.value; + if ( + value == null || + (typeof value === 'string' && value.trim() === '') + ) { + return asFragmentRoot(null); + } + const date = value instanceof Date ? value : new Date(value); + const formatted = Number.isNaN(date.getTime()) + ? String(value) + : new Intl.DateTimeFormat( + getVariableFormatLocales(props, state), + props.options + ) + .format(date) + .replace(/[\u200F\u202B\u202E]/g, ''); + return asFragmentRoot(formatted); + }; + }, + }), + 'variable-datetime' +); + +/** + * Formats the required `value` prop as currency. `currency` defaults to + * `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', + type: String, + }, + /** 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 to format. */ + value: { + required: true, + type: [Number, String, null] as unknown as PropType< + number | string | null + >, + }, + }, + setup(props) { + const state = useGTState(); + return () => { + const value = props.value; + if ( + value == null || + (typeof value === 'string' && value.trim() === '') + ) { + return asFragmentRoot(null); + } + const number = typeof value === 'number' ? value : Number(value); + const formatted = Number.isNaN(number) + ? String(value) + : 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.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 new file mode 100644 index 0000000000..e20ee49479 --- /dev/null +++ b/packages/vue/src/composables/locale.ts @@ -0,0 +1,29 @@ +import { toRef, 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(): Readonly> { + const state = useGTState(); + return toRef(state.getLocale); +} + +/** + * 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/src/composables/strings.ts b/packages/vue/src/composables/strings.ts new file mode 100644 index 0000000000..1588e5a826 --- /dev/null +++ b/packages/vue/src/composables/strings.ts @@ -0,0 +1,61 @@ +import { + decodeOptions, + isEncodedTranslationOptions, +} from 'gt-i18n/internal/string'; +import { + 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 = decodeOptions(message); + if (decoded && isEncodedTranslationOptions(decoded)) { + 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/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 0000000000..e70a42346a --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,16 @@ +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, + GTPlugin, + GTStringOptions, + LoadTranslations, + MessagesFunction, + TranslationCatalog, +} from './types'; diff --git a/packages/vue/src/messages/msg.ts b/packages/vue/src/messages/msg.ts new file mode 100644 index 0000000000..12c273f0c4 --- /dev/null +++ b/packages/vue/src/messages/msg.ts @@ -0,0 +1,35 @@ +import { msgString as registerMessage } from 'gt-i18n/internal/string'; +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[] { + return registerMessage(message, options); +} diff --git a/packages/vue/src/messages/translation.ts b/packages/vue/src/messages/translation.ts new file mode 100644 index 0000000000..47713b913f --- /dev/null +++ b/packages/vue/src/messages/translation.ts @@ -0,0 +1,19 @@ +import { hashStringMessage } from 'gt-i18n/internal/string'; +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 = 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 new file mode 100644 index 0000000000..2692e70e5c --- /dev/null +++ b/packages/vue/src/rendering/translateVueChildren.ts @@ -0,0 +1,988 @@ +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, + Suspense, + Text, + cloneVNode, + h, + isVNode, + mergeProps, + type Component, + type Slots, + type VNode, + type VNodeChild, +} from 'vue'; +import { getFormatLocales, isBranchAttribute } from '../components/utils'; +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; + identity: string; + opaque: boolean; + preserveExplicitKey: boolean; + transformation: Transformation; + variableName?: string; + variableType?: VariableType; + vnode: VNode; +}; + +type SourceNode = SourceElement | string; + +type ComponentWithGTMetadata = Component & { + _gtt?: string; + __name?: string; + name?: string; +}; + +type RichTranslationOptions = { + /** @internal React-compatible alias accepted for compiler output. */ + $context?: string; + _hash?: string; + context?: string; +}; + +/** 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; +}; + +/** 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; + ssContent?: VNode; + ssFallback?: VNode; +}; + +export function translateVueChildren( + children: VNode[], + state: GTState, + options: RichTranslationOptions, + identityCache: TranslationIdentityCache = createTranslationIdentityCache() +): VNodeChild { + const identityRender = createTranslationIdentityRender(identityCache); + let rendered: VNodeChild; + try { + const source = createSourceNodes(children, identityRender); + if (state.getLocale() === 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; + } + sweepTranslationIdentityCache(identityRender); + return rendered; +} + +/** 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(), + }; +} + +/** 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. + * + * 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 { + const identityCache = createTranslationIdentityCache(); + return serializeNodes( + createSourceNodes(children, createTranslationIdentityRender(identityCache)) + ); +} + +function createSourceNodes( + children: unknown, + identityRender: TranslationIdentityRender +): SourceNode[] { + const index = { value: 0 }; + return visitChildren(children, index, 'root', identityRender); +} + +function visitChildren( + children: unknown, + index: { value: number }, + identityScope: string, + identityRender: TranslationIdentityRender, + identityOccurrences: Map = new Map(), + transparentKeyScope = false +): SourceNode[] { + if (Array.isArray(children)) { + return mergeAdjacentStrings( + children.flatMap((child) => + visitChildren( + child, + index, + identityScope, + identityRender, + identityOccurrences, + transparentKeyScope + ) + ) + ); + } + 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) { + const fragmentChildren = isSlots(children.children) + ? children.children.default?.() + : children.children; + if (children.key != null) { + return visitChildren( + fragmentChildren, + index, + getExplicitIdentityScope(identityScope, children.key, identityRender), + identityRender, + new Map(), + true + ); + } + return visitChildren( + fragmentChildren, + index, + identityScope, + identityRender, + identityOccurrences, + transparentKeyScope + ); + } + + index.value += 1; + const id = index.value; + let identity: string; + if (children.key == null) { + const typeScope = getVNodeTypeScope(children.type, identityRender); + const occurrence = (identityOccurrences.get(typeScope) ?? 0) + 1; + identityOccurrences.set(typeScope, occurrence); + identity = `${identityScope}/${typeScope}/o:${occurrence}`; + } else { + identity = getExplicitIdentityScope( + identityScope, + children.key, + identityRender + ); + } + 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, transformation); + const source: SourceElement = { + branches: {}, + children: + variable || defaultSlot.opaque + ? [] + : visitChildren( + defaultSlot.children, + index, + transformation === 'branch' || transformation === 'plural' + ? `${identity}/default` + : identity, + identityRender + ), + id, + identity, + opaque: defaultSlot.opaque, + preserveExplicitKey: !transparentKeyScope, + transformation, + variableName: variable?.name, + variableType: variable?.type, + vnode: children, + }; + + if (transformation === 'branch' || transformation === 'plural') { + source.branches = getBranches( + children, + transformation, + id, + identity, + identityRender + ); + } + return [source]; +} + +/** Gives each distinct Vue VNode type a stable per-T identity token. */ +function getVNodeTypeScope( + type: unknown, + identityRender: TranslationIdentityRender +): string { + const { cache } = identityRender; + identityRender.typeScopes.add(type); + let scope = cache.typeScopes.get(type); + if (!scope) { + cache.nextTypeScope += 1; + scope = `t:${cache.nextTypeScope}`; + cache.typeScopes.set(type, scope); + (identityRender.createdTypeScopes ??= []).push(type); + } + return scope; +} + +/** Anchors descendant identity to an explicit Vue key without string coercion. */ +function getExplicitIdentityScope( + parentScope: string, + key: PropertyKey, + identityRender: TranslationIdentityRender +): string { + const { cache } = identityRender; + identityRender.explicitScopes.add(key); + let scope = cache.explicitScopes.get(key); + if (!scope) { + cache.nextExplicitScope += 1; + scope = `k:${cache.nextExplicitScope}`; + cache.explicitScopes.set(key, scope); + (identityRender.createdExplicitScopes ??= []).push(key); + } + return `${parentScope}/${scope}`; +} + +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, + }; +} + +/** + * Reads source-owned content without speculatively invoking user components. + * + * 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, + 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 { children: vnode.children.default?.(), opaque: false }; +} + +function getBranches( + vnode: VNode, + transformation: 'branch' | 'plural', + branchElementId: number, + identity: string, + identityRender: TranslationIdentityRender +): Record { + const inputs = Object.create(null) as 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 ( + isBranchAttribute(key, value) && + !Object.prototype.hasOwnProperty.call(inputs, key) + ) { + 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 }, + `${identity}/branch:${key.length}:${key}`, + identityRender + ), + ]) + ); +} + +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, node.id), + i: node.id, + ...(Object.keys(data).length && { d: data }), + ...(node.children.length && { c: serializeNodes(node.children) }), + }; +} + +/** + * 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 || fallback; + if (typeof vnode.type === 'object') { + const type = vnode.type as ComponentWithGTMetadata; + return type.name || type.__name || fallback; + } + return fallback; +} + +function renderNodes( + source: SourceNode[], + target: JsxChildren | undefined, + state: GTState, + 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, identityRender, state.getLocale()); + } + 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' + ); + 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 + ? keySourceResult( + variable, + renderDefaultNode( + variable, + state, + identityRender, + state.getLocale() + ), + occurrences, + identityRender + ) + : null; + } + + // 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 = + (targetNode.i == null ? undefined : ordinaryById.get(targetNode.i)) ?? + fallback.shift(); + return sourceNode + ? keySourceResult( + sourceNode, + renderElement(sourceNode, targetNode, state, identityRender), + occurrences, + identityRender + ) + : 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, + identityRender: TranslationIdentityRender +): VNode { + const occurrence = occurrences.get(source) ?? 0; + occurrences.set(source, occurrence + 1); + + const explicitKey = + occurrence === 0 && source.preserveExplicitKey ? source.vnode.key : null; + const cacheKey = `${source.identity}/occurrence:${occurrence}`; + let key: PropertyKey; + if (explicitKey != null) { + key = explicitKey; + } else { + const { cache } = identityRender; + identityRender.generatedKeys.add(cacheKey); + let generatedKey = cache.generatedKeys.get(cacheKey); + if (!generatedKey) { + generatedKey = Symbol(cacheKey); + cache.generatedKeys.set(cacheKey, generatedKey); + (identityRender.createdGeneratedKeys ??= []).push(cacheKey); + } + key = generatedKey; + } + + if (isVNode(rendered)) { + if (rendered.key === key) return 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, + identityRender: TranslationIdentityRender +): VNodeChild { + if (source.transformation === 'branch') { + const branch = getBranchKey(source.vnode); + return renderNodes( + getSelectedSourceBranch(source, branch), + getSelectedTargetBranch(target, branch), + state, + identityRender + ); + } + if (source.transformation === 'plural') { + const n = source.vnode.props?.n; + if (typeof n !== 'number') { + return renderDefaultNode( + source, + state, + identityRender, + state.getLocale() + ); + } + const sourceBranch = getPluralKey( + n, + Object.keys(source.branches), + source, + state, + state.defaultLocale, + true + ); + 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, + identityRender + ); + } + if (source.transformation === 'fragment') { + return renderNodes(source.children, target.c, state, identityRender); + } + 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 source.children.length + ? cloneWithChildren( + source.vnode, + renderDefaultNodes( + source.children, + state, + identityRender, + state.getLocale() + ), + translatedProps + ) + : Object.keys(translatedProps).length + ? cloneWithProps(source.vnode, translatedProps) + : source.vnode; + } + + return cloneWithChildren( + source.vnode, + renderNodes(source.children, target.c, state, identityRender), + translatedProps + ); +} + +function getBranchKey(source: VNode): string | undefined { + const branch = source.props?.branch; + if (branch == null) return undefined; + const key = String(branch); + return key || undefined; +} + +function getPluralKey( + n: number, + branches: string[], + source: SourceElement, + state: GTState, + locale = state.getLocale(), + includeSourceLocales = false +): string | undefined { + const forms = branches.filter(isAcceptedPluralForm); + if (!forms.length) return undefined; + 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, + getFormatLocales(sourceLocales, locale, state.defaultLocale) + ) || undefined + ); +} + +function getSelectedSourceBranch( + source: SourceElement, + branch?: string +): SourceNode[] { + return branch && Object.hasOwn(source.branches, branch) + ? source.branches[branch] + : source.children; +} + +function getSelectedTargetBranch( + target: JsxElement, + branch?: string +): JsxChildren | 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)) { + const value = target.d?.[shortName as keyof HtmlContentPropKeysRecord]; + if (typeof value === 'string') result[propName] = value; + } + return result; +} + +function renderDefaultNodes( + nodes: SourceNode[], + state: GTState, + identityRender: TranslationIdentityRender, + locale: string +): VNodeChild[] { + const occurrences = new Map(); + return nodes.map((node) => + typeof node === 'string' + ? node + : keySourceResult( + node, + renderDefaultNode(node, state, identityRender, locale), + occurrences, + identityRender + ) + ); +} + +function renderDefaultNode( + node: SourceNode, + state: GTState, + identityRender: TranslationIdentityRender, + locale: string +): VNodeChild { + if (typeof node === 'string') return node; + if (node.transformation === 'variable') { + return node.variableType !== 'v' + ? cloneWithProps(node.vnode, { _locale: locale }) + : node.vnode; + } + if (node.transformation === 'fragment') { + return renderDefaultNodes(node.children, state, identityRender, locale); + } + if (node.transformation === 'branch') { + return renderDefaultNodes( + getSelectedSourceBranch(node, getBranchKey(node.vnode)), + state, + identityRender, + locale + ); + } + if (node.transformation === 'plural') { + const n = node.vnode.props?.n; + if (typeof n !== 'number') { + return renderDefaultNodes(node.children, state, identityRender, locale); + } + const branch = getPluralKey( + n, + Object.keys(node.branches), + node, + state, + locale, + true + ); + return renderDefaultNodes( + getSelectedSourceBranch(node, branch), + state, + identityRender, + locale + ); + } + if (!node.children.length) return node.vnode; + return cloneWithChildren( + node.vnode, + renderDefaultNodes(node.children, state, identityRender, locale) + ); +} + +function cloneWithChildren( + vnode: VNode, + children: VNodeChild, + extraProps: Record = {} +): VNode { + 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); + + // 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. + 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 normalizedContent = (vnode as VNodeWithRenderMetadata).ssContent; + const normalizedFallback = (vnode as VNodeWithRenderMetadata).ssFallback; + const cloned = h(vnode.type, props, { + ...slots, + 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. + ...(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 + // 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, 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 : {}; + return h(type, props, { + ...slots, + default: () => children, + }); +} + +/** + * Rebuilds translated Suspense content with its normalized source root shape. + * + * 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 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. */ +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 +): VNode { + return cloneVNode(vnode, extraProps); +} + +function isVariable(value: JsxElement | Variable): value is Variable { + return 'k' in value && typeof value.k === 'string'; +} 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 new file mode 100644 index 0000000000..8c54a9e573 --- /dev/null +++ b/packages/vue/src/runtime/state.ts @@ -0,0 +1,153 @@ +import { inject, ref, type InjectionKey } from 'vue'; +import { defaultLocaleCookieName } from 'gt-i18n/internal/cookies'; +import { + createDiagnosticMessage, + formatDiagnosticErrorDetails, + libraryDefaultLocale, +} from 'generaltranslation/internal'; +import type { + CreateGTOptions, + GTPlugin, + GTState, + TranslationCatalog, +} from '../types'; +import { createCookieBackedLocale } from './localeCookie'; + +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, + locale: explicitLocale, + localeCookieName = defaultLocaleCookieName, +}: CreateGTOptions = {}): GTPlugin { + const localeAccessor = createCookieBackedLocale({ + defaultLocale, + locale: explicitLocale, + localeCookieName, + }); + 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); + if (targetLocale === getLocale()) 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); + return promise; + }; + + const setLocale = async (targetLocale: string): Promise => { + const request = ++localeRequest; + await load(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 = { + 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(getLocale()) ?? {}; + }, + getLocale, + loadTranslations: load, + revision, + setLocale, + }; + + return { + 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(getLocale()).catch(() => undefined); + }, + loadTranslations: load, + setLocale, + }; +} + +/** @internal Returns the GT state provided to the current Vue component. */ +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/types/index.ts b/packages/vue/src/types/index.ts new file mode 100644 index 0000000000..ff53d9109a --- /dev/null +++ b/packages/vue/src/types/index.ts @@ -0,0 +1,109 @@ +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. + * 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; + /** + * 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; +}; + +/** + * 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 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; + /** + * 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. Superseded calls + * still fulfill after their catalog loads, without changing the locale. + */ + setLocale(locale: string): Promise; +}; + +/** @internal Reactive state scoped to one installed plugin instance. */ +export type GTState = { + defaultLocale: string; + getCatalog(): TranslationCatalog; + getLocale(): string; + loadTranslations(locale: string): Promise; + revision: Ref; + setLocale(locale: string): Promise; +}; diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 0000000000..78336efcfe --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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" + }, + { + "path": "../i18n" + } + ] +} diff --git a/packages/vue/tsdown.config.mts b/packages/vue/tsdown.config.mts new file mode 100644 index 0000000000..40f814f0d3 --- /dev/null +++ b/packages/vue/tsdown.config.mts @@ -0,0 +1,29 @@ +import { defineConfig } from 'tsdown'; +import { createTsdownConfig } from '../../tsdown.preset.mts'; + +const deps = { + neverBundle: [ + /^vue$/, + /^vue\//, + /^generaltranslation$/, + /^generaltranslation\//, + /^gt-i18n$/, + /^gt-i18n\//, + ], +}; + +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, + jsdoc: false, + legal: true, + }, + }, +})); + +export default defineConfig(configs); 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..2b6c355af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1634,6 +1634,31 @@ 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 + gt-i18n: + specifier: workspace:* + version: link:../i18n + 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 +2643,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 +2659,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 +2693,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 +3342,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 +9811,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 +19288,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 +20290,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 +20332,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 +21125,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 @@ -28726,6 +28816,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 @@ -40915,6 +41059,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 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/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", 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" + } +]