diff --git a/i18n.json b/i18n.json index f838bcb43..c08ad9ade 100644 --- a/i18n.json +++ b/i18n.json @@ -1,5 +1,5 @@ { - "version": "1.10", + "version": "1.11", "locale": { "source": "en", "targets": [ @@ -27,5 +27,14 @@ "include": ["readme/[locale].md"] } }, + "review": { + "attribute": "data-lingo-id", + "outputDir": ".lingo/context", + "routes": [], + "compiler": { + "sourceRoot": "src", + "lingoDir": "lingo" + } + }, "$schema": "https://lingo.dev/schema/i18n.json" } diff --git a/packages/cli/README.md b/packages/cli/README.md index 8e9e9ff1d..864cc08da 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -99,6 +99,22 @@ It fingerprints every string, caches results, and only re-translates what change --- +### 📸 Context Snapshot Manifest + +Feed your translators with real UI context. Once you've run the compiler, capture a manifest that links every localized JSX scope to its DOM marker: + +```bash +npx lingo.dev@latest review capture +``` + +The command reads `meta.json`/`dictionary.js`, outputs `.lingo/context/context-manifest.json`, and reminds you to enable `exposeContextAttribute` in your build config so `data-lingo-id` markers show up in the rendered app. + +Each manifest entry includes the compiler-provided `marker.attribute`/`marker.value` pair alongside source and translated strings, making it easy to target the exact DOM nodes when capturing context. + +Add per-route capture rules under the new `review` block in `i18n.json` to drive upcoming screenshot automation. + +--- + ### 🔄 Lingo.dev CI/CD Ship perfect translations automatically. diff --git a/packages/cli/src/cli/cmd/review/capture.ts b/packages/cli/src/cli/cmd/review/capture.ts new file mode 100644 index 000000000..4ecef68eb --- /dev/null +++ b/packages/cli/src/cli/cmd/review/capture.ts @@ -0,0 +1,404 @@ +import { Command } from "interactive-commander"; +import fs from "fs"; +import path from "path"; +import Ora from "ora"; +import { pathToFileURL } from "url"; + +import { getConfig } from "../../utils/config"; + +type Config = NonNullable>; + +type LCPScope = { + type: "element" | "attribute"; + content?: string; + hash?: string; + context?: string; + skip?: boolean; + overrides?: Record; + marker?: { + attribute: string; + value: string; + }; +}; + +type LCPFile = { + scopes?: Record; +}; + +type LCPSchema = { + version?: number | string; + files?: Record; +}; + +type DictionaryCacheEntry = { + content: Record; + hash: string; +}; + +type DictionaryCacheFile = { + entries?: Record; +}; + +type DictionaryCacheSchema = { + version?: number | string; + files?: Record; +}; + +interface ReviewCaptureOptions { + meta?: string; + output?: string; +} + +type ReviewRoute = { + path: string; + name?: string; + locales?: string[]; +}; + +type NormalizedRoute = { + path: string; + name?: string; + locales: string[]; +}; + +type ContextManifestEntry = { + id: string; + file: string; + entry: string; + type: string; + hash?: string; + skip: boolean; + source: { + locale: string; + text: string; + context?: string; + }; + translations: Record; + overrides: Record; + marker: { + attribute: string; + value: string; + }; +}; + +type ContextManifest = { + version: string; + generatedAt: string; + marker: { + attribute: string; + }; + compiler: { + sourceRoot: string; + lingoDir: string; + metaPath: string; + dictionaryPath?: string | null; + }; + locales: { + source: string; + targets: string[]; + }; + routes: NormalizedRoute[]; + entries: ContextManifestEntry[]; +}; + +const DEFAULT_MARKER_ATTRIBUTE = "data-lingo-id"; + +function resolveCompilerPaths( + config: Config, + metaOverride?: string, +): { metaPath: string; dictionaryPath: string } { + const review = config.review; + const compilerSourceRoot = review.compiler?.sourceRoot ?? "src"; + const compilerLingoDir = review.compiler?.lingoDir ?? "lingo"; + + const defaultMetaPath = path.resolve( + process.cwd(), + compilerSourceRoot, + compilerLingoDir, + "meta.json", + ); + + const metaPath = metaOverride + ? path.resolve(process.cwd(), metaOverride) + : defaultMetaPath; + + const dictionaryPath = path.join(path.dirname(metaPath), "dictionary.js"); + + return { metaPath, dictionaryPath }; +} + +async function loadDictionaryCache( + dictionaryPath: string, +): Promise { + if (!fs.existsSync(dictionaryPath)) { + return null; + } + + try { + const module = await import(pathToFileURL(dictionaryPath).href); + const dictionary = module?.default as DictionaryCacheSchema | undefined; + return dictionary ?? null; + } catch (error) { + return null; + } +} + +function normalizeRoutes( + routes: Array, + defaultLocales: string[], +): NormalizedRoute[] { + return routes + .map((route) => { + if (!route) return null; + if (typeof route === "string") { + const normalized: NormalizedRoute = { + path: route, + locales: [...defaultLocales], + }; + return normalized; + } + + if (!route.path) { + return null; + } + + const locales = route.locales ? [...route.locales] : [...defaultLocales]; + + const normalized: NormalizedRoute = { + path: route.path, + name: route.name, + locales, + }; + return normalized; + }) + .filter((route): route is NormalizedRoute => Boolean(route)); +} + +function buildManifestEntry(params: { + fallbackAttribute: string; + fileKey: string; + entryKey: string; + scope: LCPScope; + dictionary?: DictionaryCacheSchema | null; + sourceLocale: string; +}): ContextManifestEntry { + const { + fallbackAttribute, + fileKey, + entryKey, + scope, + dictionary, + sourceLocale, + } = params; + + const markerAttributeCandidate = scope.marker?.attribute ?? fallbackAttribute; + const markerValueCandidate = scope.marker?.value ?? `${fileKey}::${entryKey}`; + + const markerAttribute = markerAttributeCandidate.trim().length + ? markerAttributeCandidate.trim() + : fallbackAttribute; + const markerValue = markerValueCandidate.trim().length + ? markerValueCandidate.trim() + : `${fileKey}::${entryKey}`; + + const translations: Record = {}; + const overrides = scope.overrides ?? {}; + + if (dictionary?.files?.[fileKey]?.entries?.[entryKey]?.content) { + Object.assign( + translations, + dictionary.files[fileKey]!.entries![entryKey]!.content, + ); + } + + if (!translations[sourceLocale]) { + translations[sourceLocale] = scope.content ?? ""; + } + + return { + id: markerValue, + file: fileKey, + entry: entryKey, + type: scope.type, + hash: scope.hash, + skip: Boolean(scope.skip), + source: { + locale: sourceLocale, + text: scope.content ?? "", + context: scope.context, + }, + translations, + overrides, + marker: { + attribute: markerAttribute, + value: markerValue, + }, + }; +} + +function buildManifest(params: { + config: Config; + meta: LCPSchema; + dictionary: DictionaryCacheSchema | null; + metaPath: string; + dictionaryPath: string; + attributeName: string; +}): ContextManifest { + const { config, meta, dictionary, metaPath, dictionaryPath, attributeName } = + params; + const entries: ContextManifestEntry[] = []; + + const files = (meta.files ?? {}) as Record; + for (const [fileKey, file] of Object.entries(files)) { + const scopes = (file?.scopes ?? {}) as Record; + for (const [entryKey, scope] of Object.entries(scopes)) { + entries.push( + buildManifestEntry({ + fallbackAttribute: attributeName, + fileKey, + entryKey, + scope, + dictionary, + sourceLocale: config.locale.source, + }), + ); + } + } + + entries.sort((a, b) => a.id.localeCompare(b.id)); + + const routes = normalizeRoutes(config.review.routes ?? [], config.locale.targets); + routes.sort((a, b) => a.path.localeCompare(b.path)); + + return { + version: "1.0", + generatedAt: new Date().toISOString(), + marker: { + attribute: attributeName, + }, + compiler: { + sourceRoot: config.review.compiler?.sourceRoot ?? "src", + lingoDir: config.review.compiler?.lingoDir ?? "lingo", + metaPath: path.relative(process.cwd(), metaPath), + dictionaryPath: fs.existsSync(dictionaryPath) + ? path.relative(process.cwd(), dictionaryPath) + : null, + }, + locales: { + source: config.locale.source, + targets: config.locale.targets, + }, + routes, + entries, + }; +} + +function resolveMarkerAttribute(raw: string | undefined | null): { + name: string; + usedFallback: boolean; +} { + const trimmed = raw?.trim() ?? ""; + if (trimmed.startsWith("data-")) { + return { name: trimmed, usedFallback: false }; + } + + return { name: DEFAULT_MARKER_ATTRIBUTE, usedFallback: Boolean(trimmed) }; +} + +export default new Command() + .command("capture") + .description( + "Generate a context manifest that links compiler scopes to DOM markers for screenshot tooling.", + ) + .helpOption("-h, --help", "Show help") + .option( + "--meta ", + "Override the path to meta.json (defaults to //meta.json)", + ) + .option( + "--output ", + "Directory to write the context manifest (defaults to review.outputDir)", + ) + .action(async function capture(options: ReviewCaptureOptions) { + const ora = Ora(); + + try { + ora.start("Loading i18n configuration..."); + const config = getConfig(); + if (!config) { + ora.fail("i18n.json not found. Please run `lingo.dev init` first."); + process.exit(1); + } + ora.succeed("Configuration loaded"); + + const { metaPath, dictionaryPath } = resolveCompilerPaths( + config, + options.meta, + ); + + const { name: attributeName, usedFallback } = resolveMarkerAttribute( + config.review.attribute, + ); + + ora.start("Reading compiler metadata..."); + if (!fs.existsSync(metaPath)) { + ora.fail( + `meta.json not found at ${path.relative(process.cwd(), metaPath)}. Run your build (next build / vite build) to regenerate compiler artifacts, or provide --meta .`, + ); + process.exit(1); + } + + const metaContent = fs.readFileSync(metaPath, "utf8"); + const meta = JSON.parse(metaContent) as LCPSchema; + ora.succeed("Compiler metadata loaded"); + + ora.start("Loading dictionary cache..."); + const dictionary = await loadDictionaryCache(dictionaryPath); + if (dictionary) { + ora.succeed("Dictionary cache loaded"); + } else { + ora.warn( + "dictionary.js not found or failed to load. Continuing without cached translations.", + ); + } + + const manifest = buildManifest({ + config, + meta, + dictionary, + metaPath, + dictionaryPath, + attributeName, + }); + + const outputDir = options.output + ? path.resolve(process.cwd(), options.output) + : path.resolve(process.cwd(), config.review.outputDir ?? ".lingo/context"); + const outputPath = path.join(outputDir, "context-manifest.json"); + + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2)); + + if (manifest.entries.length === 0) { + ora.warn( + "No compiler scopes were found in meta.json. Run your framework build with the Lingo.dev compiler enabled to generate scope metadata.", + ); + } + + ora.succeed( + `Context manifest created with ${manifest.entries.length} entries → ${path.relative(process.cwd(), outputPath)}`, + ); + if (usedFallback) { + ora.warn( + `Configured review.attribute must start with "data-". Falling back to ${DEFAULT_MARKER_ATTRIBUTE}. Update i18n.json to avoid this warning.`, + ); + } + ora.info( + `Ensure your compiler config sets exposeContextAttribute: true so ${manifest.marker.attribute} markers are emitted in rendered HTML.`, + ); + } catch (error) { + const err = error as Error; + ora.fail(err.message); + process.exit(1); + } + }); diff --git a/packages/cli/src/cli/cmd/review/index.ts b/packages/cli/src/cli/cmd/review/index.ts new file mode 100644 index 000000000..abb78df99 --- /dev/null +++ b/packages/cli/src/cli/cmd/review/index.ts @@ -0,0 +1,11 @@ +import { Command } from "interactive-commander"; + +import captureCmd from "./capture"; + +export default new Command() + .command("review") + .description( + "Context snapshot utilities for building the translator review portal.", + ) + .helpOption("-h, --help", "Show help") + .addCommand(captureCmd); diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 151b5c6b7..78ebc3ff9 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -17,6 +17,7 @@ import cleanupCmd from "./cmd/cleanup"; import mcpCmd from "./cmd/mcp"; import ciCmd from "./cmd/ci"; import statusCmd from "./cmd/status"; +import reviewCmd from "./cmd/review"; import mayTheFourthCmd from "./cmd/may-the-fourth"; import packageJson from "../../package.json"; import run from "./cmd/run"; @@ -59,6 +60,7 @@ Star the the repo :) https://github.com/LingoDotDev/lingo.dev .addCommand(mcpCmd) .addCommand(ciCmd) .addCommand(statusCmd) + .addCommand(reviewCmd) .addCommand(mayTheFourthCmd, { hidden: true }) .addCommand(run) .addCommand(purgeCmd) diff --git a/packages/compiler/src/_base.ts b/packages/compiler/src/_base.ts index 276b214f2..1dc7c6dc7 100644 --- a/packages/compiler/src/_base.ts +++ b/packages/compiler/src/_base.ts @@ -63,6 +63,23 @@ export type CompilerParams = { * @default false */ debug: boolean; + /** + * When `true`, the compiler will inject a stable DOM data attribute on every + * localized JSX scope so that downstream tooling (like the context capture + * pipeline) can discover strings at runtime. + * + * @default false + */ + exposeContextAttribute: boolean; + /** + * The name of the DOM data attribute that will be injected when + * `exposeContextAttribute` is enabled. Use a `data-*` attribute to avoid + * interfering with user props. Values that do not start with `data-` fall + * back to `data-lingo-id`. + * + * @default "data-lingo-id" + */ + contextAttributeName: string; /** * The model(s) to use for translation. * @@ -202,6 +219,8 @@ export const defaultParams: CompilerParams = { rsc: false, useDirective: false, debug: false, + exposeContextAttribute: false, + contextAttributeName: "data-lingo-id", models: {}, prompt: null, }; diff --git a/packages/compiler/src/jsx-scope-inject.spec.ts b/packages/compiler/src/jsx-scope-inject.spec.ts index e33137b9f..9920e5d8e 100644 --- a/packages/compiler/src/jsx-scope-inject.spec.ts +++ b/packages/compiler/src/jsx-scope-inject.spec.ts @@ -5,8 +5,12 @@ import * as parser from "@babel/parser"; import generate from "@babel/generator"; // Helper function to run mutation and get result -function runMutation(code: string, rsc = false) { - const params = { ...defaultParams, rsc }; +function runMutation( + code: string, + rsc = false, + paramsOverride?: Partial, +) { + const params = { ...defaultParams, ...(paramsOverride ?? {}), rsc }; const input = createPayload({ code, params, relativeFilePath: "test" }); const mutated = lingoJsxScopeInjectMutation(input); if (!mutated) throw new Error("Mutation returned null"); @@ -153,6 +157,56 @@ function Component() { const result = runMutation(input); expect(normalizeCode(result)).toBe(normalizeCode(expected)); }); + + it("should inject context marker attribute when enabled", () => { + const input = ` +function Component() { + return
+

