From 3e83e57a5f44c46949037e5e676764b0601fa98e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:13:05 +0000 Subject: [PATCH 01/10] feat: add i18n.fallbacks config and shared locale-fallbacks resolver Adds an `i18n.fallbacks` config option for declaring locale fallback chains for translations (e.g. en-CA -> en-GB -> en), along with a shared resolveLocaleFallbacks() helper (server + browser safe) and unit tests. Also surfaces i18n.defaultLocale/fallbacks and the v2TranslationsManager experiment flag on the CMS UI's window.__ROOT_CTX types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pw4Wrj1oL9DuVbKoXasBTE --- .../root-cms/shared/locale-fallbacks.test.ts | 101 ++++++++++++++++++ packages/root-cms/shared/locale-fallbacks.ts | 71 ++++++++++++ packages/root-cms/ui/ui.tsx | 3 + packages/root/src/core/config.ts | 18 ++++ 4 files changed, 193 insertions(+) create mode 100644 packages/root-cms/shared/locale-fallbacks.test.ts create mode 100644 packages/root-cms/shared/locale-fallbacks.ts diff --git a/packages/root-cms/shared/locale-fallbacks.test.ts b/packages/root-cms/shared/locale-fallbacks.test.ts new file mode 100644 index 000000000..76882cca0 --- /dev/null +++ b/packages/root-cms/shared/locale-fallbacks.test.ts @@ -0,0 +1,101 @@ +import {describe, expect, it} from 'vitest'; +import {resolveLocaleFallbacks} from './locale-fallbacks.js'; + +describe('resolveLocaleFallbacks', () => { + it('returns the locale followed by the default locale', () => { + expect(resolveLocaleFallbacks({}, 'fr')).toEqual(['fr', 'en']); + expect(resolveLocaleFallbacks(undefined, 'fr')).toEqual(['fr', 'en']); + }); + + it('uses the configured defaultLocale', () => { + expect(resolveLocaleFallbacks({defaultLocale: 'de'}, 'fr')).toEqual([ + 'fr', + 'de', + ]); + }); + + it('does not duplicate the default locale', () => { + expect(resolveLocaleFallbacks({}, 'en')).toEqual(['en']); + expect(resolveLocaleFallbacks({defaultLocale: 'de'}, 'de')).toEqual(['de']); + }); + + it('follows configured fallback chains', () => { + const i18n = {fallbacks: {'en-CA': ['en-GB']}}; + expect(resolveLocaleFallbacks(i18n, 'en-CA')).toEqual([ + 'en-CA', + 'en-GB', + 'en', + ]); + }); + + it('resolves fallbacks recursively (breadth first)', () => { + const i18n = { + fallbacks: { + 'fr-ca': ['fr-fr', 'en-CA'], + 'fr-fr': ['fr'], + 'en-CA': ['en-GB'], + }, + }; + expect(resolveLocaleFallbacks(i18n, 'fr-ca')).toEqual([ + 'fr-ca', + 'fr-fr', + 'en-CA', + 'fr', + 'en-GB', + 'en', + ]); + }); + + it('matches fallback keys case-insensitively', () => { + const i18n = {fallbacks: {'EN-ca': ['en-GB']}}; + expect(resolveLocaleFallbacks(i18n, 'en-CA')).toEqual([ + 'en-CA', + 'en-GB', + 'en', + ]); + }); + + it('preserves the configured casing of fallback values', () => { + const i18n = {fallbacks: {'en-ca': ['EN-gb']}}; + expect(resolveLocaleFallbacks(i18n, 'en-CA')).toEqual([ + 'en-CA', + 'EN-gb', + 'en', + ]); + }); + + it('protects against cycles', () => { + const i18n = { + fallbacks: { + a: ['b'], + b: ['c'], + c: ['a'], + }, + }; + expect(resolveLocaleFallbacks(i18n, 'a')).toEqual(['a', 'b', 'c', 'en']); + }); + + it('protects against self-referencing fallbacks', () => { + const i18n = {fallbacks: {'en-ca': ['en-CA', 'en-GB']}}; + expect(resolveLocaleFallbacks(i18n, 'en-CA')).toEqual([ + 'en-CA', + 'en-GB', + 'en', + ]); + }); + + it('dedupes shared fallbacks across chains', () => { + const i18n = { + fallbacks: { + 'es-mx': ['es-419', 'es'], + 'es-419': ['es'], + }, + }; + expect(resolveLocaleFallbacks(i18n, 'es-mx')).toEqual([ + 'es-mx', + 'es-419', + 'es', + 'en', + ]); + }); +}); diff --git a/packages/root-cms/shared/locale-fallbacks.ts b/packages/root-cms/shared/locale-fallbacks.ts new file mode 100644 index 000000000..c37672839 --- /dev/null +++ b/packages/root-cms/shared/locale-fallbacks.ts @@ -0,0 +1,71 @@ +/** + * Utilities for resolving the locale fallback chain used by translations. + * + * The `i18n.fallbacks` config in root.config.ts maps a locale to an ordered + * list of fallback locales. When a translation is missing for a locale, each + * fallback locale is checked (in order) before falling back to + * `i18n.defaultLocale` and finally the source string. For example: + * + * ```ts + * i18n: { + * locales: ['en', 'en-GB', 'en-CA'], + * fallbacks: { + * 'en-CA': ['en-GB'], + * }, + * } + * ``` + * + * With the config above, `resolveLocaleFallbacks(i18n, 'en-CA')` returns + * `['en-CA', 'en-GB', 'en']`. Fallbacks are resolved recursively (breadth + * first), and all matching is case-insensitive. + */ + +export interface LocaleFallbacksI18nConfig { + locales?: string[]; + defaultLocale?: string; + fallbacks?: Record; +} + +/** + * Resolves the ordered locale fallback chain for a locale, starting with the + * locale itself and ending with the default locale. Fallback chains are + * followed recursively (breadth first) with cycle protection, and locale keys + * are matched case-insensitively while preserving the configured casing of + * each fallback value. + */ +export function resolveLocaleFallbacks( + i18nConfig: LocaleFallbacksI18nConfig | undefined, + locale: string +): string[] { + const fallbacks = i18nConfig?.fallbacks || {}; + // Normalize the fallback keys for case-insensitive lookups. + const fallbacksByLowerKey: Record = {}; + for (const [key, values] of Object.entries(fallbacks)) { + fallbacksByLowerKey[key.toLowerCase()] = values || []; + } + + const chain: string[] = []; + const visited = new Set(); + const queue: string[] = [locale]; + while (queue.length > 0) { + const current = queue.shift()!; + const lower = String(current).toLowerCase(); + if (visited.has(lower)) { + continue; + } + visited.add(lower); + chain.push(current); + for (const fallback of fallbacksByLowerKey[lower] || []) { + if (!visited.has(String(fallback).toLowerCase())) { + queue.push(fallback); + } + } + } + + // Always fall back to the default locale last. + const defaultLocale = i18nConfig?.defaultLocale || 'en'; + if (!visited.has(defaultLocale.toLowerCase())) { + chain.push(defaultLocale); + } + return chain; +} diff --git a/packages/root-cms/ui/ui.tsx b/packages/root-cms/ui/ui.tsx index 3e6637d43..ddc0a7e61 100644 --- a/packages/root-cms/ui/ui.tsx +++ b/packages/root-cms/ui/ui.tsx @@ -168,9 +168,11 @@ declare global { gci: string | boolean; i18n: { locales?: string[]; + defaultLocale?: string; urlFormat?: string; groups?: Record; translationLanguages?: Record; + fallbacks?: Record; }; server: { trailingSlash?: boolean; @@ -198,6 +200,7 @@ declare global { experiments?: { ai?: boolean | {endpoint?: string}; taskManager?: boolean; + v2TranslationsManager?: boolean; }; ai?: { defaultModel?: string; diff --git a/packages/root/src/core/config.ts b/packages/root/src/core/config.ts index 71d9105b5..ee23c8e17 100644 --- a/packages/root/src/core/config.ts +++ b/packages/root/src/core/config.ts @@ -222,6 +222,24 @@ export interface RootI18nConfig { * language. */ translationLanguages?: Record; + + /** + * Locale fallback chains for translations. When a translation is missing + * for a locale, each fallback locale is checked (in order) before falling + * back to `defaultLocale` and finally the source string. Fallbacks are + * resolved recursively, e.g. with the config below, `en-CA` resolves to + * `['en-CA', 'en-GB', 'en']`. For example: + * + * ``` + * i18n: { + * locales: ['en', 'en-GB', 'en-CA'], + * fallbacks: { + * 'en-CA': ['en-GB'], + * }, + * } + * ``` + */ + fallbacks?: Record; } export interface RootBuildConfig { From 4bf996401d6a8175755ae93615bbad40ac710ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:24:16 +0000 Subject: [PATCH 02/10] feat: fix v2 TranslationsManager core and integrate publishing - Fix the inverted experiments.v2TranslationsManager check in getTranslationsManager() and add isV2TranslationsEnabled(). - TranslationsManager.loadTranslations(): use array-contains-any for tag filters, chunk id `in` queries (10/chunk, parallel), and merge results in ids order for deterministic precedence (doc-specific last/wins). - saveTranslations(): add {linkedSheet, modifiedBy} options, chunk batch commits at 400 ops, and remove the misleading inner update loop. - Add addPublishTranslationsOps(), publishTranslationsBulk(), getTranslationsLocaleDocs() and a public translationsForLocaleV2() fallback-chain resolver; loadTranslationsForLocale() now defaults its fallback chain from i18n.fallbacks. - publishDocs()/publishScheduledDocs() (and releases via publishDocs) publish each doc's draft translations in the same write batch as the doc; unpublishDocs() deletes the published locale docs and clears the draft docs' published metadata. All flag-gated. - Rewrite BatchRequest/BatchResponse for the per-locale schema: fix the translations double-fetch, auto-collect translations ids from query results when translate is enabled, fetch exact locale-doc refs expanded through i18n.fallbacks (chunked getAll) with an id-query fallback when locales are unknown, and resolve fallback chains in getTranslations(). - Remove the orphaned incompatible TranslationsDoc schema and the unused dbTranslationsPath()/dbTranslationsRef() helpers. - Add Firestore-emulator tests for the manager, publish integration, and BatchRequest translation resolution. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pw4Wrj1oL9DuVbKoXasBTE --- packages/root-cms/core/batch-request.test.ts | 190 +++++++ packages/root-cms/core/client.ts | 384 +++++++++---- .../core/publish-translations.test.ts | 196 +++++++ .../core/translations-manager.test.ts | 377 +++++++++++++ .../root-cms/core/translations-manager.ts | 507 +++++++++++++----- 5 files changed, 1404 insertions(+), 250 deletions(-) create mode 100644 packages/root-cms/core/batch-request.test.ts create mode 100644 packages/root-cms/core/publish-translations.test.ts create mode 100644 packages/root-cms/core/translations-manager.test.ts diff --git a/packages/root-cms/core/batch-request.test.ts b/packages/root-cms/core/batch-request.test.ts new file mode 100644 index 000000000..451a334fb --- /dev/null +++ b/packages/root-cms/core/batch-request.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for BatchRequest/BatchResponse translation resolution against the + * Firestore emulator (see translations-manager.test.ts for details on the + * emulator setup). + */ + +import {getApps, initializeApp} from 'firebase-admin/app'; +import {Timestamp, getFirestore} from 'firebase-admin/firestore'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {RootCMSClient} from './client.js'; + +const FIREBASE_PROJECT_ID = 'rootjs-cms'; + +function getTestApp() { + const existing = getApps().find((app) => app.name === 'batch-test'); + if (existing) { + return existing; + } + return initializeApp({projectId: FIREBASE_PROJECT_ID}, 'batch-test'); +} + +let projectCounter = 0; + +function createTestCmsClient(options?: {i18n?: any}): RootCMSClient { + const app = getTestApp(); + const db = getFirestore(app); + const projectId = `batch-test-${Date.now()}-${projectCounter++}`; + const plugin = { + name: 'root-cms', + getConfig: () => ({ + id: projectId, + firebaseConfig: { + apiKey: 'test', + authDomain: 'test', + projectId: FIREBASE_PROJECT_ID, + storageBucket: 'test', + }, + experiments: {v2TranslationsManager: true}, + }), + getFirebaseApp: () => app, + getFirestore: () => db, + }; + const rootConfig: any = { + rootDir: '/test', + i18n: options?.i18n ?? { + locales: ['en', 'en-GB', 'en-CA', 'es'], + fallbacks: {'en-CA': ['en-GB']}, + }, + plugins: [plugin], + }; + return new RootCMSClient(rootConfig); +} + +async function seedDraftDoc(cmsClient: RootCMSClient, docId: string) { + const [collection, slug] = docId.split('/'); + await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/Collections/${collection}/Drafts/${slug}` + ) + .set({ + id: docId, + collection, + slug, + sys: { + createdAt: Timestamp.now(), + createdBy: 'test', + modifiedAt: Timestamp.now(), + modifiedBy: 'test', + locales: ['en'], + }, + fields: {title: `Title for ${docId}`}, + }); +} + +describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)('BatchRequest', () => { + let cmsClient: RootCMSClient; + + beforeEach(() => { + cmsClient = createTestCmsClient(); + }); + + it('fetches explicitly-added translations without translate', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', { + hello: {es: 'hola', 'en-GB': 'hello (GB)'}, + }); + + const req = cmsClient.createBatchRequest({mode: 'draft'}); + req.addTranslations('common'); + const res = await req.fetch(); + + expect(Object.keys(res.translations)).toEqual(['common']); + expect(res.getTranslations('es')).toEqual({hello: 'hola'}); + }); + + it('resolves locale fallbacks in getTranslations()', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', { + color: {'en-GB': 'colour'}, + untranslated: {es: 'es only'}, + }); + + const req = cmsClient.createBatchRequest({mode: 'draft'}); + req.addTranslations('common'); + const res = await req.fetch(); + + // en-CA falls back to en-GB (per i18n.fallbacks). + expect(res.getTranslations('en-CA')).toEqual({ + color: 'colour', + untranslated: 'untranslated', + }); + // An explicit fallback chain can also be provided. + expect(res.getTranslations(['en-CA', 'en-GB'])).toEqual({ + color: 'colour', + untranslated: 'untranslated', + }); + expect(res.getTranslations('es')).toEqual({ + color: 'color', + untranslated: 'es only', + }); + }); + + it('auto-collects translations for docs when translate is enabled', async () => { + await seedDraftDoc(cmsClient, 'Pages/index'); + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('Pages/index', {hello: {es: 'hola (doc)'}}); + await tm.saveTranslations('common', { + hello: {es: 'hola (common)'}, + bye: {es: 'adios'}, + }); + + const req = cmsClient.createBatchRequest({mode: 'draft', translate: true}); + req.addDoc('Pages/index'); + req.addTranslations('common'); + const res = await req.fetch(); + + expect(res.docs['Pages/index']).toBeDefined(); + // Generic translations come first, doc-specific translations last. + expect(Object.keys(res.translations)).toEqual(['common', 'Pages/index']); + // Doc-specific translations take precedence. + expect(res.getTranslations('es')).toEqual({ + hello: 'hola (doc)', + bye: 'adios', + }); + }); + + it('auto-collects translations for query results when translate is enabled', async () => { + await seedDraftDoc(cmsClient, 'BlogPosts/foo'); + await seedDraftDoc(cmsClient, 'BlogPosts/bar'); + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('BlogPosts/foo', {'foo title': {es: 'foo es'}}); + await tm.saveTranslations('BlogPosts/bar', {'bar title': {es: 'bar es'}}); + + const req = cmsClient.createBatchRequest({mode: 'draft', translate: true}); + req.addQuery('blogPosts', 'BlogPosts'); + const res = await req.fetch(); + + expect(res.queries.blogPosts).toHaveLength(2); + expect(res.getTranslations('es')).toEqual({ + 'foo title': 'foo es', + 'bar title': 'bar es', + }); + }); + + it('falls back to id queries when no locales are known', async () => { + const client = createTestCmsClient({i18n: {}}); + const tm = client.getTranslationsManager(); + await tm.saveTranslations('common', {hello: {es: 'hola', fr: 'bonjour'}}); + + const req = client.createBatchRequest({mode: 'draft'}); + req.addTranslations('common'); + const res = await req.fetch(); + + expect(Object.keys(res.translations.common).sort()).toEqual(['es', 'fr']); + expect(res.getTranslations(['fr'])).toEqual({hello: 'bonjour'}); + }); + + it('limits fetched locales via options.locales', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', {hello: {es: 'hola', 'en-GB': 'hi'}}); + + const req = cmsClient.createBatchRequest({mode: 'draft', locales: ['es']}); + req.addTranslations('common'); + const res = await req.fetch(); + + // Only the `es` fallback chain (es -> en) is fetched. + expect(Object.keys(res.translations.common)).toEqual(['es']); + expect(res.getTranslations('es')).toEqual({hello: 'hola'}); + }); +}); diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 41ef5d27c..ac0d8f3e2 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -8,11 +8,19 @@ import { Timestamp, WriteBatch, } from 'firebase-admin/firestore'; +import {resolveLocaleFallbacks} from '../shared/locale-fallbacks.js'; import {normalizeSlug} from '../shared/slug.js'; import {isCronDue} from './cron-schedule.js'; import {CMSPlugin} from './plugin.js'; import {Collection} from './schema.js'; -import {TranslationsManager} from './translations-manager.js'; +import { + TranslationsLocaleDoc, + TranslationsLocaleDocWithRef, + TranslationsManager, + buildTranslationsDbPath, + buildTranslationsLocaleDocDbPath, + chunkArray, +} from './translations-manager.js'; import {validateFields} from './validation.js'; import {setValueAtPath} from './values.js'; @@ -684,10 +692,24 @@ export class RootCMSClient { } } + // If the v2 translations manager is enabled, prefetch the draft + // translations locale docs for each doc so that the doc's translations + // are published in the same batch as the doc itself. + let tm: TranslationsManager | null = null; + let translationsByDocId: Record = + {}; + if (this.isV2TranslationsEnabled()) { + tm = this.getTranslationsManager(); + translationsByDocId = await tm.getTranslationsLocaleDocs( + docs.map((doc) => doc.id), + 'draft' + ); + } + // // Each transaction or batch can write a max of 500 ops. // // https://firebase.google.com/docs/firestore/manage-data/transactions let batchCount = 0; - const batch = options?.batch || this.db.batch(); + let batch = options?.batch || this.db.batch(); const versionTags = ['published']; if (options?.releaseId) { versionTags.push(`release:${options.releaseId}`); @@ -765,10 +787,20 @@ export class RootCMSClient { }); batchCount += 1; + // Publish the doc's translations within the same batch so the doc and + // its translations go live atomically. + if (tm) { + const localeDocs = translationsByDocId[id] || []; + batchCount += tm.addPublishTranslationsOps(localeDocs, batch, { + publishedBy, + }); + } + publishedDocs.push(doc); if (batchCount >= 400) { await batch.commit(); + batch = this.db.batch(); batchCount = 0; } } @@ -813,8 +845,29 @@ export class RootCMSClient { return []; } + // If the v2 translations manager is enabled, unpublish each doc's + // translations along with the doc. + let draftTranslationsByDocId: Record< + string, + TranslationsLocaleDocWithRef[] + > = {}; + let publishedTranslationsByDocId: Record< + string, + TranslationsLocaleDocWithRef[] + > = {}; + const v2TranslationsEnabled = this.isV2TranslationsEnabled(); + if (v2TranslationsEnabled) { + const tm = this.getTranslationsManager(); + const docIdsToUnpublish = docs.map((doc) => doc.id); + [draftTranslationsByDocId, publishedTranslationsByDocId] = + await Promise.all([ + tm.getTranslationsLocaleDocs(docIdsToUnpublish, 'draft'), + tm.getTranslationsLocaleDocs(docIdsToUnpublish, 'published'), + ]); + } + let batchCount = 0; - const batch = options?.batch || this.db.batch(); + let batch = options?.batch || this.db.batch(); const unpublishedDocs: any[] = []; for (const doc of docs) { @@ -848,10 +901,27 @@ export class RootCMSClient { batch.delete(publishedRef); batchCount += 1; + // Unpublish the doc's translations: delete the published locale docs + // and clear the published metadata from the draft locale docs. + if (v2TranslationsEnabled) { + for (const localeDoc of publishedTranslationsByDocId[doc.id] || []) { + batch.delete(localeDoc.ref); + batchCount += 1; + } + for (const localeDoc of draftTranslationsByDocId[doc.id] || []) { + batch.update(localeDoc.ref, { + 'sys.publishedAt': FieldValue.delete(), + 'sys.publishedBy': FieldValue.delete(), + }); + batchCount += 1; + } + } + unpublishedDocs.push(doc); if (batchCount >= 400) { await batch.commit(); + batch = this.db.batch(); batchCount = 0; } } @@ -905,10 +975,24 @@ export class RootCMSClient { return []; } + // If the v2 translations manager is enabled, prefetch the draft + // translations locale docs for each doc so that the doc's translations + // are published in the same batch as the doc itself. + let tm: TranslationsManager | null = null; + let translationsByDocId: Record = + {}; + if (this.isV2TranslationsEnabled()) { + tm = this.getTranslationsManager(); + translationsByDocId = await tm.getTranslationsLocaleDocs( + docs.map((doc) => doc.id), + 'draft' + ); + } + // Each transaction or batch can write a max of 500 ops. // https://firebase.google.com/docs/firestore/manage-data/transactions let batchCount = 0; - const batch = this.db.batch(); + let batch = this.db.batch(); const versionTags = ['published']; const publishedDocs: any[] = []; for (const doc of docs) { @@ -985,10 +1069,20 @@ export class RootCMSClient { }); batchCount += 1; + // Publish the doc's translations within the same batch so the doc and + // its translations go live atomically. + if (tm) { + const localeDocs = translationsByDocId[id] || []; + batchCount += tm.addPublishTranslationsOps(localeDocs, batch, { + publishedBy: scheduledBy || 'root-cms-client', + }); + } + publishedDocs.push(doc); if (batchCount >= 400) { await batch.commit(); + batch = this.db.batch(); batchCount = 0; continue; } @@ -1169,8 +1263,7 @@ export class RootCMSClient { * replace the v1 translations system. */ getTranslationsManager(): TranslationsManager { - const cmsPluginOptions = this.cmsPlugin.getConfig(); - if (cmsPluginOptions.experiments?.v2TranslationsManager) { + if (!this.isV2TranslationsEnabled()) { throw new Error( '`v2TranslationsManager` is not enabled. update root.config.ts and add: `{experiments: {v2TranslationsManager: true}}`' ); @@ -1178,6 +1271,15 @@ export class RootCMSClient { return new TranslationsManager(this); } + /** + * Returns true if the v2 `TranslationsManager` is enabled via the + * `experiments.v2TranslationsManager` plugin config flag. + */ + isV2TranslationsEnabled(): boolean { + const cmsPluginOptions = this.cmsPlugin.getConfig(); + return Boolean(cmsPluginOptions.experiments?.v2TranslationsManager); + } + /** * Loads translations saved in the translations collection, optionally * filtered by tag. @@ -1287,33 +1389,6 @@ export class RootCMSClient { return translationsForLocale(translationsMap, locale); } - /** - * Firestore path for a translations file. - */ - dbTranslationsPath( - translationsId: string, - options: {mode: 'draft' | 'published'} - ) { - const mode = options.mode; - if (!(mode === 'draft' || mode === 'published')) { - throw new Error(`invalid mode: ${mode}`); - } - const slug = normalizeSlug(translationsId); - const dbPath = `Projects/${this.projectId}/TranslationsManager/${mode}/Translations/${slug}`; - return dbPath; - } - - /** - * Firestore doc ref for a translations file. - */ - dbTranslationsRef( - translationsId: string, - options: {mode: 'draft' | 'published'} - ) { - const dbPath = this.dbTranslationsPath(translationsId, options); - return this.db.doc(dbPath); - } - /** * Returns a data source configuration object. */ @@ -2327,9 +2402,15 @@ export interface BatchRequestOptions { mode: 'draft' | 'published'; /** * Whether to automatically fetch translations for the docs retrieved in the - * request. + * request (including docs returned by queries). */ translate?: boolean; + /** + * Locales to fetch translations for. Each locale is expanded through its + * fallback chain (per the `i18n.fallbacks` config). Defaults to the locales + * configured in `i18n.locales`. + */ + locales?: string[]; } export interface BatchRequestQuery { @@ -2346,24 +2427,6 @@ export interface BatchRequestQueryOptions { query?: (query: Query) => Query; } -export interface TranslationsDoc { - id: string; - sys: { - modifiedAt: Timestamp; - modifiedBy: string; - publishedAt?: Timestamp; - publishedBy?: string; - linkedSheet?: { - spreadsheetId: string; - gid: number; - linkedAt: Timestamp; - linkedBy: string; - }; - tags?: string[]; - }; - strings: TranslationsMap; -} - export class BatchRequest { cmsClient: RootCMSClient; private options: BatchRequestOptions; @@ -2372,6 +2435,15 @@ export class BatchRequest { private dataSourceIds: string[] = []; private queries: BatchRequestQuery[] = []; private translationsIds: string[] = []; + /** + * Translations ids auto-collected from query results when + * `options.translate` is enabled. Kept separate from the explicitly-added + * ids so that the merge order is deterministic (generic translations first, + * doc-specific translations last). + */ + private queryTranslationsIds: string[] = []; + /** Translations ids auto-collected from `addDoc()` docs. */ + private docTranslationsIds: string[] = []; constructor(cmsClient: RootCMSClient, options: BatchRequestOptions) { this.cmsClient = cmsClient; @@ -2409,7 +2481,7 @@ export class BatchRequest { } /** - * Adds a translation file to the request. + * Adds a translations doc to the request. */ addTranslations(translationsId: string) { this.translationsIds.push(translationsId); @@ -2419,25 +2491,25 @@ export class BatchRequest { * Fetches data from the DB. */ async fetch(): Promise { - const res = new BatchResponse(); + const res = new BatchResponse(this.cmsClient.rootConfig.i18n || {}); const promises = [ this.fetchDocs(res), this.fetchQueries(res), this.fetchDataSources(res), ]; - // If `options.translate` is disabled and translations are requested, - // fetch the translations in parallel with the other docs. + // If `options.translate` is disabled, any explicitly-added translations + // can be fetched in parallel with the other docs. if (!this.options.translate && this.translationsIds.length > 0) { promises.push(this.fetchTranslations(res)); } await Promise.all(promises); - // If `options.translate` is enabled, the fetchX() methods will - // automatically add each doc's translations id to the request, so - // translations should be fetched after all the other docs are fetched. - if (this.translationsIds.length > 0) { + // If `options.translate` is enabled, `fetchDocs()` and `fetchQueries()` + // auto-collect each doc's translations id, so translations are fetched + // after all the other docs are fetched. + if (this.options.translate) { await this.fetchTranslations(res); } @@ -2465,7 +2537,7 @@ export class BatchRequest { res.docs[docId] = docData; if (this.options.translate) { - this.addTranslations(docId); + this.docTranslationsIds.push(docId); } }); } @@ -2503,6 +2575,11 @@ export class BatchRequest { results.forEach((result) => { const doc = unmarshalData(result.data()) as Doc; docs.push(doc); + // Based on the results of the query, fetch the corresponding + // translations for each doc. + if (this.options.translate && doc.id) { + this.queryTranslationsIds.push(doc.id); + } }); res.queries[queryItem.queryId] = docs; }; @@ -2531,24 +2608,95 @@ export class BatchRequest { } private async fetchTranslations(res: BatchResponse) { - if (this.translationsIds.length === 0) { + // Order the translations ids so that precedence is deterministic: + // generic translations (e.g. "common") first, docs returned from queries + // next, and specific docs (e.g. "Pages/index") last. + const translationsIds = Array.from( + new Set([ + ...this.translationsIds, + ...this.queryTranslationsIds, + ...this.docTranslationsIds, + ]) + ); + if (translationsIds.length === 0) { return; } - - const docRefs = this.translationsIds.map((translationsId) => { - return this.cmsClient.dbTranslationsRef(translationsId, { - mode: this.options.mode, + const mode = this.options.mode; + const project = this.cmsClient.projectId; + const i18nConfig = this.cmsClient.rootConfig.i18n || {}; + + const locales = this.options.locales || i18nConfig.locales; + if (locales && locales.length > 0) { + // When the locales are known, expand each locale through its fallback + // chain and fetch the exact locale doc refs with `getAll()`. + const localeSet = new Set(); + for (const locale of locales) { + for (const fallback of resolveLocaleFallbacks(i18nConfig, locale)) { + localeSet.add(fallback); + } + } + const localeDocRefs: Array<{ + translationsId: string; + locale: string; + ref: FirebaseFirestore.DocumentReference; + }> = []; + for (const translationsId of translationsIds) { + for (const locale of localeSet) { + const dbPath = buildTranslationsLocaleDocDbPath({ + project, + mode, + id: translationsId, + locale, + }); + localeDocRefs.push({ + translationsId, + locale, + ref: this.db.doc(dbPath), + }); + } + } + // Fetch the refs in chunks. Missing locale docs are fine (e.g. a + // translations doc may not have every locale). + const chunks = chunkArray(localeDocRefs, 300); + const snapshotChunks = await Promise.all( + chunks.map((chunk) => this.db.getAll(...chunk.map((item) => item.ref))) + ); + const snapshots = snapshotChunks.flat(); + localeDocRefs.forEach((item, i) => { + const snapshot = snapshots[i]; + if (!snapshot.exists) { + return; + } + res.translations[item.translationsId] ??= {}; + res.translations[item.translationsId][item.locale] = + snapshot.data() as TranslationsLocaleDoc; }); - }); - const docs = await this.db.getAll(...docRefs); - this.translationsIds.forEach((translationsId, i) => { - const doc = docs[i]; - if (!doc.exists) { - // console.warn(`translations "${translationsId}" does not exist`); - return; + } else { + // When the locales are unknown, query the translations locale docs by + // translations id (chunked due to Firestore's `in` query limits). + const dbPath = buildTranslationsDbPath({project, mode}); + const collectionRef = this.db.collection(dbPath); + const snapshots = await Promise.all( + chunkArray(translationsIds, 10).map((chunk) => + collectionRef.where('id', 'in', chunk).get() + ) + ); + const localeDocsById: Record = {}; + snapshots.forEach((snapshot) => { + snapshot.docs.forEach((doc) => { + const localeDoc = doc.data() as TranslationsLocaleDoc; + localeDocsById[localeDoc.id] ??= []; + localeDocsById[localeDoc.id].push(localeDoc); + }); + }); + // Store the results in insertion (precedence) order. + for (const translationsId of translationsIds) { + for (const localeDoc of localeDocsById[translationsId] || []) { + res.translations[translationsId] ??= {}; + res.translations[translationsId][localeDoc.locale] = localeDoc; + } } - res.translations[translationsId] = doc.data() as TranslationsDoc; - }); + } } } @@ -2556,55 +2704,69 @@ export class BatchResponse { docs: Record = {}; queries: Record = {}; dataSources: Record = {}; - translations: Record = {}; + /** + * Translations locale docs retrieved in the request, keyed by translations + * id then by locale. The ids are in precedence order: generic translations + * (e.g. "common") first, doc-specific translations last. + */ + translations: Record> = {}; + + private i18nConfig: RootConfig['i18n']; + + constructor(i18nConfig?: RootConfig['i18n']) { + this.i18nConfig = i18nConfig || {}; + } /** * Returns a map of translations for a given locale or locale fallbacks. * - * The input is either a single locale (e.g. "de") or an array of locales - * representing the fallback tree, e.g. ["en-CA", "en-GB", "en"]. - * - * TODO(stevenle): support the locale fallback tree. + * The input is either a single locale (e.g. "de"), which is expanded + * through the fallback chain configured in `i18n.fallbacks`, or an array of + * locales representing an explicit fallback chain, e.g. + * `["en-CA", "en-GB", "en"]`. * * The returned value is a flat map of source string to translated string, * e.g.: * {"": ""} */ - getTranslations(locale: string): LocaleTranslations { - const translationsMap = this.getTranslationsMap(); - const translations = translationsForLocale(translationsMap, locale); - return translations; - } - - /** - * Merges the strings from all translations files retrieved in the request. - * The returned value is a map of string to translations, e.g.: - * - * {"": {"source": "", "": ""}} - */ - private getTranslationsMap(): TranslationsMap { - // Load translations in the following order: - // - generic translations (e.g. "global") - // - docs returned from queries (e.g. "list all blog posts") - // - specific docs (e.g. "Pages/index") - const translationsDocs = Object.values(this.translations).reverse(); - - // Consolidate the strings from all of the translations files. - // {"": {"source": "", "": ""}} - const translationsMap: TranslationsMap = {}; - for (const translationsDoc of translationsDocs) { - const strings = translationsDoc.strings || {}; - for (const hash in strings) { - const translations = strings[hash]; - translationsMap[hash] ??= {source: translations.source}; - for (const locale in translations) { - if (locale !== 'source' && translations[locale]) { - translationsMap[hash][locale] = translations[locale]; + getTranslations(locale: string | string[]): LocaleTranslations { + const fallbackLocales = Array.isArray(locale) + ? locale + : resolveLocaleFallbacks(this.i18nConfig, locale); + + // Merge the strings from all of the translations docs. The docs are + // merged in precedence order (generic translations first, doc-specific + // translations last) so that later docs override earlier ones. + const merged: Record< + string, + {source: string; translations: Record} + > = {}; + for (const localeDocs of Object.values(this.translations)) { + for (const localeDoc of Object.values(localeDocs)) { + const strings = localeDoc.strings || {}; + for (const hash in strings) { + const entry = strings[hash]; + merged[hash] ??= {source: entry.source, translations: {}}; + if (entry.translation) { + merged[hash].translations[localeDoc.locale] = entry.translation; } } } } - return translationsMap; + // For each string, pick the first locale in the fallback chain with a + // non-empty translation, falling back to the source string. + const localeTranslations: LocaleTranslations = {}; + for (const item of Object.values(merged)) { + let translation = item.source; + for (const fallbackLocale of fallbackLocales) { + if (item.translations[fallbackLocale]) { + translation = item.translations[fallbackLocale]; + break; + } + } + localeTranslations[item.source] = translation; + } + return localeTranslations; } } diff --git a/packages/root-cms/core/publish-translations.test.ts b/packages/root-cms/core/publish-translations.test.ts new file mode 100644 index 000000000..3738231e4 --- /dev/null +++ b/packages/root-cms/core/publish-translations.test.ts @@ -0,0 +1,196 @@ +/** + * Tests for the publish/unpublish integration between content docs and the + * v2 TranslationsManager, against the Firestore emulator (see + * translations-manager.test.ts for details on the emulator setup). + */ + +import {getApps, initializeApp} from 'firebase-admin/app'; +import {Timestamp, getFirestore} from 'firebase-admin/firestore'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {RootCMSClient} from './client.js'; +import {TranslationsLocaleDoc} from './translations-manager.js'; + +const FIREBASE_PROJECT_ID = 'rootjs-cms'; + +function getTestApp() { + const existing = getApps().find((app) => app.name === 'publish-test'); + if (existing) { + return existing; + } + return initializeApp({projectId: FIREBASE_PROJECT_ID}, 'publish-test'); +} + +let projectCounter = 0; + +function createTestCmsClient(options?: { + experiments?: Record; +}): RootCMSClient { + const app = getTestApp(); + const db = getFirestore(app); + const projectId = `publish-test-${Date.now()}-${projectCounter++}`; + const plugin = { + name: 'root-cms', + getConfig: () => ({ + id: projectId, + firebaseConfig: { + apiKey: 'test', + authDomain: 'test', + projectId: FIREBASE_PROJECT_ID, + storageBucket: 'test', + }, + experiments: options?.experiments ?? {v2TranslationsManager: true}, + }), + getFirebaseApp: () => app, + getFirestore: () => db, + }; + const rootConfig: any = { + rootDir: '/test', + i18n: {locales: ['en', 'es']}, + plugins: [plugin], + }; + return new RootCMSClient(rootConfig); +} + +async function seedDraftDoc(cmsClient: RootCMSClient, docId: string) { + const [collection, slug] = docId.split('/'); + await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/Collections/${collection}/Drafts/${slug}` + ) + .set({ + id: docId, + collection, + slug, + sys: { + createdAt: Timestamp.now(), + createdBy: 'test', + modifiedAt: Timestamp.now(), + modifiedBy: 'test', + locales: ['en', 'es'], + }, + fields: {title: `Title for ${docId}`}, + }); +} + +describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( + 'publishDocs() translations integration', + () => { + let cmsClient: RootCMSClient; + + beforeEach(() => { + cmsClient = createTestCmsClient(); + }); + + it('publishes a doc together with its translations', async () => { + await seedDraftDoc(cmsClient, 'Pages/index'); + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('Pages/index', {hello: {es: 'hola'}}); + + await cmsClient.publishDocs(['Pages/index'], { + publishedBy: 'test@example.com', + }); + + // The published doc exists. + const publishedDoc = await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/Collections/Pages/Published/index` + ) + .get(); + expect(publishedDoc.exists).toBe(true); + + // The published translations exist. + const publishedTranslations = await tm.loadTranslations({ + ids: ['Pages/index'], + mode: 'published', + }); + expect(publishedTranslations.hello.es).toBe('hola'); + + // The draft locale doc's sys records the publish. + const draftLocaleDoc = await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/TranslationsManager/draft/Translations/Pages--index:es` + ) + .get(); + const draftData = draftLocaleDoc.data() as TranslationsLocaleDoc; + expect(draftData.sys.publishedBy).toBe('test@example.com'); + }); + + it('does not publish unrelated translations docs', async () => { + await seedDraftDoc(cmsClient, 'Pages/index'); + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('Pages/index', {hello: {es: 'hola'}}); + await tm.saveTranslations('common', {bye: {es: 'adios'}}); + + await cmsClient.publishDocs(['Pages/index'], { + publishedBy: 'test@example.com', + }); + + const published = await tm.loadTranslations({ + ids: ['common'], + mode: 'published', + }); + expect(published).toEqual({}); + }); + + it('does not touch translations when the flag is disabled', async () => { + const v1Client = createTestCmsClient({experiments: {}}); + await seedDraftDoc(v1Client, 'Pages/index'); + // Seed a draft translations locale doc directly (the manager API is + // gated behind the flag). + await v1Client.db + .doc( + `Projects/${v1Client.projectId}/TranslationsManager/draft/Translations/Pages--index:es` + ) + .set({ + id: 'Pages/index', + locale: 'es', + tags: [], + strings: {}, + sys: {modifiedAt: Timestamp.now(), modifiedBy: 'test'}, + }); + + await v1Client.publishDocs(['Pages/index'], { + publishedBy: 'test@example.com', + }); + + const publishedLocaleDoc = await v1Client.db + .doc( + `Projects/${v1Client.projectId}/TranslationsManager/published/Translations/Pages--index:es` + ) + .get(); + expect(publishedLocaleDoc.exists).toBe(false); + }); + + it('unpublishDocs() removes published translations', async () => { + await seedDraftDoc(cmsClient, 'Pages/index'); + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('Pages/index', {hello: {es: 'hola'}}); + await cmsClient.publishDocs(['Pages/index'], { + publishedBy: 'test@example.com', + }); + + await cmsClient.unpublishDocs(['Pages/index'], { + unpublishedBy: 'test@example.com', + }); + + // The published locale docs are deleted. + const published = await tm.loadTranslations({ + ids: ['Pages/index'], + mode: 'published', + }); + expect(published).toEqual({}); + + // The draft locale doc's published metadata is cleared. + const draftLocaleDoc = await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/TranslationsManager/draft/Translations/Pages--index:es` + ) + .get(); + const draftData = draftLocaleDoc.data() as TranslationsLocaleDoc; + expect(draftData.sys.publishedAt).toBeUndefined(); + expect(draftData.sys.publishedBy).toBeUndefined(); + // The draft translations themselves remain. + expect(draftData.id).toBe('Pages/index'); + }); + } +); diff --git a/packages/root-cms/core/translations-manager.test.ts b/packages/root-cms/core/translations-manager.test.ts new file mode 100644 index 000000000..7f7b3dff2 --- /dev/null +++ b/packages/root-cms/core/translations-manager.test.ts @@ -0,0 +1,377 @@ +/** + * Tests for the v2 TranslationsManager. These tests run against the Firestore + * emulator (the package's test script runs vitest under + * `firebase emulators:exec`, which sets FIRESTORE_EMULATOR_HOST so the admin + * SDK auto-connects to the emulator). + */ + +import crypto from 'node:crypto'; +import {getApps, initializeApp} from 'firebase-admin/app'; +import {Timestamp, getFirestore} from 'firebase-admin/firestore'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {hashStr} from '../shared/strings.js'; +import {RootCMSClient} from './client.js'; +import { + TranslationsLocaleDoc, + translationsForLocaleV2, +} from './translations-manager.js'; + +const FIREBASE_PROJECT_ID = 'rootjs-cms'; + +function getTestApp() { + const existing = getApps().find((app) => app.name === 'tm-test'); + if (existing) { + return existing; + } + return initializeApp({projectId: FIREBASE_PROJECT_ID}, 'tm-test'); +} + +let projectCounter = 0; + +/** + * Creates a RootCMSClient backed by the Firestore emulator. Each call uses a + * unique CMS project id so tests are isolated from each other. + */ +function createTestCmsClient(options?: { + i18n?: any; + experiments?: Record; +}): RootCMSClient { + const app = getTestApp(); + const db = getFirestore(app); + const projectId = `test-project-${Date.now()}-${projectCounter++}`; + const plugin = { + name: 'root-cms', + getConfig: () => ({ + id: projectId, + firebaseConfig: { + apiKey: 'test', + authDomain: 'test', + projectId: FIREBASE_PROJECT_ID, + storageBucket: 'test', + }, + experiments: options?.experiments ?? {v2TranslationsManager: true}, + }), + getFirebaseApp: () => app, + getFirestore: () => db, + }; + const rootConfig: any = { + rootDir: '/test', + i18n: options?.i18n ?? {locales: ['en', 'es', 'fr']}, + plugins: [plugin], + }; + return new RootCMSClient(rootConfig); +} + +function sha1(str: string) { + return crypto.createHash('sha1').update(str).digest('hex'); +} + +describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( + 'TranslationsManager', + () => { + let cmsClient: RootCMSClient; + + beforeEach(() => { + cmsClient = createTestCmsClient(); + }); + + it('getTranslationsManager() throws when the flag is disabled', () => { + const disabledClient = createTestCmsClient({experiments: {}}); + expect(() => disabledClient.getTranslationsManager()).toThrowError( + /v2TranslationsManager/ + ); + expect(disabledClient.isV2TranslationsEnabled()).toBe(false); + expect(cmsClient.isV2TranslationsEnabled()).toBe(true); + }); + + it('saves draft translations as per-locale docs', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations( + 'Pages/index', + { + one: {es: 'uno', fr: 'un'}, + two: {es: 'dos'}, + }, + {tags: ['Pages/index']} + ); + + const dbPath = `Projects/${cmsClient.projectId}/TranslationsManager/draft/Translations`; + // Slashes in the translations id are normalized to `--` in doc keys. + const esDoc = await cmsClient.db + .doc(`${dbPath}/Pages--index:es`) + .get(); + expect(esDoc.exists).toBe(true); + const esData = esDoc.data() as TranslationsLocaleDoc; + expect(esData.id).toBe('Pages/index'); + expect(esData.locale).toBe('es'); + expect(esData.tags).toEqual(['Pages/index']); + expect(esData.strings[hashStr('one')]).toEqual({ + source: 'one', + translation: 'uno', + }); + expect(esData.strings[hashStr('two')]).toEqual({ + source: 'two', + translation: 'dos', + }); + expect(esData.sys.modifiedAt).toBeDefined(); + + const frDoc = await cmsClient.db + .doc(`${dbPath}/Pages--index:fr`) + .get(); + expect(frDoc.exists).toBe(true); + expect((frDoc.data() as TranslationsLocaleDoc).strings).toEqual({ + [hashStr('one')]: {source: 'one', translation: 'un'}, + }); + }); + + it('loads draft translations by id', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', {hello: {es: 'hola'}}); + const strings = await tm.loadTranslations({ + ids: ['common'], + mode: 'draft', + }); + expect(strings).toEqual({hello: {source: 'hello', es: 'hola'}}); + }); + + it('chunks `in` queries when loading more than 10 ids', async () => { + const tm = cmsClient.getTranslationsManager(); + const ids: string[] = []; + for (let i = 0; i < 12; i++) { + const id = `Pages/page-${i}`; + ids.push(id); + await tm.saveTranslations(id, {[`string ${i}`]: {es: `cadena ${i}`}}); + } + const strings = await tm.loadTranslations({ids, mode: 'draft'}); + expect(Object.keys(strings)).toHaveLength(12); + expect(strings['string 11'].es).toBe('cadena 11'); + }); + + it('merges translations in ids order (doc-specific wins)', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', {hello: {es: 'hola (common)'}}); + await tm.saveTranslations('Pages/index', {hello: {es: 'hola (doc)'}}); + const strings = await tm.loadTranslations({ + ids: ['common', 'Pages/index'], + mode: 'draft', + }); + expect(strings.hello.es).toBe('hola (doc)'); + }); + + it('loads translations by tags using array-contains-any', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', {hello: {es: 'hola'}}, { + tags: ['common'], + }); + await tm.saveTranslations('Pages/foo', {bye: {es: 'adios'}}, { + tags: ['Pages/foo'], + }); + const strings = await tm.loadTranslations({ + tags: ['common', 'other'], + mode: 'draft', + }); + expect(strings).toEqual({hello: {source: 'hello', es: 'hola'}}); + }); + + it('loadTranslationsForLocale() applies config fallbacks', async () => { + const client = createTestCmsClient({ + i18n: { + locales: ['en', 'en-GB', 'en-CA'], + fallbacks: {'en-CA': ['en-GB']}, + }, + }); + const tm = client.getTranslationsManager(); + await tm.saveTranslations('common', { + color: {'en-GB': 'colour'}, + localOnly: {'en-CA': 'local (en-CA)', 'en-GB': 'local (en-GB)'}, + untranslated: {fr: 'french only'}, + }); + const translations = await tm.loadTranslationsForLocale('en-CA', { + mode: 'draft', + }); + expect(translations.color).toBe('colour'); + expect(translations.localOnly).toBe('local (en-CA)'); + // Strings translated only into locales outside the fallback chain are + // not loaded (only the chain's locale docs are fetched). + expect(translations.untranslated).toBeUndefined(); + }); + + it('publishes draft translations to the published collection', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('Pages/index', {one: {es: 'uno'}}); + await tm.publishTranslations('Pages/index', { + publishedBy: 'test@example.com', + }); + + const publishedPath = `Projects/${cmsClient.projectId}/TranslationsManager/published/Translations/Pages--index:es`; + const publishedDoc = await cmsClient.db.doc(publishedPath).get(); + expect(publishedDoc.exists).toBe(true); + const publishedData = publishedDoc.data() as TranslationsLocaleDoc; + expect(publishedData.strings[hashStr('one')].translation).toBe('uno'); + expect(publishedData.sys.publishedBy).toBe('test@example.com'); + expect(publishedData.sys.publishedAt).toBeDefined(); + + // The draft doc's sys should be updated with the published metadata. + const draftPath = `Projects/${cmsClient.projectId}/TranslationsManager/draft/Translations/Pages--index:es`; + const draftDoc = await cmsClient.db.doc(draftPath).get(); + expect((draftDoc.data() as TranslationsLocaleDoc).sys.publishedBy).toBe( + 'test@example.com' + ); + + // Published translations are returned by loadTranslations(). + const strings = await tm.loadTranslations({ids: ['Pages/index']}); + expect(strings.one.es).toBe('uno'); + }); + + it('publishTranslationsBulk() publishes multiple ids', async () => { + const tm = cmsClient.getTranslationsManager(); + await tm.saveTranslations('common', {hello: {es: 'hola'}}); + await tm.saveTranslations('Pages/index', {one: {es: 'uno', fr: 'un'}}); + const res = await tm.publishTranslationsBulk( + ['common', 'Pages/index', 'does-not-exist'], + {publishedBy: 'test@example.com'} + ); + expect(res.publishedIds.sort()).toEqual(['Pages/index', 'common']); + const strings = await tm.loadTranslations({ + ids: ['common', 'Pages/index'], + mode: 'published', + }); + expect(strings.hello.es).toBe('hola'); + expect(strings.one.fr).toBe('un'); + }); + + describe('importTranslationsFromV1', () => { + async function seedV1Translation( + source: string, + translations: Record, + tags?: string[] + ) { + const data: Record = {...translations, source}; + if (tags) { + data.tags = tags; + } + await cmsClient.db + .doc(`Projects/${cmsClient.projectId}/Translations/${sha1(source)}`) + .set(data); + } + + it('groups strings into a translations doc per tag', async () => { + await seedV1Translation('hello', {es: 'hola'}, [ + 'Pages', + 'Pages/index', + 'common', + ]); + await seedV1Translation('bye', {es: 'adios'}, ['Pages', 'Pages/foo']); + await seedV1Translation('untagged string', {es: 'sin etiqueta'}); + // Non-string locale values are skipped. + await seedV1Translation('bad values', {es: ['not', 'a', 'string']}, [ + 'common', + ]); + + const tm = cmsClient.getTranslationsManager(); + const res = await tm.importTranslationsFromV1(); + expect(res.ids.sort()).toEqual([ + 'Pages', + 'Pages/foo', + 'Pages/index', + 'common', + 'v1-untagged', + ]); + expect(res.stats.numStrings).toBe(4); + expect(res.stats.numDocs).toBe(5); + + const commonStrings = await tm.loadTranslations({ + ids: ['common'], + mode: 'draft', + }); + expect(commonStrings.hello.es).toBe('hola'); + // The bad-values string has no valid translations but its source is + // grouped without locale values (nothing to store per locale). + expect(commonStrings['bad values']).toBeUndefined(); + + const untagged = await tm.loadTranslations({ + ids: ['v1-untagged'], + mode: 'draft', + }); + expect(untagged['untagged string'].es).toBe('sin etiqueta'); + + // The import saves drafts only; nothing is published yet. + const published = await tm.loadTranslations({ids: ['common']}); + expect(published).toEqual({}); + }); + + it('migrates the linked l10n sheet from the doc', async () => { + await seedV1Translation('hello', {es: 'hola'}, ['Pages/index']); + await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/Collections/Pages/Drafts/index` + ) + .set({ + id: 'Pages/index', + collection: 'Pages', + slug: 'index', + sys: { + createdAt: Timestamp.now(), + createdBy: 'test', + modifiedAt: Timestamp.now(), + modifiedBy: 'test', + l10nSheet: { + spreadsheetId: 'sheet123', + gid: 0, + linkedAt: Timestamp.now(), + linkedBy: 'test@example.com', + }, + }, + fields: {}, + }); + + const tm = cmsClient.getTranslationsManager(); + await tm.importTranslationsFromV1(); + + const localeDoc = await cmsClient.db + .doc( + `Projects/${cmsClient.projectId}/TranslationsManager/draft/Translations/Pages--index:es` + ) + .get(); + const data = localeDoc.data() as TranslationsLocaleDoc; + expect(data.sys.linkedSheet?.spreadsheetId).toBe('sheet123'); + }); + + it('returns empty results when there are no v1 translations', async () => { + const tm = cmsClient.getTranslationsManager(); + const res = await tm.importTranslationsFromV1(); + expect(res.ids).toEqual([]); + expect(res.stats).toEqual({numStrings: 0, numDocs: 0}); + }); + }); + } +); + +describe('translationsForLocaleV2', () => { + it('resolves translations through the fallback chain', () => { + const strings = { + one: {'en-GB': 'one (GB)', en: 'one (en)'}, + two: {en: 'two (en)'}, + three: {fr: 'trois'}, + }; + const translations = translationsForLocaleV2(strings, [ + 'en-CA', + 'en-GB', + 'en', + ]); + expect(translations).toEqual({ + one: 'one (GB)', + two: 'two (en)', + three: 'three', + }); + }); + + it('skips empty translations in the chain', () => { + const strings = { + one: {'en-CA': '', 'en-GB': 'one (GB)'}, + }; + expect(translationsForLocaleV2(strings, ['en-CA', 'en-GB'])).toEqual({ + one: 'one (GB)', + }); + }); +}); diff --git a/packages/root-cms/core/translations-manager.ts b/packages/root-cms/core/translations-manager.ts index 4b5ac2e2c..1ca78a77e 100644 --- a/packages/root-cms/core/translations-manager.ts +++ b/packages/root-cms/core/translations-manager.ts @@ -1,9 +1,11 @@ import { + DocumentReference, FieldValue, Query, Timestamp, WriteBatch, } from 'firebase-admin/firestore'; +import {resolveLocaleFallbacks} from '../shared/locale-fallbacks.js'; import {normalizeSlug} from '../shared/slug.js'; import {hashStr} from '../shared/strings.js'; import type {RootCMSClient} from './client.js'; @@ -13,6 +15,18 @@ const TRANSLATIONS_DB_PATH_FORMAT = const TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`; +/** + * Firestore limits `in` queries to 10 values, so larger queries are broken up + * into chunks. + */ +const IN_QUERY_CHUNK_SIZE = 10; + +/** + * Firestore batches allow a max of 500 write ops. Use a slightly lower limit + * to leave headroom for callers that add their own ops to a shared batch. + */ +const MAX_BATCH_OPS = 400; + export type Locale = string; export type SourceString = string; export type TranslatedString = string; @@ -26,7 +40,7 @@ export type TranslationsDocMode = 'draft' | 'published'; * This type is not meant to be used by external callers since this is primarily * an internal implementation detail. */ -interface TranslationsLocaleDoc { +export interface TranslationsLocaleDoc { /** * Translations id. In most cases, this is the same as the doc id, e.g. * `Pages/foo--bar`. @@ -40,15 +54,25 @@ interface TranslationsLocaleDoc { modifiedBy: string; publishedAt?: Timestamp; publishedBy?: string; - linkedSheet?: { - spreadsheetId: string; - gid: number; - linkedAt: Timestamp; - linkedBy: string; - }; + linkedSheet?: TranslationsLinkedSheet; }; } +export interface TranslationsLinkedSheet { + spreadsheetId: string; + gid: number; + linkedAt: Timestamp; + linkedBy: string; +} + +/** + * A translations locale doc paired with its Firestore doc ref. + */ +export interface TranslationsLocaleDocWithRef { + ref: DocumentReference; + data: TranslationsLocaleDoc; +} + export interface TranslationsLocaleDocHashMap { /** * A hash map of a source string's hash fingerprint to the source string and @@ -107,6 +131,20 @@ export interface SingleLocaleTranslationsMap { [source: SourceString]: TranslatedString; } +/** + * Stats returned by `importTranslationsFromV1()`. + */ +export interface ImportTranslationsFromV1Result { + /** Translations doc ids that were created or updated. */ + ids: string[]; + stats: { + /** Number of v1 source strings imported. */ + numStrings: number; + /** Number of v2 translations docs saved. */ + numDocs: number; + }; +} + export class TranslationsManager { cmsClient: RootCMSClient; @@ -129,7 +167,11 @@ export class TranslationsManager { async saveTranslations( id: string, strings: MultiLocaleTranslationsMap, - options?: {tags?: string[]; modifiedBy?: string} + options?: { + tags?: string[]; + modifiedBy?: string; + linkedSheet?: TranslationsLinkedSheet; + } ) { const mode = 'draft'; const localesSet: Set = new Set(); @@ -141,9 +183,15 @@ export class TranslationsManager { }); }); - const batch = this.cmsClient.db.batch(); + const db = this.cmsClient.db; + let batch = db.batch(); + let numOps = 0; const locales = Array.from(localesSet); - locales.forEach((locale) => { + for (const locale of locales) { + const hashMap = this.toLocaleDocHashMap(strings, locale); + if (Object.keys(hashMap).length === 0) { + continue; + } const updates: Record = { id: id, locale: locale, @@ -151,34 +199,32 @@ export class TranslationsManager { modifiedAt: Timestamp.now(), modifiedBy: options?.modifiedBy || 'root-cms-client', }, - strings: {}, + strings: hashMap, }; if (options?.tags && options.tags.length > 0) { updates.tags = FieldValue.arrayUnion(...options.tags); } - let numUpdates = 0; - const hashMap = this.toLocaleDocHashMap(strings, locale); - Object.entries(hashMap).forEach(([hash, translations]) => { - Object.entries(translations).forEach(([locale, translation]) => { - if (translation) { - updates.strings[hash] ??= {}; - updates.strings[hash][locale] = translation; - numUpdates += 1; - } - }); + if (options?.linkedSheet) { + updates.sys.linkedSheet = options.linkedSheet; + } + const localeDocPath = buildTranslationsLocaleDocDbPath({ + project: this.cmsClient.projectId, + mode: mode, + id: id, + locale: locale, }); - if (numUpdates > 0) { - const localeDocPath = buildTranslationsLocaleDocDbPath({ - project: this.cmsClient.projectId, - mode: mode, - id: id, - locale: locale, - }); - const localeDocRef = this.cmsClient.db.doc(localeDocPath); - batch.set(localeDocRef, updates, {merge: true}); + const localeDocRef = db.doc(localeDocPath); + batch.set(localeDocRef, updates, {merge: true}); + numOps += 1; + if (numOps >= MAX_BATCH_OPS) { + await batch.commit(); + batch = db.batch(); + numOps = 0; } - }); - await batch.commit(); + } + if (numOps > 0) { + await batch.commit(); + } } /** @@ -189,40 +235,128 @@ export class TranslationsManager { options?: {batch?: WriteBatch; publishedBy?: string} ) { const db = this.cmsClient.db; - const project = this.cmsClient.projectId; - const draftPath = buildTranslationsDbPath({project, mode: 'draft'}); - const query = db.collection(draftPath).where('id', '==', id); - const res = await query.get(); - if (res.size === 0) { + const localeDocsById = await this.getTranslationsLocaleDocs([id], 'draft'); + const localeDocs = localeDocsById[id] || []; + if (localeDocs.length === 0) { console.warn(`no translations to publish for ${id}`); return; } const batch = options?.batch || db.batch(); - res.docs.forEach((doc) => { - const translationsLocaleDoc = doc.data() as TranslationsLocaleDoc; + this.addPublishTranslationsOps(localeDocs, batch, { + publishedBy: options?.publishedBy, + }); + + // If a batch was provided, assume that the caller is responsible for + // calling `batch.commit()`. + const shouldCommitBatch = !options?.batch; + if (shouldCommitBatch) { + await batch.commit(); + } + } + + /** + * Publishes multiple translations docs by id, e.g.: + * ``` + * await tm.publishTranslationsBulk(['Pages/index', 'common']); + * ``` + */ + async publishTranslationsBulk( + ids: string[], + options?: {publishedBy?: string} + ): Promise<{publishedIds: string[]}> { + const db = this.cmsClient.db; + const localeDocsById = await this.getTranslationsLocaleDocs(ids, 'draft'); + const publishedIds: string[] = []; + let batch = db.batch(); + let numOps = 0; + for (const id of Object.keys(localeDocsById)) { + const localeDocs = localeDocsById[id]; + if (localeDocs.length === 0) { + continue; + } + // Keep all of a translations doc's ops within a single commit. + if (numOps > 0 && numOps + 2 * localeDocs.length > MAX_BATCH_OPS) { + await batch.commit(); + batch = db.batch(); + numOps = 0; + } + numOps += this.addPublishTranslationsOps(localeDocs, batch, options); + publishedIds.push(id); + } + if (numOps > 0) { + await batch.commit(); + } + return {publishedIds}; + } + + /** + * Adds the write ops for publishing a set of draft translations locale docs + * to a batch. For each locale doc, the draft doc's `sys` is updated with + * `publishedAt/By` and a copy is saved to the published collection. Returns + * the number of ops added to the batch (2 per locale doc). + */ + addPublishTranslationsOps( + localeDocs: TranslationsLocaleDocWithRef[], + batch: WriteBatch, + options?: {publishedBy?: string} + ): number { + const db = this.cmsClient.db; + const project = this.cmsClient.projectId; + const publishedBy = options?.publishedBy || 'root-cms-client'; + let numOps = 0; + for (const localeDoc of localeDocs) { + const data = localeDoc.data; const sys = { - ...translationsLocaleDoc.sys, + ...data.sys, publishedAt: Timestamp.now(), - publishedBy: options?.publishedBy || 'root-cms-client', + publishedBy: publishedBy, }; - batch.update(doc.ref, {sys}); + batch.update(localeDoc.ref, {sys}); const publishedDocPath = buildTranslationsLocaleDocDbPath({ - project, + project: project, mode: 'published', - id: translationsLocaleDoc.id, - locale: translationsLocaleDoc.locale, + id: data.id, + locale: data.locale, }); const publishedDocRef = db.doc(publishedDocPath); - batch.set(publishedDocRef, {...translationsLocaleDoc, sys}); - }); + batch.set(publishedDocRef, {...data, sys}); + numOps += 2; + } + return numOps; + } - // If a batch was provided, assume that the caller is responsible for - // calling `batch.commit()`. - const shouldCommitBatch = !options?.batch; - if (shouldCommitBatch) { - await batch.commit(); + /** + * Fetches the translations locale docs (with their doc refs) for a set of + * translations doc ids, grouped by id. + */ + async getTranslationsLocaleDocs( + ids: string[], + mode: TranslationsDocMode + ): Promise> { + const localeDocsById: Record = {}; + const uniqueIds = Array.from(new Set(ids)); + if (uniqueIds.length === 0) { + return localeDocsById; } + const dbPath = buildTranslationsDbPath({ + project: this.cmsClient.projectId, + mode: mode, + }); + const collectionRef = this.cmsClient.db.collection(dbPath); + const snapshots = await Promise.all( + chunkArray(uniqueIds, IN_QUERY_CHUNK_SIZE).map((chunk) => + collectionRef.where('id', 'in', chunk).get() + ) + ); + snapshots.forEach((snapshot) => { + snapshot.docs.forEach((doc) => { + const data = doc.data() as TranslationsLocaleDoc; + localeDocsById[data.id] ??= []; + localeDocsById[data.id].push({ref: doc.ref, data}); + }); + }); + return localeDocsById; } /** @@ -265,31 +399,69 @@ export class TranslationsManager { tags?: string[]; locales?: Locale[]; mode?: TranslationsDocMode; - }) { + }): Promise { const mode = options?.mode || 'published'; const dbPath = buildTranslationsDbPath({ project: this.cmsClient.projectId, mode: mode, }); + const collectionRef = this.cmsClient.db.collection(dbPath); - let query = this.cmsClient.db.collection(dbPath) as Query; - if (options?.ids && options.ids.length > 0) { - query = query.where('id', 'in', options.ids); - } - if (options?.tags && options.tags.length > 0) { - query = query.where('tags', 'array-contains', options.tags); - } - if (options?.locales && options.locales.length > 0) { - query = query.where('locale', 'in', options.locales); + const ids = options?.ids || []; + const tags = options?.tags || []; + const locales = options?.locales || []; + + let localeDocs: TranslationsLocaleDoc[]; + if (ids.length > 0) { + // Chunk the `in` query (Firestore limits `in` filters to 10 values) and + // run the chunks in parallel. Any tags/locales filters are applied in + // memory to avoid combining disjunctive filters in a single query. + const snapshots = await Promise.all( + chunkArray(ids, IN_QUERY_CHUNK_SIZE).map((chunk) => + collectionRef.where('id', 'in', chunk).get() + ) + ); + localeDocs = snapshots.flatMap((snapshot) => + snapshot.docs.map((doc) => doc.data() as TranslationsLocaleDoc) + ); + if (tags.length > 0) { + localeDocs = localeDocs.filter((localeDoc) => + (localeDoc.tags || []).some((tag) => tags.includes(tag)) + ); + } + if (locales.length > 0) { + localeDocs = localeDocs.filter((localeDoc) => + locales.includes(localeDoc.locale) + ); + } + // Merge the results in `ids` order so that precedence is deterministic, + // e.g. for `{ids: ['common', 'Pages/index']}` the doc-specific + // translations take precedence over the generic ones. + const idOrder = new Map(ids.map((id, i) => [id, i])); + localeDocs.sort( + (a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0) + ); + } else { + let query = collectionRef as Query; + if (tags.length > 0) { + query = query.where('tags', 'array-contains-any', tags); + } + if (locales.length > 0) { + query = query.where('locale', 'in', locales); + } + const results = await query.get(); + localeDocs = results.docs.map( + (doc) => doc.data() as TranslationsLocaleDoc + ); } - const results = await query.get(); const strings: MultiLocaleTranslationsMap = {}; - results.forEach((result) => { - const localeDoc = result.data() as TranslationsLocaleDoc; + localeDocs.forEach((localeDoc) => { Object.values(localeDoc.strings || {}).forEach((item) => { strings[item.source] ??= {source: item.source}; - strings[item.source][localeDoc.locale] = item.translation; + if (item.translation) { + strings[item.source][localeDoc.locale] = item.translation; + } }); }); return strings; @@ -299,6 +471,9 @@ export class TranslationsManager { * Fetches translations for a given locale, with optional fallbacks. * The return value is a map of source string to translated string. * + * If no `fallbackLocales` are provided, the fallback chain is resolved from + * the project's `i18n.fallbacks` config. + * * Example: * ``` * await translationsDoc.loadTranslationsForLocale('es'); @@ -315,49 +490,15 @@ export class TranslationsManager { ): Promise { const localeSet: Set = new Set([ locale, - ...(options?.fallbackLocales || []), + ...(options?.fallbackLocales || + resolveLocaleFallbacks(this.cmsClient.rootConfig.i18n, locale)), ]); const fallbackLocales = Array.from(localeSet); const multiLocaleStrings = await this.loadTranslations({ mode: options?.mode, locales: fallbackLocales, }); - return this.toSingleLocaleMap(multiLocaleStrings, fallbackLocales); - } - - /** - * Converts a multi-locale translations map to a flat single-locale map, - * with optional support for fallback locales. - * - * ``` - * const multiLocaleStrings = { - * 'one': {es: 'uno', fr: 'un'}, - * 'two': {es: 'dos', fr: 'deux'} - * }; - * translationsDoc.toSingleLocaleMap(multiLocaleStrings, ['es']); - * // => - * // { - * // "one": "uno", - * // "two": "dos", - * // } - * ``` - */ - private toSingleLocaleMap( - multiLocaleStrings: MultiLocaleTranslationsMap, - fallbackLocales: Locale[] - ): SingleLocaleTranslationsMap { - const singleLocaleStrings: SingleLocaleTranslationsMap = {}; - Object.entries(multiLocaleStrings).forEach(([source, translations]) => { - let translation = source; - for (const locale of fallbackLocales) { - if (translations[locale]) { - translation = translations[locale]; - break; - } - } - singleLocaleStrings[source] = translation; - }); - return singleLocaleStrings; + return translationsForLocaleV2(multiLocaleStrings, fallbackLocales); } /** @@ -396,71 +537,148 @@ export class TranslationsManager { } /** - * Import translations from the v1 system to the TranslationsManager. + * Imports translations from the v1 system to the TranslationsManager. + * + * Each v1 string is grouped into a v2 translations doc per tag (e.g. a + * string tagged `Pages/index` is saved to the `Pages/index` translations + * doc). Untagged strings are grouped into a `v1-untagged` doc so that + * nothing is dropped. The imported translations are saved as drafts; use + * `publishTranslationsBulk()` to publish them. */ - async importTranslationsFromV1() { + async importTranslationsFromV1(): Promise { const projectId = this.cmsClient.projectId; const db = this.cmsClient.db; const dbPath = `Projects/${projectId}/Translations`; const query = db.collection(dbPath); const querySnapshot = await query.get(); + const stats = {numStrings: 0, numDocs: 0}; if (querySnapshot.size === 0) { - return; + return {ids: [], stats}; } console.log( '[root cms] importing v1 Translations to v2 TranslationsManager' ); - const translationsDocs: Record = {}; + const translationsDocs: Record< + string, + {id: string; strings: MultiLocaleTranslationsMap} + > = {}; querySnapshot.forEach((doc) => { const translation = doc.data(); - const source = this.cmsClient.normalizeString(translation.source); - delete translation.source; - const tags = (translation.tags || []) as string[]; - delete translation.tags; - for (const tag of tags) { - if (tag.includes('/')) { - const translationsId = tag; - translationsDocs[translationsId] ??= { - id: translationsId, - tags: tags, - strings: {}, - }; - translationsDocs[translationsId].strings[source] = translation; + const source = this.cmsClient.normalizeString(translation.source || ''); + if (!source) { + return; + } + // Collect the locale values, ignoring metadata keys and any non-string + // values. + const localeValues: Record = {}; + for (const [key, value] of Object.entries(translation)) { + if (key === 'source' || key === 'tags') { + continue; } + if (typeof value !== 'string' || !value) { + continue; + } + localeValues[key] = value; + } + // Group the string into a translations doc per tag. Untagged strings + // are grouped into a `v1-untagged` doc so that nothing is dropped. + const tags = (translation.tags || []) as string[]; + const translationsIds = tags.length > 0 ? tags : ['v1-untagged']; + for (const translationsId of translationsIds) { + translationsDocs[translationsId] ??= { + id: translationsId, + strings: {}, + }; + translationsDocs[translationsId].strings[source] = localeValues; } + stats.numStrings += 1; }); - if (Object.keys(translationsDocs).length === 0) { + const ids = Object.keys(translationsDocs); + if (ids.length === 0) { console.log('[root cms] no v1 translations to save'); - return; + return {ids: [], stats}; } - // Move the doc's "l10nSheet" to the translations doc's "linkedSheet". - for (const docId in translationsDocs) { - const [collection, slug] = docId.split('/'); - if (collection && slug) { - const doc: any = await this.cmsClient.getDoc(collection, slug, { - mode: 'draft', - }); - const linkedSheet = doc?.sys?.l10nSheet; - if (linkedSheet) { - translationsDocs[docId].sys.linkedSheet = linkedSheet; + for (const translationsId of ids) { + const data = translationsDocs[translationsId]; + + // For doc-backed translations ids (e.g. `Pages/index`), move the doc's + // "l10nSheet" to the translations doc's "linkedSheet". + let linkedSheet: TranslationsLinkedSheet | undefined; + const sepIndex = translationsId.indexOf('/'); + if (sepIndex > 0) { + const collection = translationsId.slice(0, sepIndex); + const slug = translationsId.slice(sepIndex + 1); + try { + const rawDoc = await this.cmsClient.getRawDoc(collection, slug, { + mode: 'draft', + }); + linkedSheet = rawDoc?.sys?.l10nSheet; + } catch (err) { + // Tags are user-defined and may look like a doc id without matching + // an actual collection. Ignore lookup errors. + console.warn( + `[root cms] failed to look up doc for tag "${translationsId}":`, + String(err) + ); } } - } - Object.entries(translationsDocs).forEach(([translationsId, data]) => { - const len = Object.keys(data.strings).length; - console.log(`[root cms] saving ${len} string(s) to ${translationsId}...`); - this.saveTranslations(translationsId, data.strings, { - tags: data.tags || [translationsId], + const numStrings = Object.keys(data.strings).length; + console.log( + `[root cms] saving ${numStrings} string(s) to ${translationsId}...` + ); + await this.saveTranslations(translationsId, data.strings, { + tags: [translationsId], + linkedSheet: linkedSheet, + modifiedBy: 'root-cms v1 migration', }); - }); + stats.numDocs += 1; + } + return {ids, stats}; } } +/** + * Converts a multi-locale translations map to a flat single-locale map using + * a locale fallback chain. For each source string, the first locale in the + * chain with a non-empty translation wins; if no locale matches, the source + * string is returned. + * + * ``` + * const multiLocaleStrings = { + * 'one': {'en-GB': 'one!', es: 'uno'}, + * 'two': {es: 'dos'} + * }; + * translationsForLocaleV2(multiLocaleStrings, ['en-CA', 'en-GB', 'en']); + * // => + * // { + * // "one": "one!", + * // "two": "two", + * // } + * ``` + */ +export function translationsForLocaleV2( + multiLocaleStrings: MultiLocaleTranslationsMap, + fallbackLocales: Locale[] +): SingleLocaleTranslationsMap { + const singleLocaleStrings: SingleLocaleTranslationsMap = {}; + Object.entries(multiLocaleStrings).forEach(([source, translations]) => { + let translation = source; + for (const locale of fallbackLocales) { + if (translations[locale]) { + translation = translations[locale]; + break; + } + } + singleLocaleStrings[source] = translation; + }); + return singleLocaleStrings; +} + export function buildTranslationsDbPath(options: TranslationsDbPathOptions) { return TRANSLATIONS_DB_PATH_FORMAT.replace( '{project}', @@ -479,3 +697,14 @@ export function buildTranslationsLocaleDocDbPath( .replace('{id}', normalizeSlug(options.id)) .replace('{locale}', options.locale); } + +/** + * Splits an array into chunks of (up to) a given size. + */ +export function chunkArray(items: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += chunkSize) { + chunks.push(items.slice(i, i + chunkSize)); + } + return chunks; +} From b9b6fe0d89a46b9e0737f97487cd73fa3726c981 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:27:13 +0000 Subject: [PATCH 03/10] feat: auto-migrate v1 translations to the v2 TranslationsManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds migrateV1TranslationsIfNeeded(), which copies v1 translations into per-locale v2 docs and publishes them (v1 saves were immediately live), leaving the v1 data untouched as a backup. Migration state is tracked in a Projects/{p}/TranslationsManager/migration doc: after the first successful run each boot costs a single read, a Firestore transaction guards against concurrent runs, and a stale running lock expires after 10 minutes. The CMS plugin (gated on experiments.v2TranslationsManager) awaits the migration in the preBuild hook — failing the build with an ADC credentials hint — and fire-and-forgets it on dev server startup. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pw4Wrj1oL9DuVbKoXasBTE --- packages/root-cms/core/core.ts | 1 + packages/root-cms/core/plugin.ts | 43 ++++ .../core/translations-migration.test.ts | 210 ++++++++++++++++++ .../root-cms/core/translations-migration.ts | 159 +++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 packages/root-cms/core/translations-migration.test.ts create mode 100644 packages/root-cms/core/translations-migration.ts diff --git a/packages/root-cms/core/core.ts b/packages/root-cms/core/core.ts index 70994ee9a..a451abbb5 100644 --- a/packages/root-cms/core/core.ts +++ b/packages/root-cms/core/core.ts @@ -3,3 +3,4 @@ export * from './route.js'; export * from './runtime.js'; export * as schema from './schema.js'; export * from './translations-manager.js'; +export * from './translations-migration.js'; diff --git a/packages/root-cms/core/plugin.ts b/packages/root-cms/core/plugin.ts index bb6f9c9a7..80f2b06ba 100644 --- a/packages/root-cms/core/plugin.ts +++ b/packages/root-cms/core/plugin.ts @@ -781,6 +781,30 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin { */ preBuild: async (rootConfig: RootConfig) => { await writeCollectionSchemasToJson(rootConfig); + + // When the v2 translations manager is enabled, migrate v1 + // translations before the build (SSG reads translations at build + // time). A failure fails the build. + if (options.experiments?.v2TranslationsManager) { + const {migrateV1TranslationsIfNeeded} = await import( + './translations-migration.js' + ); + const cmsClient = new RootCMSClient(rootConfig); + try { + await migrateV1TranslationsIfNeeded(cmsClient, { + trigger: 'build', + }); + } catch (err) { + console.error( + '[root cms] v1 -> v2 translations migration failed. the build ' + + 'requires Firestore access when `v2TranslationsManager` is ' + + 'enabled — ensure application default credentials (ADC) are ' + + 'available, e.g. run `gcloud auth application-default login` ' + + 'or set GOOGLE_APPLICATION_CREDENTIALS.' + ); + throw err; + } + } }, }, @@ -800,6 +824,25 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin { server: Server, serverOptions: ConfigureServerOptions ) => { + // When the v2 translations manager is enabled, migrate v1 translations + // on dev server startup. Fire-and-forget so dev startup isn't blocked; + // prod servers never migrate (the migration runs at build time via the + // preBuild hook). + if ( + options.experiments?.v2TranslationsManager && + serverOptions.type === 'dev' + ) { + import('./translations-migration.js') + .then(({migrateV1TranslationsIfNeeded}) => { + const cmsClient = new RootCMSClient(serverOptions.rootConfig); + return migrateV1TranslationsIfNeeded(cmsClient, {trigger: 'dev'}); + }) + .catch((err) => { + console.error('[root cms] v1 -> v2 translations migration failed:'); + console.error(err); + }); + } + // The AI "prepare" routes can carry a moderately large body (the edit // flow sends the JSON object being edited). Use a larger limit for those // routes only; body-parser short-circuits when `req._body` is already diff --git a/packages/root-cms/core/translations-migration.test.ts b/packages/root-cms/core/translations-migration.test.ts new file mode 100644 index 000000000..7fdf36c77 --- /dev/null +++ b/packages/root-cms/core/translations-migration.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for the v1 -> v2 translations auto-migration, against the Firestore + * emulator (see translations-manager.test.ts for details on the emulator + * setup). + */ + +import crypto from 'node:crypto'; +import {getApps, initializeApp} from 'firebase-admin/app'; +import {Timestamp, getFirestore} from 'firebase-admin/firestore'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {RootCMSClient} from './client.js'; +import { + TranslationsMigrationState, + migrateV1TranslationsIfNeeded, +} from './translations-migration.js'; + +const FIREBASE_PROJECT_ID = 'rootjs-cms'; + +function getTestApp() { + const existing = getApps().find((app) => app.name === 'migration-test'); + if (existing) { + return existing; + } + return initializeApp({projectId: FIREBASE_PROJECT_ID}, 'migration-test'); +} + +let projectCounter = 0; + +function createTestCmsClient(): RootCMSClient { + const app = getTestApp(); + const db = getFirestore(app); + const projectId = `migration-test-${Date.now()}-${projectCounter++}`; + const plugin = { + name: 'root-cms', + getConfig: () => ({ + id: projectId, + firebaseConfig: { + apiKey: 'test', + authDomain: 'test', + projectId: FIREBASE_PROJECT_ID, + storageBucket: 'test', + }, + experiments: {v2TranslationsManager: true}, + }), + getFirebaseApp: () => app, + getFirestore: () => db, + }; + const rootConfig: any = { + rootDir: '/test', + i18n: {locales: ['en', 'es']}, + plugins: [plugin], + }; + return new RootCMSClient(rootConfig); +} + +function sha1(str: string) { + return crypto.createHash('sha1').update(str).digest('hex'); +} + +async function seedV1Translation( + cmsClient: RootCMSClient, + source: string, + translations: Record, + tags?: string[] +) { + const data: Record = {...translations, source}; + if (tags) { + data.tags = tags; + } + await cmsClient.db + .doc(`Projects/${cmsClient.projectId}/Translations/${sha1(source)}`) + .set(data); +} + +function migrationStateRef(cmsClient: RootCMSClient) { + return cmsClient.db.doc( + `Projects/${cmsClient.projectId}/TranslationsManager/migration` + ); +} + +describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( + 'migrateV1TranslationsIfNeeded', + () => { + let cmsClient: RootCMSClient; + + beforeEach(() => { + cmsClient = createTestCmsClient(); + }); + + it('migrates and publishes v1 translations', async () => { + await seedV1Translation(cmsClient, 'hello', {es: 'hola'}, [ + 'common', + 'Pages/index', + ]); + + const res = await migrateV1TranslationsIfNeeded(cmsClient, { + trigger: 'dev', + }); + expect(res).toEqual({status: 'complete', skipped: false}); + + // v1 translations were live, so the migrated data is published. + const tm = cmsClient.getTranslationsManager(); + const published = await tm.loadTranslations({ + ids: ['common', 'Pages/index'], + mode: 'published', + }); + expect(published.hello.es).toBe('hola'); + + // The migration state doc records completion. + const state = ( + await migrationStateRef(cmsClient).get() + ).data() as TranslationsMigrationState; + expect(state.status).toBe('complete'); + expect(state.version).toBe(1); + expect(state.stats).toEqual({numStrings: 1, numDocs: 2}); + + // The v1 data is left untouched as a backup. + const v1Doc = await cmsClient.db + .doc(`Projects/${cmsClient.projectId}/Translations/${sha1('hello')}`) + .get(); + expect(v1Doc.exists).toBe(true); + }); + + it('skips when the migration already completed', async () => { + await seedV1Translation(cmsClient, 'hello', {es: 'hola'}, ['common']); + const first = await migrateV1TranslationsIfNeeded(cmsClient); + expect(first.skipped).toBe(false); + + // Add a new v1 string; a second run should not import it. + await seedV1Translation(cmsClient, 'bye', {es: 'adios'}, ['common']); + const second = await migrateV1TranslationsIfNeeded(cmsClient); + expect(second).toEqual({status: 'complete', skipped: true}); + + const tm = cmsClient.getTranslationsManager(); + const published = await tm.loadTranslations({ + ids: ['common'], + mode: 'published', + }); + expect(published.bye).toBeUndefined(); + }); + + it('completes with empty stats when there is nothing to migrate', async () => { + const res = await migrateV1TranslationsIfNeeded(cmsClient); + expect(res).toEqual({status: 'complete', skipped: false}); + const state = ( + await migrationStateRef(cmsClient).get() + ).data() as TranslationsMigrationState; + expect(state.stats).toEqual({numStrings: 0, numDocs: 0}); + }); + + it('skips when another process holds a fresh running lock', async () => { + await seedV1Translation(cmsClient, 'hello', {es: 'hola'}, ['common']); + await migrationStateRef(cmsClient).set({ + version: 1, + status: 'running', + startedAt: Timestamp.now(), + startedBy: 'other-process', + }); + + const res = await migrateV1TranslationsIfNeeded(cmsClient); + expect(res).toEqual({status: 'running', skipped: true}); + + const tm = cmsClient.getTranslationsManager(); + const published = await tm.loadTranslations({ + ids: ['common'], + mode: 'published', + }); + expect(published).toEqual({}); + }); + + it('takes over a stale running lock', async () => { + await seedV1Translation(cmsClient, 'hello', {es: 'hola'}, ['common']); + const staleMillis = Date.now() - 11 * 60 * 1000; + await migrationStateRef(cmsClient).set({ + version: 1, + status: 'running', + startedAt: Timestamp.fromMillis(staleMillis), + startedBy: 'crashed-process', + }); + + const res = await migrateV1TranslationsIfNeeded(cmsClient); + expect(res).toEqual({status: 'complete', skipped: false}); + + const tm = cmsClient.getTranslationsManager(); + const published = await tm.loadTranslations({ + ids: ['common'], + mode: 'published', + }); + expect(published.hello.es).toBe('hola'); + }); + + it('re-runs after a failed migration', async () => { + await seedV1Translation(cmsClient, 'hello', {es: 'hola'}, ['common']); + await migrationStateRef(cmsClient).set({ + version: 1, + status: 'error', + error: 'something went wrong', + startedAt: Timestamp.now(), + }); + + const res = await migrateV1TranslationsIfNeeded(cmsClient); + expect(res).toEqual({status: 'complete', skipped: false}); + const state = ( + await migrationStateRef(cmsClient).get() + ).data() as TranslationsMigrationState; + expect(state.status).toBe('complete'); + expect(state.error).toBeUndefined(); + }); + } +); diff --git a/packages/root-cms/core/translations-migration.ts b/packages/root-cms/core/translations-migration.ts new file mode 100644 index 000000000..793969667 --- /dev/null +++ b/packages/root-cms/core/translations-migration.ts @@ -0,0 +1,159 @@ +/** + * Auto-migration of v1 translations to the v2 TranslationsManager. + * + * When the `experiments.v2TranslationsManager` flag is enabled, the CMS + * plugin runs `migrateV1TranslationsIfNeeded()` on dev server startup and + * before prod builds. The migration copies the v1 translations + * (`Projects/{p}/Translations`) into per-locale v2 docs and publishes them + * (v1 translations were live as soon as they were saved, so the migrated + * data must be live too). The v1 data is left untouched as a backup. + * + * Migration state is tracked in a + * `Projects/{p}/TranslationsManager/migration` doc (a sibling of the + * `draft`/`published` container docs) so the migration only runs once. A + * Firestore transaction guards against concurrent runs; a stale `running` + * lock expires after 10 minutes. + */ + +import {Timestamp} from 'firebase-admin/firestore'; +import type {RootCMSClient} from './client.js'; + +/** + * Bump this version to force the migration to re-run on projects that have + * already completed an older version of the migration. + */ +const MIGRATION_VERSION = 1; + +/** A `running` migration lock older than this is considered stale. */ +const STALE_LOCK_TIMEOUT_MS = 10 * 60 * 1000; + +export type TranslationsMigrationStatus = 'running' | 'complete' | 'error'; + +export interface TranslationsMigrationState { + version: number; + status: TranslationsMigrationStatus; + startedAt?: Timestamp; + startedBy?: string; + finishedAt?: Timestamp; + error?: string; + stats?: { + numStrings: number; + numDocs: number; + }; +} + +export interface MigrateV1TranslationsOptions { + /** What triggered the migration (used for logging/state). */ + trigger?: 'build' | 'dev'; +} + +export interface MigrateV1TranslationsResult { + status: TranslationsMigrationStatus; + /** + * True if the migration was skipped, either because it already completed + * or because another process holds the `running` lock. + */ + skipped: boolean; +} + +function migrationStateDbPath(projectId: string) { + return `Projects/${projectId}/TranslationsManager/migration`; +} + +/** + * Migrates v1 translations to the v2 TranslationsManager if the migration + * hasn't already run for this project. Safe to call on every dev server boot + * and build: after the first successful run, this costs a single Firestore + * read. + */ +export async function migrateV1TranslationsIfNeeded( + cmsClient: RootCMSClient, + options?: MigrateV1TranslationsOptions +): Promise { + const trigger = options?.trigger || 'build'; + const db = cmsClient.db; + const stateRef = db.doc(migrationStateDbPath(cmsClient.projectId)); + + // Fast path: a single read per boot. `complete` is the "migrated" tag. + const snapshot = await stateRef.get(); + const state = snapshot.data() as TranslationsMigrationState | undefined; + if (state?.status === 'complete' && state.version >= MIGRATION_VERSION) { + return {status: 'complete', skipped: true}; + } + + const startedAt = Timestamp.now(); + const startedBy = `root-cms (${trigger})`; + + // Claim the migration via a transaction so that only one process runs it + // (e.g. multiple devs booting dev servers at the same time). + const claimed = await db.runTransaction(async (tx) => { + const snapshot = await tx.get(stateRef); + const state = snapshot.data() as TranslationsMigrationState | undefined; + if (state?.status === 'complete' && state.version >= MIGRATION_VERSION) { + return false; + } + if (state?.status === 'running') { + const startedAt = state.startedAt?.toMillis?.() || 0; + if (Date.now() - startedAt < STALE_LOCK_TIMEOUT_MS) { + return false; + } + console.warn( + '[root cms] taking over stale translations migration lock ' + + `(started ${new Date(startedAt).toISOString()})` + ); + } + const runningState: TranslationsMigrationState = { + version: MIGRATION_VERSION, + status: 'running', + startedAt: startedAt, + startedBy: startedBy, + }; + tx.set(stateRef, runningState); + return true; + }); + if (!claimed) { + return {status: state?.status || 'running', skipped: true}; + } + + try { + const tm = cmsClient.getTranslationsManager(); + const res = await tm.importTranslationsFromV1(); + // v1 translations were live as soon as they were saved, so the migrated + // translations must be published for the site to keep serving them. + if (res.ids.length > 0) { + await tm.publishTranslationsBulk(res.ids, { + publishedBy: `root-cms migration (${trigger})`, + }); + } + const completeState: TranslationsMigrationState = { + version: MIGRATION_VERSION, + status: 'complete', + startedAt: startedAt, + startedBy: startedBy, + finishedAt: Timestamp.now(), + stats: res.stats, + }; + await stateRef.set(completeState); + if (res.ids.length > 0) { + console.log( + `[root cms] migrated ${res.stats.numStrings} v1 translation(s) into ` + + `${res.stats.numDocs} translations doc(s)` + ); + } + return {status: 'complete', skipped: false}; + } catch (err) { + const errorState: Partial = { + status: 'error', + finishedAt: Timestamp.now(), + error: String(err), + }; + // Best effort: release the lock so a subsequent boot can retry. + await stateRef.set(errorState, {merge: true}).catch((stateErr) => { + console.error( + '[root cms] failed to save translations migration error state:', + stateErr + ); + }); + throw err; + } +} From a8d12e1e78887f5e686626835351424a9888e948 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:28:44 +0000 Subject: [PATCH 04/10] feat: read v2 translations at runtime in createRoute and checks When experiments.v2TranslationsManager is enabled, createRoute's SSR handler and SSG props load translations from the v2 TranslationsManager (using the same ids as the v1 tags, honoring the draft/published mode from ?preview=true) and flatten them per locale through the i18n.fallbacks chain. The missing-translations check reads the doc's draft v2 translations the same way. Flag off keeps v1 reads unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pw4Wrj1oL9DuVbKoXasBTE --- packages/root-cms/core/checks-translations.ts | 15 +++-- packages/root-cms/core/route.ts | 62 +++++++++++++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/root-cms/core/checks-translations.ts b/packages/root-cms/core/checks-translations.ts index 69beb1eb1..330b5925a 100644 --- a/packages/root-cms/core/checks-translations.ts +++ b/packages/root-cms/core/checks-translations.ts @@ -225,11 +225,18 @@ export function translationsCheck( }; } - // Load all translations tagged to this document. + // Load all translations tagged to this document. When the v2 + // translations manager is enabled, read the doc's draft translations + // from the v2 per-locale docs instead (both shapes are maps of + // `{source: ..., [locale]: ...}` values, keyed by hash in v1 and by + // source in v2). const docId = `${collectionId}/${slug}`; - const translationsMap = await cmsClient.loadTranslations({ - tags: [docId], - }); + const translationsMap = cmsClient.isV2TranslationsEnabled() + ? await cmsClient.getTranslationsManager().loadTranslations({ + ids: [docId], + mode: 'draft', + }) + : await cmsClient.loadTranslations({tags: [docId]}); // Build a map from source string to available translations. const sourceToTranslations: Map< diff --git a/packages/root-cms/core/route.ts b/packages/root-cms/core/route.ts index f9b6f2ef4..35e519917 100644 --- a/packages/root-cms/core/route.ts +++ b/packages/root-cms/core/route.ts @@ -33,8 +33,10 @@ import { RootConfig, RouteParams, } from '@blinkk/root'; +import {resolveLocaleFallbacks} from '../shared/locale-fallbacks.js'; import {normalizeSlug} from '../shared/slug.js'; import {DocMode, RootCMSClient, translationsForLocale} from './client.js'; +import {translationsForLocaleV2} from './translations-manager.js'; export interface RootCMSDoc { /** The id of the doc, e.g. "Pages/foo-bar". */ @@ -224,8 +226,46 @@ export function createRoute(options: CreateRouteOptions): Route { return resolvePromisesMap(promisesMap); } + /** + * Loads the translations used by the route. When the v2 translations + * manager is enabled, translations are read from the v2 per-locale docs + * (using the same ids as the v1 tags); otherwise the v1 translations + * collection is used. + */ + function loadRouteTranslations( + cmsClient: RootCMSClient, + translationsTags: string[], + mode: DocMode + ): Promise> { + if (cmsClient.isV2TranslationsEnabled()) { + const tm = cmsClient.getTranslationsManager(); + return tm.loadTranslations({ids: translationsTags, mode}); + } + return cmsClient.loadTranslations({tags: translationsTags}); + } + + /** + * Flattens a loaded translations map to a source -> translation map for a + * locale, resolving the locale's fallback chain (`i18n.fallbacks`) when the + * v2 translations manager is enabled. + */ + function translationsMapForLocale( + cmsClient: RootCMSClient, + translationsMap: Record, + locale: string + ): Record { + if (cmsClient.isV2TranslationsEnabled()) { + const fallbackLocales = resolveLocaleFallbacks( + cmsClient.rootConfig.i18n, + locale + ); + return translationsForLocaleV2(translationsMap, fallbackLocales); + } + return translationsForLocale(translationsMap, locale); + } + async function generateProps(routeContext: RouteContext, locale: string) { - const {slug, mode} = routeContext; + const {slug, mode, cmsClient} = routeContext; const translationsTags = [ 'common', `${options.collection}/${normalizeSlug(slug)}`, @@ -234,12 +274,16 @@ export function createRoute(options: CreateRouteOptions): Route { const tags = options.translations(routeContext)?.tags || []; translationsTags.push(...tags); } + const siteLocales = cmsClient.rootConfig.i18n?.locales || ['en']; const [doc, translationsMap, data] = await Promise.all([ cmsClient.getDoc(options.collection, slug, { mode, }), - cmsClient.loadTranslations({tags: translationsTags}), + // Only load translations for sites that have >1 locale configured. + siteLocales.length > 1 + ? loadRouteTranslations(cmsClient, translationsTags, mode) + : Promise.resolve({}), fetchData(routeContext), ]); if (!doc) { @@ -250,7 +294,11 @@ export function createRoute(options: CreateRouteOptions): Route { return {notFound: true}; } - const translations = translationsForLocale(translationsMap, locale); + const translations = translationsMapForLocale( + cmsClient, + translationsMap, + locale + ); let props: any = {...data, locale, mode, slug, doc}; if (options.preRenderHook) { props = await options.preRenderHook(props, routeContext); @@ -305,7 +353,7 @@ export function createRoute(options: CreateRouteOptions): Route { }), // Only load translations for sites that have >1 locale configured. siteLocales.length > 1 - ? cmsClient.loadTranslations({tags: translationsTags}) + ? loadRouteTranslations(cmsClient, translationsTags, mode) : Promise.resolve({}), fetchData(routeContext), ]); @@ -408,7 +456,11 @@ export function createRoute(options: CreateRouteOptions): Route { } } - const translations = translationsForLocale(translationsMap, locale); + const translations = translationsMapForLocale( + cmsClient, + translationsMap, + locale + ); let props: any = {...data, req, locale, mode, slug, doc, country}; if (options.preRenderHook) { props = await options.preRenderHook(props, routeContext); From d6d1347202586d64d3aa7610f4868920c0bb8f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:32:41 +0000 Subject: [PATCH 05/10] feat: browser data layer for v2 translations + publish integration Adds ui/utils/translations-manager.ts (Firebase Web SDK, mirroring the core per-locale doc paths): list/load/save/publish/unpublish/delete for translations docs, batch-op helpers shared with doc publishing, and a testV2TranslationsEnabled() flag helper. Locale docs are grouped by the doc data's id field (never parsed from the ':' doc key, which is ambiguous with '--' slugs), and per-id modified/published timestamps drive a hasUnpublishedChanges status. When the flag is enabled, cmsPublishDocs() publishes each doc's draft translations in the same write batch as the doc (now with op-counted chunked commits, <=450 ops/batch, each doc + its translations kept in one chunk, and upfront lock/archive validation so chunked commits can't partially publish); the releases UI inherits this. cmsUnpublishDoc() clears the doc's published translations and cmsDeleteDoc() removes the doc's translations entirely. PublishDocModal notes that translations publish together with the doc. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pw4Wrj1oL9DuVbKoXasBTE --- .../PublishDocModal/PublishDocModal.tsx | 9 + packages/root-cms/ui/utils/doc.ts | 84 ++- .../root-cms/ui/utils/translations-manager.ts | 477 ++++++++++++++++++ 3 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 packages/root-cms/ui/utils/translations-manager.ts diff --git a/packages/root-cms/ui/components/PublishDocModal/PublishDocModal.tsx b/packages/root-cms/ui/components/PublishDocModal/PublishDocModal.tsx index 1325439c4..95cd46418 100644 --- a/packages/root-cms/ui/components/PublishDocModal/PublishDocModal.tsx +++ b/packages/root-cms/ui/components/PublishDocModal/PublishDocModal.tsx @@ -15,6 +15,7 @@ import {errorMessage} from '../../utils/notifications.js'; import {testCanPublish} from '../../utils/permissions.js'; import {extractReferenceDocIds} from '../../utils/references.js'; import {getLocalISOString} from '../../utils/time.js'; +import {testV2TranslationsEnabled} from '../../utils/translations-manager.js'; import {useAddToReleaseModal} from '../AddToReleaseModal/AddToReleaseModal.js'; import {AiSummary} from '../AiSummary/AiSummary.js'; import {DocDiffViewer} from '../DocDiffViewer/DocDiffViewer.js'; @@ -272,6 +273,14 @@ export function PublishDocModal( + {testV2TranslationsEnabled() && ( +
+ + The doc's translations will be published together with the doc. + +
+ )} +
+ ); +}; + +TranslationsManagerEditPage.AddStringInput = (props: { + onAdd: (source: string) => void; +}) => { + const [value, setValue] = useState(''); + + function submit() { + if (!value.trim()) { + return; + } + props.onAdd(value); + setValue(''); + } + + return ( +
+ { + setValue((e.target as HTMLInputElement).value); + }} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }} + /> + +
+ ); +}; diff --git a/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css new file mode 100644 index 000000000..fe696138f --- /dev/null +++ b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css @@ -0,0 +1,173 @@ +.TranslationsManagerPage { + --side-width: 280px; + background: var(--page-background); +} + +.TranslationsManagerPage__layout { + position: relative; + min-height: calc(100vh - var(--header-height)); +} + +.TranslationsManagerPage__side { + position: absolute; + top: 0; + left: 0; + bottom: 0; + overflow: auto; + width: var(--side-width); + z-index: 10; + padding-top: var(--page-padding); + padding-left: var(--page-padding); + padding-bottom: var(--page-padding); +} + +.TranslationsManagerPage__side__title { + font-size: 14px; + line-height: 1.2; + font-weight: 600; + margin-bottom: 12px; +} + +.TranslationsManagerPage__side__groups { + display: flex; + flex-direction: column; + gap: 2px; +} + +.TranslationsManagerPage__side__group { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + font-size: 13px; + line-height: 1.2; + text-align: left; + cursor: pointer; +} + +.TranslationsManagerPage__side__group:hover { + background: rgba(0, 0, 0, 0.05); +} + +.TranslationsManagerPage__side__group.active { + background: rgba(0, 0, 0, 0.08); + font-weight: 600; +} + +.TranslationsManagerPage__side__group__label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.TranslationsManagerPage__side__group__count { + font-size: 11px; + color: #868e96; +} + +.TranslationsManagerPage__main { + padding: var(--page-padding); + padding-left: calc(var(--side-width) + var(--page-padding)); +} + +.TranslationsManagerPage__main__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 16px; +} + +.TranslationsManagerPage__main__header__controls { + display: flex; + align-items: center; + gap: 10px; +} + +.TranslationsManagerPage__main__header__search { + width: 240px; +} + +.TranslationsManagerPage__main__content { + padding: 8px 0; +} + +.TranslationsManagerPage__loading, +.TranslationsManagerPage__empty { + display: flex; + align-items: center; + justify-content: center; + padding: 60px 20px; + font-size: 14px; + color: #868e96; +} + +.TranslationsManagerPage__docsList__header, +.TranslationsManagerPage__docsList__doc { + display: grid; + grid-template-columns: minmax(200px, 1fr) 90px 170px 140px 140px 44px; + align-items: center; + gap: 12px; + padding: 8px 20px; +} + +.TranslationsManagerPage__docsList__header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #868e96; + border-bottom: 1px solid #e9ecef; +} + +.TranslationsManagerPage__docsList__doc { + border-bottom: 1px solid #f1f3f5; + font-size: 13px; +} + +.TranslationsManagerPage__docsList__doc:last-child { + border-bottom: none; +} + +.TranslationsManagerPage__docsList__doc__id { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.TranslationsManagerPage__docsList__doc__id:hover { + text-decoration: underline; + text-underline-offset: 4px; +} + +.TranslationsManagerPage__docsList__doc__locales { + font-size: 12px; + color: #495057; +} + +.TranslationsManagerPage__docsList__doc__timestamp { + font-size: 12px; + color: #495057; +} + +.TranslationsManagerPage__docsList__doc__controls { + display: flex; + justify-content: flex-end; +} + +.TranslationsManagerPage__confirmText { + font-size: 14px; + line-height: 1.5; +} + +.TranslationsManagerPage__confirmText code { + background: #f1f3f5; + border-radius: 4px; + padding: 1px 5px; + font-size: 12px; +} diff --git a/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx new file mode 100644 index 000000000..7e0dfc992 --- /dev/null +++ b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx @@ -0,0 +1,374 @@ +import './TranslationsManagerPage.css'; + +import {Badge, Button, Loader, Menu, TextInput, Tooltip} from '@mantine/core'; +import {useModals} from '@mantine/modals'; +import {showNotification} from '@mantine/notifications'; +import { + IconDotsVertical, + IconFolder, + IconLanguage, + IconPencil, + IconRocket, + IconSearch, + IconTrash, + IconWorld, +} from '@tabler/icons-preact'; +import {useEffect, useMemo, useState} from 'preact/hooks'; +import {useLocation} from 'preact-iso'; +import {Heading} from '../../components/Heading/Heading.js'; +import {Surface} from '../../components/Surface/Surface.js'; +import {UserActionTooltip} from '../../components/UserActionTooltip/UserActionTooltip.js'; +import {useModalTheme} from '../../hooks/useModalTheme.js'; +import {usePageTitle} from '../../hooks/usePageTitle.js'; +import {Layout} from '../../layout/Layout.js'; +import {joinClassNames} from '../../utils/classes.js'; +import {notifyErrors} from '../../utils/notifications.js'; +import {formatDateTime, getTimeAgo} from '../../utils/time.js'; +import { + TranslationsDocSummary, + deleteTranslationsDoc, + listTranslationsDocs, + publishTranslations, +} from '../../utils/translations-manager.js'; + +/** Group used for translations ids without a `/` prefix, e.g. `common`. */ +const GLOBAL_GROUP = 'Global'; + +function getGroup(id: string): string { + const sepIndex = id.indexOf('/'); + if (sepIndex <= 0) { + return GLOBAL_GROUP; + } + return id.slice(0, sepIndex); +} + +export function TranslationsManagerPage() { + usePageTitle('Translations'); + const [loading, setLoading] = useState(true); + const [docs, setDocs] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + async function reload() { + await notifyErrors(async () => { + const docs = await listTranslationsDocs(); + setDocs(docs); + }); + setLoading(false); + } + + useEffect(() => { + reload(); + }, []); + + const groups = useMemo(() => { + const byGroup: Record = {}; + docs.forEach((doc) => { + const group = getGroup(doc.id); + byGroup[group] = (byGroup[group] || 0) + 1; + }); + return Object.entries(byGroup) + .map(([name, count]) => ({name, count})) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [docs]); + + const filteredDocs = useMemo(() => { + let filtered = docs; + if (selectedGroup) { + filtered = filtered.filter((doc) => getGroup(doc.id) === selectedGroup); + } + const query = searchQuery.trim().toLowerCase(); + if (query) { + filtered = filtered.filter((doc) => doc.id.toLowerCase().includes(query)); + } + return filtered; + }, [docs, selectedGroup, searchQuery]); + + return ( + +
+
+
+
+ Translations +
+
+ + {groups.map((group) => ( + + ))} +
+
+
+
+ + {selectedGroup + ? `Translations: ${selectedGroup}` + : 'Translations'} + +
+ } + size="xs" + value={searchQuery} + onChange={(e: Event) => { + setSearchQuery((e.target as HTMLInputElement).value); + }} + /> +
+
+ + {loading ? ( +
+ +
+ ) : filteredDocs.length === 0 ? ( +
+ {docs.length === 0 + ? 'No translations yet. Translations saved from a doc appear here.' + : 'No translations match your filters.'} +
+ ) : ( + reload()} + /> + )} +
+
+
+
+
+ ); +} + +TranslationsManagerPage.DocsList = (props: { + docs: TranslationsDocSummary[]; + reload: () => void; +}) => { + return ( +
+
+
+ ID +
+
+ Locales +
+
+ Status +
+
+ Modified +
+
+ Published +
+
+
+ {props.docs.map((doc) => ( + + ))} +
+ ); +}; + +TranslationsManagerPage.DocRow = (props: { + doc: TranslationsDocSummary; + reload: () => void; +}) => { + const doc = props.doc; + const {route} = useLocation(); + const modals = useModals(); + const modalTheme = useModalTheme(); + const editUrl = `/cms/translations/${doc.id}`; + + function onPublish() { + modals.openConfirmModal({ + ...modalTheme, + title: `Publish translations: ${doc.id}`, + children: ( +
+ Are you sure you want to publish the translations for{' '} + {doc.id}? The translations will go live immediately. +
+ ), + labels: {confirm: 'Publish', cancel: 'Cancel'}, + cancelProps: {size: 'xs'}, + confirmProps: {color: 'dark', size: 'xs'}, + onConfirm: async () => { + await notifyErrors(async () => { + await publishTranslations(doc.id); + showNotification({ + title: 'Published translations', + message: `Published translations for ${doc.id}.`, + autoClose: 10000, + }); + props.reload(); + }); + }, + }); + } + + function onDelete() { + modals.openConfirmModal({ + ...modalTheme, + title: `Delete translations: ${doc.id}`, + children: ( +
+ Are you sure you want to delete the translations for{' '} + {doc.id}? This deletes both the draft and published + translations and cannot be undone. +
+ ), + labels: {confirm: 'Delete', cancel: 'Cancel'}, + cancelProps: {size: 'xs'}, + confirmProps: {color: 'red', size: 'xs'}, + onConfirm: async () => { + await notifyErrors(async () => { + await deleteTranslationsDoc(doc.id); + showNotification({ + title: 'Deleted translations', + message: `Deleted translations for ${doc.id}.`, + autoClose: 10000, + }); + props.reload(); + }); + }, + }); + } + + return ( +
+ + {doc.id} + +
+ + {doc.locales.length} locale{doc.locales.length === 1 ? '' : 's'} + +
+
+ {doc.publishedAt ? ( + doc.hasUnpublishedChanges ? ( + + Unpublished changes + + ) : ( + + Published + + ) + ) : ( + + Draft + + )} +
+
+ {doc.modifiedAt ? ( + + {getTimeAgo(doc.modifiedAt.toMillis())} + + ) : ( + '—' + )} +
+
+ {doc.publishedAt ? ( + + {getTimeAgo(doc.publishedAt.toMillis())} + + ) : ( + '—' + )} +
+
+ + + + } + > + } + onClick={() => route(editUrl)} + > + Edit + + } onClick={onPublish}> + Publish… + + } + color="red" + onClick={onDelete} + > + Delete… + + +
+
+ ); +}; diff --git a/packages/root-cms/ui/ui.tsx b/packages/root-cms/ui/ui.tsx index ddc0a7e61..6db055e8d 100644 --- a/packages/root-cms/ui/ui.tsx +++ b/packages/root-cms/ui/ui.tsx @@ -143,6 +143,16 @@ const TranslationsArbPage = lazyRoute(() => (m) => m.TranslationsArbPage ) ); +const TranslationsManagerEditPage = lazyRoute(() => + import('./pages/TranslationsManagerEditPage/TranslationsManagerEditPage.js').then( + (m) => m.TranslationsManagerEditPage + ) +); +const TranslationsManagerPage = lazyRoute(() => + import('./pages/TranslationsManagerPage/TranslationsManagerPage.js').then( + (m) => m.TranslationsManagerPage + ) +); const TranslationsEditPage = lazyRoute(() => import('./pages/TranslationsEditPage/TranslationsEditPage.js').then( (m) => m.TranslationsEditPage @@ -254,6 +264,46 @@ declare global { } function App() { + const v2TranslationsEnabled = Boolean( + window.__ROOT_CTX.experiments?.v2TranslationsManager + ); + // NOTE: conditional route children must be arrays (not fragments) — + // preact-iso's Router only flattens arrays. + const translationsRoutes = v2TranslationsEnabled + ? [ + , + , + ] + : [ + , + , + , + , + ]; return ( - - - - + {translationsRoutes} From 515641235a995921da08d3518282b704ed06287a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:44:21 +0000 Subject: [PATCH 07/10] chore: changeset + test isolation for v2 translations manager Adds a minor changeset for @blinkk/root and @blinkk/root-cms (noting the TranslationsDoc export removal), runs the admin-SDK emulator tests against a separate emulator project id so security.test.ts's clearFirestore() can't race them, and applies lint formatting. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pw4Wrj1oL9DuVbKoXasBTE --- .changeset/lucky-poems-drive.md | 23 +++++++++++++ packages/root-cms/core/batch-request.test.ts | 2 +- packages/root-cms/core/plugin.ts | 5 ++- .../core/publish-translations.test.ts | 2 +- .../core/translations-manager.test.ts | 34 ++++++++++--------- .../core/translations-migration.test.ts | 2 +- 6 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 .changeset/lucky-poems-drive.md diff --git a/.changeset/lucky-poems-drive.md b/.changeset/lucky-poems-drive.md new file mode 100644 index 000000000..9766ae365 --- /dev/null +++ b/.changeset/lucky-poems-drive.md @@ -0,0 +1,23 @@ +--- +'@blinkk/root': minor +'@blinkk/root-cms': minor +--- + +feat: v2 translations manager (behind `experiments.v2TranslationsManager`) + +Wires up the v2 `TranslationsManager` end to end: a new `i18n.fallbacks` +config for locale fallback chains, a translations manager UI at +`/cms/translations` (list + editor) that replaces the v1 pages when the flag +is on, publishing content docs now publishes their translations in the same +write batch (plus a standalone per-doc Publish action), runtime reads in +`createRoute()` and the missing-translations check use the v2 per-locale +docs, and v1 translations are automatically copy-migrated (and published) on +dev/build boot, leaving v1 data untouched as a backup. + +BREAKING (experimental surface, no known callers): the unused +`TranslationsDoc` interface and `RootCMSClient.dbTranslationsPath()`/ +`dbTranslationsRef()` helpers were removed, and `BatchRequest`/ +`BatchResponse.translations` now uses the per-locale doc schema +(`Record>`). +`BatchResponse.getTranslations()` accepts a locale or an explicit fallback +chain and resolves `i18n.fallbacks`. diff --git a/packages/root-cms/core/batch-request.test.ts b/packages/root-cms/core/batch-request.test.ts index 451a334fb..eea5b9b84 100644 --- a/packages/root-cms/core/batch-request.test.ts +++ b/packages/root-cms/core/batch-request.test.ts @@ -9,7 +9,7 @@ import {Timestamp, getFirestore} from 'firebase-admin/firestore'; import {beforeEach, describe, expect, it} from 'vitest'; import {RootCMSClient} from './client.js'; -const FIREBASE_PROJECT_ID = 'rootjs-cms'; +const FIREBASE_PROJECT_ID = 'rootjs-cms-admin-tests'; function getTestApp() { const existing = getApps().find((app) => app.name === 'batch-test'); diff --git a/packages/root-cms/core/plugin.ts b/packages/root-cms/core/plugin.ts index 80f2b06ba..31a27e960 100644 --- a/packages/root-cms/core/plugin.ts +++ b/packages/root-cms/core/plugin.ts @@ -786,9 +786,8 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin { // translations before the build (SSG reads translations at build // time). A failure fails the build. if (options.experiments?.v2TranslationsManager) { - const {migrateV1TranslationsIfNeeded} = await import( - './translations-migration.js' - ); + const {migrateV1TranslationsIfNeeded} = + await import('./translations-migration.js'); const cmsClient = new RootCMSClient(rootConfig); try { await migrateV1TranslationsIfNeeded(cmsClient, { diff --git a/packages/root-cms/core/publish-translations.test.ts b/packages/root-cms/core/publish-translations.test.ts index 3738231e4..83a44934d 100644 --- a/packages/root-cms/core/publish-translations.test.ts +++ b/packages/root-cms/core/publish-translations.test.ts @@ -10,7 +10,7 @@ import {beforeEach, describe, expect, it} from 'vitest'; import {RootCMSClient} from './client.js'; import {TranslationsLocaleDoc} from './translations-manager.js'; -const FIREBASE_PROJECT_ID = 'rootjs-cms'; +const FIREBASE_PROJECT_ID = 'rootjs-cms-admin-tests'; function getTestApp() { const existing = getApps().find((app) => app.name === 'publish-test'); diff --git a/packages/root-cms/core/translations-manager.test.ts b/packages/root-cms/core/translations-manager.test.ts index 7f7b3dff2..b3f7fcc58 100644 --- a/packages/root-cms/core/translations-manager.test.ts +++ b/packages/root-cms/core/translations-manager.test.ts @@ -16,7 +16,7 @@ import { translationsForLocaleV2, } from './translations-manager.js'; -const FIREBASE_PROJECT_ID = 'rootjs-cms'; +const FIREBASE_PROJECT_ID = 'rootjs-cms-admin-tests'; function getTestApp() { const existing = getApps().find((app) => app.name === 'tm-test'); @@ -97,9 +97,7 @@ describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( const dbPath = `Projects/${cmsClient.projectId}/TranslationsManager/draft/Translations`; // Slashes in the translations id are normalized to `--` in doc keys. - const esDoc = await cmsClient.db - .doc(`${dbPath}/Pages--index:es`) - .get(); + const esDoc = await cmsClient.db.doc(`${dbPath}/Pages--index:es`).get(); expect(esDoc.exists).toBe(true); const esData = esDoc.data() as TranslationsLocaleDoc; expect(esData.id).toBe('Pages/index'); @@ -115,9 +113,7 @@ describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( }); expect(esData.sys.modifiedAt).toBeDefined(); - const frDoc = await cmsClient.db - .doc(`${dbPath}/Pages--index:fr`) - .get(); + const frDoc = await cmsClient.db.doc(`${dbPath}/Pages--index:fr`).get(); expect(frDoc.exists).toBe(true); expect((frDoc.data() as TranslationsLocaleDoc).strings).toEqual({ [hashStr('one')]: {source: 'one', translation: 'un'}, @@ -160,12 +156,20 @@ describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( it('loads translations by tags using array-contains-any', async () => { const tm = cmsClient.getTranslationsManager(); - await tm.saveTranslations('common', {hello: {es: 'hola'}}, { - tags: ['common'], - }); - await tm.saveTranslations('Pages/foo', {bye: {es: 'adios'}}, { - tags: ['Pages/foo'], - }); + await tm.saveTranslations( + 'common', + {hello: {es: 'hola'}}, + { + tags: ['common'], + } + ); + await tm.saveTranslations( + 'Pages/foo', + {bye: {es: 'adios'}}, + { + tags: ['Pages/foo'], + } + ); const strings = await tm.loadTranslations({ tags: ['common', 'other'], mode: 'draft', @@ -303,9 +307,7 @@ describe.skipIf(!process.env.FIRESTORE_EMULATOR_HOST)( it('migrates the linked l10n sheet from the doc', async () => { await seedV1Translation('hello', {es: 'hola'}, ['Pages/index']); await cmsClient.db - .doc( - `Projects/${cmsClient.projectId}/Collections/Pages/Drafts/index` - ) + .doc(`Projects/${cmsClient.projectId}/Collections/Pages/Drafts/index`) .set({ id: 'Pages/index', collection: 'Pages', diff --git a/packages/root-cms/core/translations-migration.test.ts b/packages/root-cms/core/translations-migration.test.ts index 7fdf36c77..a748e67ee 100644 --- a/packages/root-cms/core/translations-migration.test.ts +++ b/packages/root-cms/core/translations-migration.test.ts @@ -14,7 +14,7 @@ import { migrateV1TranslationsIfNeeded, } from './translations-migration.js'; -const FIREBASE_PROJECT_ID = 'rootjs-cms'; +const FIREBASE_PROJECT_ID = 'rootjs-cms-admin-tests'; function getTestApp() { const existing = getApps().find((app) => app.name === 'migration-test'); From d36951a55398d1e93d470084d2f335ad5d31813c Mon Sep 17 00:00:00 2001 From: Steven Le Date: Thu, 9 Jul 2026 09:47:59 -0700 Subject: [PATCH 08/10] chore: enable v2 translations manager in docs/ project --- docs/root.config.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/root.config.ts b/docs/root.config.ts index 48b3c5357..8fae135cf 100644 --- a/docs/root.config.ts +++ b/docs/root.config.ts @@ -44,14 +44,14 @@ export default defineConfig({ id: 'www', name: 'Root.js', firebaseConfig: { - apiKey: process.env.GAPI_API_KEY, + apiKey: process.env.GAPI_API_KEY!, authDomain: 'rootjs-dev.firebaseapp.com', projectId: 'rootjs-dev', storageBucket: 'rootjs-dev.appspot.com', }, gapi: { - apiKey: process.env.GAPI_API_KEY, - clientId: process.env.GAPI_CLIENT_ID, + apiKey: process.env.GAPI_API_KEY!, + clientId: process.env.GAPI_CLIENT_ID!, }, gci: true, sidebar: { @@ -125,6 +125,7 @@ export default defineConfig({ ], experiments: { taskManager: true, + v2TranslationsManager: true, }, preview: { channel: true, From dd713c0b5f86f4a524a6b0813a95ae4d8d2506fe Mon Sep 17 00:00:00 2001 From: Steven Le Date: Thu, 9 Jul 2026 10:04:30 -0700 Subject: [PATCH 09/10] chore: style polish --- .../TranslationsManagerEditPage.css | 18 ++++++++++++++---- .../TranslationsManagerPage.css | 1 + .../TranslationsManagerPage.tsx | 13 ++++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/root-cms/ui/pages/TranslationsManagerEditPage/TranslationsManagerEditPage.css b/packages/root-cms/ui/pages/TranslationsManagerEditPage/TranslationsManagerEditPage.css index 813af3a88..680550ac9 100644 --- a/packages/root-cms/ui/pages/TranslationsManagerEditPage/TranslationsManagerEditPage.css +++ b/packages/root-cms/ui/pages/TranslationsManagerEditPage/TranslationsManagerEditPage.css @@ -2,11 +2,11 @@ display: flex; flex-direction: column; position: relative; - height: calc(100vh - 48px - 60px) !important; + background: var(--page-background); } .TranslationsManagerEditPage__header { - padding: 48px 60px 30px; + padding: var(--page-padding) var(--page-padding) 20px; position: sticky; left: 0; } @@ -42,7 +42,7 @@ .TranslationsManagerEditPage__content { flex: 1; overflow: auto; - padding: 0 60px; + padding: 0 var(--page-padding); } .TranslationsManagerEditPage__content--loading { @@ -67,6 +67,7 @@ } .TranslationsManagerEditPage__Table { + background-color: #fff; border-collapse: collapse; width: 100%; } @@ -79,6 +80,15 @@ vertical-align: top; } +.TranslationsManagerEditPage__Table td { + /* Table-cell height trick: a definite (tiny) height lets the textarea's + * height: 100% resolve against the cell's *used* height. Table cells always + * expand to fit content, so the cell still grows to the tallest textarea in + * the row, and shorter/empty textareas stretch to fill it instead of + * collapsing to their one-line auto-grow min-height. */ + height: 1px; +} + .TranslationsManagerEditPage__Table__headerLabel { font-size: 12px; font-weight: 600; @@ -130,7 +140,7 @@ align-items: center; justify-content: space-between; gap: 20px; - padding: 16px 60px; + padding: 16px var(--page-padding); border-top: 1px solid #e9ecef; background: #fff; } diff --git a/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css index fe696138f..4a0c052b8 100644 --- a/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css +++ b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.css @@ -138,6 +138,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-decoration: none; } .TranslationsManagerPage__docsList__doc__id:hover { diff --git a/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx index 7e0dfc992..59e65fbcb 100644 --- a/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx +++ b/packages/root-cms/ui/pages/TranslationsManagerPage/TranslationsManagerPage.tsx @@ -1,6 +1,13 @@ import './TranslationsManagerPage.css'; -import {Badge, Button, Loader, Menu, TextInput, Tooltip} from '@mantine/core'; +import { + ActionIcon, + Badge, + Loader, + Menu, + TextInput, + Tooltip, +} from '@mantine/core'; import {useModals} from '@mantine/modals'; import {showNotification} from '@mantine/notifications'; import { @@ -346,9 +353,9 @@ TranslationsManagerPage.DocRow = (props: { position="bottom" placement="end" control={ - + } > Date: Thu, 9 Jul 2026 10:05:26 -0700 Subject: [PATCH 10/10] chore: update changeset --- .changeset/lucky-poems-drive.md | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/.changeset/lucky-poems-drive.md b/.changeset/lucky-poems-drive.md index 9766ae365..9dac5319c 100644 --- a/.changeset/lucky-poems-drive.md +++ b/.changeset/lucky-poems-drive.md @@ -1,23 +1,5 @@ --- -'@blinkk/root': minor '@blinkk/root-cms': minor --- -feat: v2 translations manager (behind `experiments.v2TranslationsManager`) - -Wires up the v2 `TranslationsManager` end to end: a new `i18n.fallbacks` -config for locale fallback chains, a translations manager UI at -`/cms/translations` (list + editor) that replaces the v1 pages when the flag -is on, publishing content docs now publishes their translations in the same -write batch (plus a standalone per-doc Publish action), runtime reads in -`createRoute()` and the missing-translations check use the v2 per-locale -docs, and v1 translations are automatically copy-migrated (and published) on -dev/build boot, leaving v1 data untouched as a backup. - -BREAKING (experimental surface, no known callers): the unused -`TranslationsDoc` interface and `RootCMSClient.dbTranslationsPath()`/ -`dbTranslationsRef()` helpers were removed, and `BatchRequest`/ -`BatchResponse.translations` now uses the per-locale doc schema -(`Record>`). -`BatchResponse.getTranslations()` accepts a locale or an explicit fallback -chain and resolves `i18n.fallbacks`. +feat: v2 translations manager with fallback chains