From fd11ff77bdb9876753657c368820251fb035d0b1 Mon Sep 17 00:00:00 2001 From: CHANDRAHARSHIT Date: Thu, 30 Oct 2025 04:05:21 +0530 Subject: [PATCH 1/2] Add context snapshot tooling and marker support Introduces a new 'review' block in i18n.json and CLI commands for capturing context manifests linking localized JSX scopes to DOM markers. The compiler now supports injecting a stable data attribute (default 'data-lingo-id') for each scope, with configurable attribute name and manifest output. Updates include schema, config, tests, and core compiler logic to support context marker injection and manifest generation for improved translator review workflows. --- i18n.json | 11 +- packages/cli/README.md | 18 +- packages/cli/src/cli/cmd/review/capture.ts | 404 ++++++++++++++++++ packages/cli/src/cli/cmd/review/index.ts | 11 + packages/cli/src/cli/index.ts | 2 + packages/compiler/src/_base.ts | 19 + .../compiler/src/jsx-scope-inject.spec.ts | 58 ++- packages/compiler/src/jsx-scope-inject.ts | 46 +- .../compiler/src/jsx-scopes-export.spec.ts | 9 + packages/compiler/src/jsx-scopes-export.ts | 16 +- packages/compiler/src/lib/lcp/index.ts | 8 + packages/compiler/src/lib/lcp/schema.ts | 6 + packages/compiler/src/utils/context-marker.ts | 16 + packages/spec/src/config.spec.ts | 6 +- packages/spec/src/config.ts | 99 ++++- 15 files changed, 716 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/cli/cmd/review/capture.ts create mode 100644 packages/cli/src/cli/cmd/review/index.ts create mode 100644 packages/compiler/src/utils/context-marker.ts 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..925e85e1c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -99,7 +99,23 @@ It fingerprints every string, caches results, and only re-translates what change --- -### 🔄 Lingo.dev CI/CD +### �️ 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..13087653d 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,7 +47,7 @@ export function jsxScopesExportMutation( Boolean(skip || false), ); - const attributesMap = getJsxAttributesMap(scope); + const attributesMap = getJsxAttributesMap(scope); const overrides = _.chain(attributesMap) .entries() .filter(([attributeKey]) => @@ -61,6 +62,19 @@ export function jsxScopesExportMutation( 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"]>; From c3bf27387198203494ec535525ed14338a861812 Mon Sep 17 00:00:00 2001 From: CHANDRAHARSHIT Date: Wed, 12 Nov 2025 21:53:47 +0530 Subject: [PATCH 2/2] Update emoji in README and refactor overrides extraction Replaces placeholder emojis with appropriate ones in the CLI README for improved clarity. Refactors the extraction of 'data-lingo-override-' attributes in jsx-scopes-export.ts to use native Object methods instead of lodash chaining, simplifying the code and removing lodash dependency for this logic. --- packages/cli/README.md | 4 ++-- packages/compiler/src/jsx-scopes-export.ts | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 925e85e1c..864cc08da 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -99,7 +99,7 @@ It fingerprints every string, caches results, and only re-translates what change --- -### �️ Context Snapshot Manifest +### 📸 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: @@ -115,7 +115,7 @@ Add per-route capture rules under the new `review` block in `i18n.json` to drive --- -### �🔄 Lingo.dev CI/CD +### 🔄 Lingo.dev CI/CD Ship perfect translations automatically. diff --git a/packages/compiler/src/jsx-scopes-export.ts b/packages/compiler/src/jsx-scopes-export.ts index 13087653d..19c4e9f87 100644 --- a/packages/compiler/src/jsx-scopes-export.ts +++ b/packages/compiler/src/jsx-scopes-export.ts @@ -47,17 +47,16 @@ 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);