Hello world!

+
; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
+ +
; +} +`.trim(); + + const result = runMutation(input, false, { + exposeContextAttribute: true, + }); + + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); + + it("should preserve an existing context attribute value", () => { + const input = ` +function Component() { + return
+

Hello world!

+
; +} +`.trim(); + + const expected = ` +import { LingoComponent } from "lingo.dev/react/client"; +function Component() { + return
+ +
; +} +`.trim(); + + const result = runMutation(input, false, { + exposeContextAttribute: true, + }); + + expect(normalizeCode(result)).toBe(normalizeCode(expected)); + }); }); describe("variables", () => { diff --git a/packages/compiler/src/jsx-scope-inject.ts b/packages/compiler/src/jsx-scope-inject.ts index d1003a68a..733d45a91 100644 --- a/packages/compiler/src/jsx-scope-inject.ts +++ b/packages/compiler/src/jsx-scope-inject.ts @@ -3,6 +3,7 @@ import { getJsxAttributeValue, getModuleExecutionMode, getOrCreateImport, + setJsxAttributeValue, } from "./utils"; import * as t from "@babel/types"; import _ from "lodash"; @@ -12,7 +13,11 @@ import { getJsxVariables } from "./utils/jsx-variables"; import { getJsxFunctions } from "./utils/jsx-functions"; import { getJsxExpressions } from "./utils/jsx-expressions"; import { collectJsxScopes, getJsxScopeAttribute } from "./utils/jsx-scope"; -import { setJsxAttributeValue } from "./utils/jsx-attribute"; +import { + DEFAULT_CONTEXT_ATTRIBUTE, + resolveContextAttributeName, +} from "./utils/context-marker"; +const invalidAttributeNameWarning = { value: false }; export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => { const mode = getModuleExecutionMode(payload.ast, payload.params.rsc); @@ -54,6 +59,8 @@ export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => { node: newNode, } as any; + const entryKey = getJsxScopeAttribute(jsxScope)!; + // Add $as prop const as = /^[A-Z]/.test(originalJsxElementName) ? t.identifier(originalJsxElementName) @@ -64,11 +71,38 @@ export const lingoJsxScopeInjectMutation = createCodeMutation((payload) => { setJsxAttributeValue(newNodePath, "$fileKey", payload.relativeFilePath); // Add $entryKey prop - setJsxAttributeValue( - newNodePath, - "$entryKey", - getJsxScopeAttribute(jsxScope)!, - ); + setJsxAttributeValue(newNodePath, "$entryKey", entryKey); + + if (payload.params.exposeContextAttribute) { + const { name: attributeName, usedFallback } = resolveContextAttributeName( + payload.params.contextAttributeName, + ); + + if (usedFallback && !invalidAttributeNameWarning.value) { + invalidAttributeNameWarning.value = true; + console.warn( + `⚠️ Lingo.dev: contextAttributeName must start with "data-". Using "${DEFAULT_CONTEXT_ATTRIBUTE}" instead.`, + ); + } + + const existingValue = getJsxAttributeValue(newNodePath, attributeName); + const existingString = + typeof existingValue === "string" ? existingValue.trim() : ""; + const markerValue = + existingString.length > 0 + ? existingString + : `${payload.relativeFilePath}::${entryKey}`; + + const shouldSetMarker = + existingValue === undefined || + existingValue === null || + typeof existingValue !== "string" || + existingString.length === 0; + + if (shouldSetMarker) { + setJsxAttributeValue(newNodePath, attributeName, markerValue); + } + } // Extract $variables from original JSX scope before lingo component was inserted const $variables = getJsxVariables(jsxScope); diff --git a/packages/compiler/src/jsx-scopes-export.spec.ts b/packages/compiler/src/jsx-scopes-export.spec.ts index de03a1436..3f490dbab 100644 --- a/packages/compiler/src/jsx-scopes-export.spec.ts +++ b/packages/compiler/src/jsx-scopes-export.spec.ts @@ -11,6 +11,7 @@ vi.mock("./lib/lcp", () => { setScopeSkip: vi.fn().mockReturnThis(), setScopeOverrides: vi.fn().mockReturnThis(), setScopeContent: vi.fn().mockReturnThis(), + setScopeMarker: vi.fn().mockReturnThis(), save: vi.fn(), }; const getInstance = vi.fn(() => instance); @@ -51,6 +52,14 @@ export default function X(){ "0/declaration/body/0/argument", "Foobar", ); + expect(inst.setScopeMarker).toHaveBeenCalledWith( + "src/App.tsx", + "0/declaration/body/0/argument", + { + attribute: "data-lingo-id", + value: "src/App.tsx::0/declaration/body/0/argument", + }, + ); expect(inst.save).toHaveBeenCalled(); }); }); diff --git a/packages/compiler/src/jsx-scopes-export.ts b/packages/compiler/src/jsx-scopes-export.ts index 6e375e54b..19c4e9f87 100644 --- a/packages/compiler/src/jsx-scopes-export.ts +++ b/packages/compiler/src/jsx-scopes-export.ts @@ -6,6 +6,7 @@ import { getJsxElementHash } from "./utils/hash"; import { getJsxAttributesMap } from "./utils/jsx-attribute"; import { extractJsxContent } from "./utils/jsx-content"; import { collectJsxScopes } from "./utils/jsx-scope"; +import { resolveContextAttributeName } from "./utils/context-marker"; import { CompilerPayload } from "./_base"; // Processes only JSX element scopes @@ -46,21 +47,33 @@ export function jsxScopesExportMutation( Boolean(skip || false), ); - const attributesMap = getJsxAttributesMap(scope); - const overrides = _.chain(attributesMap) - .entries() - .filter(([attributeKey]) => - attributeKey.startsWith("data-lingo-override-"), - ) + const attributesMap: Record = getJsxAttributesMap(scope); + const overrides = Object.entries(attributesMap) + .filter(([attributeKey]) => attributeKey.startsWith("data-lingo-override-")) .map(([k, v]) => [k.split("data-lingo-override-")[1], v]) .filter(([k]) => !!k) .filter(([, v]) => !!v) - .fromPairs() - .value(); + .reduce((acc: Record, [k, v]) => { + acc[String(k)] = v; + return acc; + }, {} as Record); lcp.setScopeOverrides(payload.relativeFilePath, scopeKey, overrides); const content = extractJsxContent(scope); lcp.setScopeContent(payload.relativeFilePath, scopeKey, content); + + const { name: attributeName } = resolveContextAttributeName( + payload.params.contextAttributeName, + ); + const attributeValue = attributesMap[attributeName]; + const markerValue = + typeof attributeValue === "string" && attributeValue.trim().length > 0 + ? attributeValue.trim() + : `${payload.relativeFilePath}::${scopeKey}`; + lcp.setScopeMarker(payload.relativeFilePath, scopeKey, { + attribute: attributeName, + value: markerValue, + }); } lcp.save(); diff --git a/packages/compiler/src/lib/lcp/index.ts b/packages/compiler/src/lib/lcp/index.ts index 5d663f37d..96eeaf943 100644 --- a/packages/compiler/src/lib/lcp/index.ts +++ b/packages/compiler/src/lib/lcp/index.ts @@ -144,6 +144,14 @@ export class LCP { return this._setScopeField(fileKey, scopeKey, "content", content); } + setScopeMarker( + fileKey: string, + scopeKey: string, + marker: { attribute: string; value: string }, + ): this { + return this._setScopeField(fileKey, scopeKey, "marker", marker); + } + toJSON() { const files = _(this.data?.files) .mapValues((file: any, fileName: string) => { diff --git a/packages/compiler/src/lib/lcp/schema.ts b/packages/compiler/src/lib/lcp/schema.ts index 21a4fc1a4..a8c45714f 100644 --- a/packages/compiler/src/lib/lcp/schema.ts +++ b/packages/compiler/src/lib/lcp/schema.ts @@ -9,6 +9,12 @@ export const lcpScope = z.object({ context: z.string().optional(), skip: z.boolean().optional(), overrides: z.record(z.string(), z.string()).optional(), + marker: z + .object({ + attribute: z.string(), + value: z.string(), + }) + .optional(), }); export type LCPScope = z.infer; diff --git a/packages/compiler/src/utils/context-marker.ts b/packages/compiler/src/utils/context-marker.ts new file mode 100644 index 000000000..f925b43c4 --- /dev/null +++ b/packages/compiler/src/utils/context-marker.ts @@ -0,0 +1,16 @@ +export const DEFAULT_CONTEXT_ATTRIBUTE = "data-lingo-id"; + +export function resolveContextAttributeName( + configuredName?: string | null, +): { name: string; usedFallback: boolean } { + const trimmed = configuredName?.trim() ?? ""; + + if (trimmed.startsWith("data-")) { + return { name: trimmed, usedFallback: false }; + } + + return { + name: DEFAULT_CONTEXT_ATTRIBUTE, + usedFallback: Boolean(trimmed), + }; +} diff --git a/packages/spec/src/config.spec.ts b/packages/spec/src/config.spec.ts index 1fdc665fb..5f552c8e9 100644 --- a/packages/spec/src/config.spec.ts +++ b/packages/spec/src/config.spec.ts @@ -78,6 +78,7 @@ describe("I18n Config Parser", () => { expect(result.version).toBe(LATEST_CONFIG_DEFINITION.defaultValue.version); expect(result.locale).toEqual(defaultConfig.locale); expect(result.buckets).toEqual({}); + expect(result.review).toEqual(defaultConfig.review); }); it("should upgrade v1 config to latest version", () => { @@ -95,6 +96,7 @@ describe("I18n Config Parser", () => { include: ["src/blog/[locale]/*.md"], }, }); + expect(result.review).toEqual(defaultConfig.review); }); it("should handle empty config and use defaults", () => { @@ -112,7 +114,9 @@ describe("I18n Config Parser", () => { const result = parseI18nConfig(configWithExtra); expect(result).not.toHaveProperty("extraField"); - expect(result).toEqual(createV1_4Config()); + expect(result.locale).toEqual(createV1_4Config().locale); + expect(result.buckets).toEqual(createV1_4Config().buckets); + expect(result.review).toEqual(defaultConfig.review); }); it("should throw an error for unsupported locales", () => { diff --git a/packages/spec/src/config.ts b/packages/spec/src/config.ts index 34ab0aaa8..6ad214be5 100644 --- a/packages/spec/src/config.ts +++ b/packages/spec/src/config.ts @@ -485,8 +485,105 @@ export const configV1_10Definition = extendConfigDefinition( }, ); +// v1.10 -> v1.11 +// Changes: Add optional "review" section used by the context snapshot tooling +const reviewRouteSchema = Z.union([ + Z.string(), + Z.object({ + path: Z.string().describe( + "Route path or absolute URL to capture for visual review recordings.", + ), + name: Z.string() + .optional() + .describe("Optional human-readable route label used in reports."), + locales: Z.array(localeCodeSchema) + .optional() + .describe( + "Override the locales to capture for this specific route. Defaults to global review locales.", + ), + }), +]).describe( + "A route definition (string shorthand or object) to capture during context review runs.", +); + +const reviewCompilerSchema = Z.object({ + sourceRoot: Z.string() + .default("src") + .describe( + "Path to the compiler source root (Next.js app/, src/, etc.) relative to the repository root.", + ), + lingoDir: Z.string() + .default("lingo") + .describe( + "Directory inside the source root where Lingo compiler artifacts (meta.json, dictionary.js) are stored.", + ), +}).describe("Compiler output location used by the context review tooling."); + +type ReviewRoute = + | string + | { + path: string; + name?: string; + locales?: string[]; + }; + +const createDefaultReviewConfig = () => ({ + attribute: "data-lingo-id", + outputDir: ".lingo/context", + routes: [] as ReviewRoute[], + compiler: { + sourceRoot: "src", + lingoDir: "lingo", + }, +}); + +const reviewSchema = Z.object({ + attribute: Z.string() + .default("data-lingo-id") + .describe( + "Name of the DOM data attribute injected by the compiler for context markers. Must begin with 'data-' or it will fall back to 'data-lingo-id'.", + ), + outputDir: Z.string() + .default(".lingo/context") + .describe( + "Output directory (relative to the repo root) where review artifacts are written.", + ), + routes: Z.array(reviewRouteSchema) + .default([]) + .describe( + "List of routes or absolute URLs to crawl when capturing visual/string context.", + ), + compiler: reviewCompilerSchema.default({ + sourceRoot: "src", + lingoDir: "lingo", + }), +}).describe("Configuration for the context snapshot review tooling."); + +export const configV1_11Definition = extendConfigDefinition( + configV1_10Definition, + { + createSchema: (baseSchema) => + baseSchema.extend({ + review: reviewSchema + .default(createDefaultReviewConfig()) + .describe("Context snapshot configuration block."), + }), + createDefaultValue: (baseDefaultValue) => ({ + ...baseDefaultValue, + version: "1.11", + review: createDefaultReviewConfig(), + }), + createUpgrader: (oldConfig) => ({ + ...oldConfig, + version: "1.11", + review: + (oldConfig as any).review ?? createDefaultReviewConfig(), + }), + }, +); + // exports -export const LATEST_CONFIG_DEFINITION = configV1_10Definition; +export const LATEST_CONFIG_DEFINITION = configV1_11Definition; export type I18nConfig = Z.infer<(typeof LATEST_CONFIG_DEFINITION)["schema"]>;