From ce9d03b99def20537b2bc95e1d7f95a6f62ae8ce Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 19 Jul 2026 16:03:07 -0400 Subject: [PATCH 1/2] feat: lazy per-locale loading, frozen-lock check command, locale persistence - virtual:solid-translate/lazy + virtual:solid-translate/locale/ virtual modules: each locale dictionary becomes its own code-split chunk via dynamic import; the eager virtual:solid-translate module is unchanged - TranslationProvider accepts either the eager translations record or the lazy manifest; lazy dictionaries load asynchronously with graceful source-language fallback (never throws, never suspends) and are cached - persistLocale prop (default off) persists the active locale to localStorage with SSR/no-storage guards - solid-translate check: zero-AI CI freshness primitive comparing extracted keys+hashes+contexts against .solid-translate.lock and verifying every target locale file contains every key; --json for machine output; exit 0 fresh / 1 stale - action.yml: command: check with stale output and fail-on-stale input - ship virtual.d.ts via solid-translate/virtual so consumers stop hand-writing virtual module declarations - defer the ai import in the CLI so ai-free commands (extract, check) run without the ai package installed - fix doubled shebang in dist/cli.js that made the built CLI fail on Node Co-Authored-By: Claude Fable 5 --- README.md | 167 ++++++++++++++++++++++++++- action.yml | 50 ++++++++- package.json | 4 + src/cli.ts | 212 +++++++++++++++++++++++++++++++++++ src/context.ts | 6 +- src/index.ts | 131 ++++++++++++++++++++-- src/types.ts | 17 +++ src/vite.ts | 82 +++++++++++++- tests/check.test.ts | 156 ++++++++++++++++++++++++++ tests/lazy-provider.test.ts | 177 +++++++++++++++++++++++++++++ tests/persist-locale.test.ts | 114 +++++++++++++++++++ tests/vite-virtual.test.ts | 91 +++++++++++++++ tsconfig.json | 8 +- virtual.d.ts | 45 ++++++++ 14 files changed, 1232 insertions(+), 28 deletions(-) create mode 100644 tests/check.test.ts create mode 100644 tests/lazy-provider.test.ts create mode 100644 tests/persist-locale.test.ts create mode 100644 tests/vite-virtual.test.ts create mode 100644 virtual.d.ts diff --git a/README.md b/README.md index 10c8385..1bb8038 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ Write your app in one language. Wrap text in ``. Get translations generated a - **Auto Locale Detection** — detects from `navigator.languages` when `locale` prop is omitted - **`msg()`** — mark strings for extraction outside of JSX - **CLI Tool** — translate JSON, Markdown, and MDX files from the command line +- **`check` Command** — CI freshness gate, no AI calls (exit 1 when translations are stale) - **Vite Plugin** — build-time translation with smart change detection +- **Lazy Locale Chunks** — `virtual:solid-translate/lazy` code-splits each locale, loads on demand without suspending +- **Locale Persistence** — opt-in `persistLocale` remembers the user's choice in `localStorage` - **GitHub Action** — `omniaura/solid-translate@v1` for CI/CD translation automation - **BYOK** — use any [Vercel AI SDK](https://ai-sdk.dev/) provider (OpenRouter, OpenAI, Anthropic, Google, etc.) @@ -251,8 +254,10 @@ Root provider. Wraps your app. ```tsx @@ -260,6 +265,56 @@ Root provider. Wraps your app. ``` +`translations` accepts either the eager record from `virtual:solid-translate` +or the lazy manifest from `virtual:solid-translate/lazy` (see +[Lazy per-locale loading](#lazy-per-locale-loading)). + +With `persistLocale` enabled, the initial locale is read from `localStorage` +(when it's still a valid locale) before falling back to browser detection, +and `setLocale` writes the choice back. Storage access is guarded, so SSR +and storage-disabled environments degrade gracefully. An explicit `locale` +prop always wins. + +### Lazy per-locale loading + +By default `virtual:solid-translate` inlines every locale dictionary into +your main bundle. For large apps (thousands of strings × many locales) use +`virtual:solid-translate/lazy` instead — each locale becomes its own chunk, +fetched on demand via dynamic import: + +```tsx +import { TranslationProvider } from "solid-translate"; +import translations from "virtual:solid-translate/lazy"; + + + +; +``` + +The lazy manifest has this shape: + +```ts +{ + sourceLocale: string; + locales: string[]; // source + target locales + loaders: Record Promise>>; +} +``` + +Loading is fully non-blocking: it never throws and never triggers +``. While a locale's dictionary is in flight (or if its chunk +fails to load), `t()` and `` render the source-language text; the UI +swaps to the translated text reactively once the loader resolves. Loaded +dictionaries are cached for the session, and `availableLocales()` derives +from `locales` in the manifest. + +You can also import a single locale's dictionary directly: + +```ts +const es = await import("virtual:solid-translate/locale/es"); +es.default; // Record +``` + ### `msg()` — Shared Strings Mark strings for extraction outside of JSX. At build time, the Vite plugin extracts them. At runtime, use `t()` to translate. @@ -311,8 +366,19 @@ npx solid-translate extract # Translate everything npx solid-translate translate + +# Verify translations are fresh (CI primitive — no AI calls, no writes) +npx solid-translate check +npx solid-translate check --json # machine-readable report ``` +`check` re-extracts source strings and compares keys, content hashes, and +`context` hints against `.solid-translate.lock`, then verifies every target +locale file contains every key. Exit code 0 means everything is fresh; exit +code 1 means stale, with a report of missing/changed/orphaned keys per +locale. It needs no API key and never modifies files, so it's safe to run +on every pull request. + ### CLI Config (`solid-translate.config.json`) ```json @@ -409,10 +475,11 @@ jobs: | Input | Default | Description | |-------|---------|-------------| -| `command` | `both` | `extract`, `translate`, or `both` | +| `command` | `both` | `extract`, `translate`, `check`, or `both` | | `working-directory` | `.` | Working directory | | `commit` | `false` | Auto-commit updated translation files | | `commit-message` | `chore: update translations` | Commit message | +| `fail-on-stale` | `true` | With `command: check` — fail the step when translations are stale | | `node-version` | `22` | Node.js version | | `package-manager` | `npm` | `npm`, `bun`, `pnpm`, or `yarn` | @@ -422,11 +489,87 @@ jobs: |--------|-------------| | `changed` | `true` if translation files were updated | | `files` | Newline-separated list of changed translation files | +| `stale` | With `command: check` — `true` if translations are out of date | The action reports and commits changes for JSON, Markdown, MDX, and `.solid-translate.lock` files only. Package manifests and package manager lockfiles are intentionally excluded. #### Examples +**PR gate — fail CI when translations are stale (no API key needed):** + +```yaml +# .github/workflows/i18n-check.yml +name: i18n check + +on: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: omniaura/solid-translate@v1 + with: + command: check + package-manager: bun +``` + +`check` runs extraction only — no AI provider, no writes — so it's a fast, +deterministic gate. Set `fail-on-stale: "false"` to keep the step green and +branch on the `stale` output instead: + +```yaml + - uses: omniaura/solid-translate@v1 + id: i18n + with: + command: check + fail-on-stale: "false" + + - name: Comment on stale translations + if: steps.i18n.outputs.stale == 'true' + run: echo "Translations are stale — run 'solid-translate translate'" +``` + +**Translate on main and open a PR with the updates:** + +```yaml +# .github/workflows/translate.yml +name: Translate + +on: + push: + branches: [main] + +jobs: + translate: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: omniaura/solid-translate@v1 + id: translate + env: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + + - name: Open PR with translation updates + if: steps.translate.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + branch: chore/update-translations + title: "chore: update translations" + commit-message: "chore: update translations" + body: | + Automated translation update. + + Changed files: + ${{ steps.translate.outputs.files }} +``` + **Translate on push and commit back:** Use auto-commit only on trusted refs where `GITHUB_TOKEN` can push to the checked-out branch, such as `push` events on your own repository. For pull requests, leave `commit` disabled and use the `changed`/`files` outputs to decide whether to fail CI or open a separate update PR. @@ -518,15 +661,27 @@ This means you can safely check in all translation files. Rebuilds are free unle ## TypeScript -For the virtual module import, add to your `env.d.ts` or `vite-env.d.ts`: +Types for all the virtual modules (`virtual:solid-translate`, +`virtual:solid-translate/lazy`, and `virtual:solid-translate/locale/*`) +ship with the package. Reference them once in your `env.d.ts` or +`vite-env.d.ts`: ```ts -declare module "virtual:solid-translate" { - const translations: Record>; - export default translations; +/// +``` + +or add them to `tsconfig.json`: + +```json +{ + "compilerOptions": { + "types": ["solid-translate/virtual"] + } } ``` +No hand-written `declare module` blocks needed. + ## Comparison with General Translation (gt-react) | Feature | gt-react | solid-translate | diff --git a/action.yml b/action.yml index 60bcbae..ae54874 100644 --- a/action.yml +++ b/action.yml @@ -8,9 +8,13 @@ branding: inputs: command: - description: "Command to run: extract, translate, or both (default: both)" + description: "Command to run: extract, translate, check, or both (default: both)" required: false default: "both" + fail-on-stale: + description: "When command is check: fail the step if translations are stale (default: true)" + required: false + default: "true" working-directory: description: "Working directory (default: repo root)" required: false @@ -39,6 +43,9 @@ outputs: files: description: "Newline-separated list of changed translation files" value: ${{ steps.check-changes.outputs.files }} + stale: + description: "When command is check: whether translations are stale (true/false)" + value: ${{ steps.check.outputs.stale }} runs: using: "composite" @@ -48,10 +55,11 @@ runs: env: COMMAND: ${{ inputs.command }} COMMIT: ${{ inputs.commit }} + FAIL_ON_STALE: ${{ inputs.fail-on-stale }} PACKAGE_MANAGER: ${{ inputs.package-manager }} run: | case "$COMMAND" in - extract|translate|both) ;; + extract|translate|check|both) ;; *) echo "Invalid command: $COMMAND" >&2; exit 1 ;; esac @@ -60,6 +68,11 @@ runs: *) echo "Invalid commit value: $COMMIT" >&2; exit 1 ;; esac + case "$FAIL_ON_STALE" in + true|false) ;; + *) echo "Invalid fail-on-stale value: $FAIL_ON_STALE" >&2; exit 1 ;; + esac + case "$PACKAGE_MANAGER" in npm|bun|pnpm|yarn) ;; *) echo "Invalid package-manager: $PACKAGE_MANAGER" >&2; exit 1 ;; @@ -107,6 +120,39 @@ runs: npm) npx --yes solid-translate translate ;; esac + - name: Run check + if: inputs.command == 'check' + id: check + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + FAIL_ON_STALE: ${{ inputs.fail-on-stale }} + run: | + set +e + case "$PACKAGE_MANAGER" in + bun) bunx solid-translate check ;; + pnpm) pnpm dlx solid-translate check ;; + yarn) yarn dlx solid-translate check ;; + npm) npx --yes solid-translate check ;; + esac + status=$? + set -e + + if [ "$status" -gt 1 ]; then + echo "solid-translate check failed with exit code $status" >&2 + exit "$status" + fi + + if [ "$status" -eq 0 ]; then + echo "stale=false" >> "$GITHUB_OUTPUT" + else + echo "stale=true" >> "$GITHUB_OUTPUT" + if [ "$FAIL_ON_STALE" = "true" ]; then + exit 1 + fi + fi + - name: Check for changes id: check-changes shell: bash diff --git a/package.json b/package.json index ebbc69a..671c65f 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,14 @@ "./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" + }, + "./virtual": { + "types": "./virtual.d.ts" } }, "files": [ "dist", + "virtual.d.ts", "README.md", "LICENSE" ], diff --git a/src/cli.ts b/src/cli.ts index 02a4be3..0ccabbe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { resolve, join, dirname, relative, basename } from "node:path"; import { translateBatch, translateMarkdown } from "./translate.js"; +import { hashContent } from "./hash.js"; import { extractStringsFromSource } from "./extract.js"; import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; import type { CLIConfig } from "./types.js"; @@ -44,6 +45,11 @@ async function main() { return; } + if (command === "check") { + await runCheck(args.includes("--json")); + return; + } + console.error(`Unknown command: ${command}`); printUsage(); process.exit(1); @@ -57,6 +63,8 @@ Usage: solid-translate init Create a config file solid-translate extract Extract strings from source files solid-translate translate Translate source strings + files to target locales + solid-translate check Verify translations are up to date (no AI calls) + Exit 0 = fresh, 1 = stale. Use --json for machine output Config: solid-translate.config.json (or .js/.ts) @@ -230,7 +238,211 @@ async function runExtract() { ); } +interface CheckLocaleReport { + /** Keys present in source but absent from this locale file */ + missing: string[]; + /** Keys present in this locale file but absent from source */ + orphaned: string[]; + /** Whether the locale file exists at all */ + fileExists: boolean; +} + +interface CheckReport { + fresh: boolean; + lock: { + /** Source keys with no lock entry (never translated) */ + missing: string[]; + /** Source keys whose text or context changed since last translation */ + changed: string[]; + /** Lock entries for keys no longer in source */ + orphaned: string[]; + }; + locales: Record; +} + +/** + * CI freshness primitive: verifies the lock file and target locale files + * are up to date with the current source strings. Runs extraction only — + * no AI provider, no writes, no `ai` package needed. + */ +async function runCheck(jsonOutput: boolean) { + const config = await loadConfig(); + const root = process.cwd(); + const sourceLocale = config.sourceLocale || "en"; + const targetLocales = config.targetLocales || []; + const localesDir = resolve(config.localesDir || "./src/locales"); + const patterns = config.include || [ + "src/**/*.tsx", + "src/**/*.ts", + "src/**/*.jsx", + ]; + + // 1. Extract strings from source files (read-only) + const { glob } = await import("glob"); + const extracted: Record = {}; + const contexts: Record = {}; + for (const pattern of patterns) { + const files = await glob(pattern, { cwd: root, absolute: true }); + for (const file of files) { + try { + const code = readFileSync(file, "utf-8"); + const entries = extractStringsFromSource( + code, + relative(root, file), + ); + for (const entry of entries) { + extracted[entry.key] = entry.source; + if (entry.context) { + contexts[entry.key] = entry.context; + } + } + } catch { + // skip unreadable + } + } + } + + // 2. Effective source dict: source locale file merged with newly + // extracted keys (mirrors what `extract` would write, without writing) + const sourceFilePath = join(localesDir, `${sourceLocale}.json`); + const sourceDict: Record = {}; + if (existsSync(sourceFilePath)) { + try { + Object.assign( + sourceDict, + JSON.parse(readFileSync(sourceFilePath, "utf-8")), + ); + } catch { + // corrupted source file — treat as empty + } + } + for (const [key, value] of Object.entries(extracted)) { + if (!(key in sourceDict)) { + sourceDict[key] = value; + } + } + + // 3. Compare keys + hashes + contexts against the lock file + const lockFilePath = join(localesDir, ".solid-translate.lock"); + let lock: LockFile = { version: 1, sourceLocale, keys: {} }; + if (existsSync(lockFilePath)) { + try { + lock = JSON.parse(readFileSync(lockFilePath, "utf-8")); + } catch { + // corrupted lock — every key reports as missing + } + } + + const report: CheckReport = { + fresh: true, + lock: { missing: [], changed: [], orphaned: [] }, + locales: {}, + }; + + for (const [key, value] of Object.entries(sourceDict)) { + const entry = lock.keys[key]; + if (!entry) { + report.lock.missing.push(key); + } else if ( + entry.hash !== hashContent(value) || + (entry.context ?? undefined) !== (contexts[key] ?? undefined) + ) { + report.lock.changed.push(key); + } + } + for (const key of Object.keys(lock.keys)) { + if (!(key in sourceDict)) { + report.lock.orphaned.push(key); + } + } + + // 4. Every target locale file must contain every source key + const sourceKeys = Object.keys(sourceDict); + for (const targetLocale of targetLocales) { + const targetFilePath = join(localesDir, `${targetLocale}.json`); + let dict: Record = {}; + let fileExists = existsSync(targetFilePath); + if (fileExists) { + try { + dict = JSON.parse(readFileSync(targetFilePath, "utf-8")); + } catch { + fileExists = false; + } + } + const localeReport: CheckLocaleReport = { + missing: sourceKeys.filter((key) => !(key in dict)), + orphaned: Object.keys(dict).filter((key) => !(key in sourceDict)), + fileExists, + }; + report.locales[targetLocale] = localeReport; + } + + report.fresh = + report.lock.missing.length === 0 && + report.lock.changed.length === 0 && + report.lock.orphaned.length === 0 && + Object.values(report.locales).every( + (l) => l.missing.length === 0 && l.orphaned.length === 0, + ); + + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + } else { + printCheckReport(report, sourceLocale); + } + + process.exit(report.fresh ? 0 : 1); +} + +function printCheckReport(report: CheckReport, sourceLocale: string) { + if (report.fresh) { + console.log("Translations are up to date."); + return; + } + + console.log("Translations are stale:\n"); + + const { missing, changed, orphaned } = report.lock; + if (missing.length || changed.length || orphaned.length) { + console.log(".solid-translate.lock:"); + for (const key of missing) { + console.log(` missing: ${JSON.stringify(key)} (never translated)`); + } + for (const key of changed) { + console.log(` changed: ${JSON.stringify(key)} (text or context changed)`); + } + for (const key of orphaned) { + console.log(` orphaned: ${JSON.stringify(key)} (no longer in source)`); + } + } + + for (const [locale, localeReport] of Object.entries(report.locales)) { + if (!localeReport.missing.length && !localeReport.orphaned.length) { + continue; + } + if (!localeReport.fileExists) { + console.log(`${locale}.json: (file missing)`); + } else { + console.log(`${locale}.json:`); + } + for (const key of localeReport.missing) { + console.log(` missing: ${JSON.stringify(key)}`); + } + for (const key of localeReport.orphaned) { + console.log(` orphaned: ${JSON.stringify(key)}`); + } + } + + console.log( + `\nRun \`solid-translate translate\` to refresh ${sourceLocale} → targets.`, + ); +} + async function runTranslate() { + // Deferred so ai-free commands (extract, check) never load the `ai` package + const { translateBatch, translateMarkdown } = await import( + "./translate.js" + ); const config = await loadConfig(); const root = process.cwd(); const sourceLocale = config.sourceLocale || "en"; diff --git a/src/context.ts b/src/context.ts index dd8bd0f..a3e6328 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,5 +1,5 @@ import { createContext } from "solid-js"; -import type { Translations } from "./types.js"; +import type { TranslationsInput } from "./types.js"; // --------------------------------------------------------------------------- // Context value type @@ -16,8 +16,8 @@ export interface TranslationContextValue { sourceLocale: string; /** All available locale codes (reactive) */ availableLocales: () => string[]; - /** Raw translations object */ - translations: Translations; + /** Raw translations object (eager record or lazy manifest) */ + translations: TranslationsInput; } // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index 41173ad..fa4c91a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,14 +11,24 @@ import { type TranslationContextValue, } from "./context.js"; import { detectLocale } from "./locale-detect.js"; -import type { TranslationDictionary, Translations } from "./types.js"; +import type { + LazyTranslations, + TranslationDictionary, + Translations, + TranslationsInput, +} from "./types.js"; // --------------------------------------------------------------------------- // Re-exports // --------------------------------------------------------------------------- export type { TranslationContextValue } from "./context.js"; -export type { TranslationDictionary, Translations } from "./types.js"; +export type { + LazyTranslations, + TranslationDictionary, + Translations, + TranslationsInput, +} from "./types.js"; export type { SolidTranslatePluginConfig } from "./types.js"; export { Var, Num, Currency, DateTime, Plural, LocaleSelector } from "./components.js"; export type { @@ -44,19 +54,118 @@ export interface TranslationProviderProps { locale?: string; /** Source locale code (default: "en") */ sourceLocale?: string; - /** Translation dictionaries keyed by locale */ - translations: Translations; + /** + * Translation dictionaries keyed by locale (from `virtual:solid-translate`), + * or a lazy manifest (from `virtual:solid-translate/lazy`) whose per-locale + * dictionaries are loaded on demand via dynamic import. + */ + translations: TranslationsInput; + /** + * Persist the active locale to `localStorage` (default: false). + * When enabled, the initial locale is read from storage (if still valid) + * before falling back to browser detection, and `setLocale` writes through. + * Pass `{ key: "..." }` to customize the storage key. + */ + persistLocale?: boolean | { key?: string }; children: JSX.Element; } +const DEFAULT_PERSIST_KEY = "solid-translate:locale"; + +function isLazyTranslations( + input: TranslationsInput, +): input is LazyTranslations { + return ( + typeof input === "object" && + input !== null && + Array.isArray((input as LazyTranslations).locales) && + typeof (input as LazyTranslations).loaders === "object" && + (input as LazyTranslations).loaders !== null + ); +} + +function readPersistedLocale(key: string): string | undefined { + try { + if (typeof localStorage === "undefined") return undefined; + return localStorage.getItem(key) ?? undefined; + } catch { + // SSR / storage disabled + return undefined; + } +} + +function writePersistedLocale(key: string, locale: string): void { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem(key, locale); + } catch { + // SSR / storage disabled / quota exceeded — ignore + } +} + export function TranslationProvider(props: TranslationProviderProps) { - const sourceLocale = props.sourceLocale || "en"; - const availableLocales = createMemo(() => Object.keys(props.translations)); + const lazy = isLazyTranslations(props.translations) + ? props.translations + : undefined; + const sourceLocale = props.sourceLocale || lazy?.sourceLocale || "en"; + const availableLocales = createMemo(() => + lazy ? lazy.locales : Object.keys(props.translations), + ); - // Auto-detect locale from browser if not explicitly provided + const persistKey = props.persistLocale + ? (typeof props.persistLocale === "object" + ? props.persistLocale.key + : undefined) || DEFAULT_PERSIST_KEY + : undefined; + + // Initial locale: explicit prop > persisted value (if valid) > detection + const persisted = persistKey ? readPersistedLocale(persistKey) : undefined; + const persistedValid = + persisted !== undefined && + (persisted === sourceLocale || availableLocales().includes(persisted)); const initialLocale = - props.locale || detectLocale(availableLocales()) || sourceLocale; - const [locale, setLocale] = createSignal(initialLocale); + props.locale || + (persistedValid ? persisted : undefined) || + detectLocale(availableLocales()) || + sourceLocale; + const [locale, setLocaleSignal] = createSignal(initialLocale); + + // Lazily loaded dictionaries, keyed by locale (lazy manifest mode only). + // Loading NEVER throws or suspends — while a dictionary is in flight, + // t() falls back to the source text. + const [loadedDicts, setLoadedDicts] = createSignal({}); + const pendingLoads = new Set(); + + const loadLocale = (target: string): void => { + if (!lazy) return; + const loader = lazy.loaders[target]; + if (!loader) return; + if (target in loadedDicts() || pendingLoads.has(target)) return; + pendingLoads.add(target); + loader() + .then((dict) => { + setLoadedDicts((prev) => ({ ...prev, [target]: dict })); + }) + .catch((err) => { + console.warn( + `[solid-translate] Failed to load locale "${target}":`, + err, + ); + }) + .finally(() => { + pendingLoads.delete(target); + }); + }; + + const setLocale = (next: string): void => { + loadLocale(next); + setLocaleSignal(next); + if (persistKey) writePersistedLocale(persistKey, next); + }; + + // Kick off loading for the initial locale (no-op in eager mode, or when + // the locale has no loader — e.g. the source locale without a dict). + loadLocale(initialLocale); const t = ( key: string, @@ -66,7 +175,9 @@ export function TranslationProvider(props: TranslationProviderProps) { let text = key; // Look up in translation dictionary (works for both source and target locales) - const dict = props.translations[cur]; + const dict = lazy + ? loadedDicts()[cur] + : (props.translations as Translations)[cur]; if (dict && key in dict) { text = dict[key]!; } diff --git a/src/types.ts b/src/types.ts index c9cced8..3e3cca9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,23 @@ export type TranslationDictionary = Record; /** All translations keyed by locale code */ export type Translations = Record; +/** + * Lazy translation manifest, as exported by `virtual:solid-translate/lazy`. + * Each loader dynamically imports one locale's dictionary so it becomes its + * own chunk instead of being inlined into the main bundle. + */ +export interface LazyTranslations { + /** Source locale code */ + sourceLocale: string; + /** All available locale codes (source + targets) */ + locales: string[]; + /** Per-locale dictionary loaders (dynamic imports) */ + loaders: Record Promise>; +} + +/** Either an eager translations record or a lazy manifest */ +export type TranslationsInput = Translations | LazyTranslations; + /** Lock file entry tracking a single translation key */ export interface LockFileEntry { hash: string; diff --git a/src/vite.ts b/src/vite.ts index 1d9b3de..6edd2de 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -6,7 +6,8 @@ import { mkdirSync, readdirSync, } from "node:fs"; -import { resolve, join, relative } from "node:path"; +import { resolve, join, relative, basename } from "node:path"; +import { hashContent } from "./hash.js"; import { translateBatch } from "./translate.js"; import { extractStringsFromSource } from "./extract.js"; import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; @@ -17,6 +18,16 @@ export type { SolidTranslatePluginConfig }; const VIRTUAL_MODULE_ID = "virtual:solid-translate"; const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID; +const VIRTUAL_LAZY_MODULE_ID = "virtual:solid-translate/lazy"; +const RESOLVED_VIRTUAL_LAZY_MODULE_ID = "\0" + VIRTUAL_LAZY_MODULE_ID; + +const VIRTUAL_LOCALE_MODULE_PREFIX = "virtual:solid-translate/locale/"; +const RESOLVED_VIRTUAL_LOCALE_MODULE_PREFIX = + "\0" + VIRTUAL_LOCALE_MODULE_PREFIX; + +/** Locale codes must be simple path-safe tokens (e.g. "en", "pt-BR", "zh_Hant") */ +const LOCALE_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + /** * Vite plugin for solid-translate. * @@ -154,6 +165,15 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin { if (id === VIRTUAL_MODULE_ID) { return RESOLVED_VIRTUAL_MODULE_ID; } + if (id === VIRTUAL_LAZY_MODULE_ID) { + return RESOLVED_VIRTUAL_LAZY_MODULE_ID; + } + if (id.startsWith(VIRTUAL_LOCALE_MODULE_PREFIX)) { + const locale = id.slice(VIRTUAL_LOCALE_MODULE_PREFIX.length); + if (LOCALE_ID_PATTERN.test(locale)) { + return RESOLVED_VIRTUAL_LOCALE_MODULE_PREFIX + locale; + } + } }, load(id: string) { @@ -179,6 +199,47 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin { return `export default ${JSON.stringify(translations)};`; } + + if (id === RESOLVED_VIRTUAL_LAZY_MODULE_ID) { + // Lazy manifest: per-locale dictionaries stay out of the main bundle + // and are code-split into their own chunks via dynamic import. + const locales = [sourceLocale, ...targetLocales].filter( + (locale, i, all) => all.indexOf(locale) === i, + ); + const loaderEntries = locales + .map( + (locale) => + ` ${JSON.stringify(locale)}: () => import(${JSON.stringify( + VIRTUAL_LOCALE_MODULE_PREFIX + locale, + )}).then((m) => m.default),`, + ) + .join("\n"); + return [ + `export const sourceLocale = ${JSON.stringify(sourceLocale)};`, + `export const locales = ${JSON.stringify(locales)};`, + `export const loaders = {`, + loaderEntries, + `};`, + `export default { sourceLocale, locales, loaders };`, + ].join("\n"); + } + + if (id.startsWith(RESOLVED_VIRTUAL_LOCALE_MODULE_PREFIX)) { + // Single locale dictionary module + const locale = id.slice( + RESOLVED_VIRTUAL_LOCALE_MODULE_PREFIX.length, + ); + let dict: Record = {}; + const filePath = join(resolvedLocalesDir, `${locale}.json`); + if (existsSync(filePath)) { + try { + dict = JSON.parse(readFileSync(filePath, "utf-8")); + } catch { + // Malformed file — serve empty dict + } + } + return `export default ${JSON.stringify(dict)};`; + } }, // HMR: reload translations when locale files change @@ -187,12 +248,21 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin { file.startsWith(resolvedLocalesDir) && file.endsWith(".json") ) { - const mod = server.moduleGraph.getModuleById( + const invalidated = []; + const locale = basename(file, ".json"); + const ids = [ RESOLVED_VIRTUAL_MODULE_ID, - ); - if (mod) { - server.moduleGraph.invalidateModule(mod); - return [mod]; + RESOLVED_VIRTUAL_LOCALE_MODULE_PREFIX + locale, + ]; + for (const id of ids) { + const mod = server.moduleGraph.getModuleById(id); + if (mod) { + server.moduleGraph.invalidateModule(mod); + invalidated.push(mod); + } + } + if (invalidated.length > 0) { + return invalidated; } } }, diff --git a/tests/check.test.ts b/tests/check.test.ts new file mode 100644 index 0000000..49c3929 --- /dev/null +++ b/tests/check.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { hashContent } from "../src/hash"; + +const CLI_PATH = resolve(import.meta.dir, "../src/cli.ts"); + +const fixtures: string[] = []; + +afterAll(() => { + for (const dir of fixtures) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Write a fixture project to a temp dir */ +function makeFixture(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "st-check-")); + fixtures.push(dir); + for (const [path, content] of Object.entries(files)) { + const full = join(dir, path); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } + return dir; +} + +function runCheck(cwd: string): { exitCode: number; report: any } { + const proc = Bun.spawnSync({ + cmd: [process.execPath, CLI_PATH, "check", "--json"], + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = proc.stdout.toString(); + let report: any = null; + try { + report = JSON.parse(stdout); + } catch { + throw new Error( + `check did not print valid JSON.\nstdout: ${stdout}\nstderr: ${proc.stderr.toString()}`, + ); + } + return { exitCode: proc.exitCode, report }; +} + +const config = JSON.stringify({ + sourceLocale: "en", + targetLocales: ["es"], + localesDir: "./locales", + include: ["src/**/*.tsx"], +}); + +const freshLock = JSON.stringify({ + version: 1, + sourceLocale: "en", + keys: { + "Hello world": { + hash: hashContent("Hello world"), + source: "Hello world", + }, + }, +}); + +const baseFixture = { + "solid-translate.config.json": config, + "src/App.tsx": `export function App() {\n return Hello world;\n}\n`, + "locales/en.json": JSON.stringify({ "Hello world": "Hello world" }), + "locales/es.json": JSON.stringify({ "Hello world": "Hola mundo" }), + "locales/.solid-translate.lock": freshLock, +}; + +describe("check command", () => { + test("fresh lock and complete locale files exit 0", () => { + const dir = makeFixture(baseFixture); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(0); + expect(report.fresh).toBe(true); + expect(report.lock.missing).toEqual([]); + expect(report.lock.changed).toEqual([]); + expect(report.lock.orphaned).toEqual([]); + expect(report.locales.es.missing).toEqual([]); + expect(report.locales.es.orphaned).toEqual([]); + }); + + test("edited source string exits 1 and reports changed key", () => { + const dir = makeFixture({ + ...baseFixture, + "locales/en.json": JSON.stringify({ + "Hello world": "Hello, world!", + }), + }); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(1); + expect(report.fresh).toBe(false); + expect(report.lock.changed).toEqual(["Hello world"]); + expect(report.lock.missing).toEqual([]); + }); + + test("new source string exits 1 and reports missing key everywhere", () => { + const dir = makeFixture({ + ...baseFixture, + "src/App.tsx": `export function App() {\n return (\n <>\n Hello world\n Goodbye\n \n );\n}\n`, + }); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(1); + expect(report.fresh).toBe(false); + expect(report.lock.missing).toEqual(["Goodbye"]); + expect(report.locales.es.missing).toEqual(["Goodbye"]); + }); + + test("missing key in a target locale file exits 1", () => { + const dir = makeFixture({ + ...baseFixture, + "locales/es.json": JSON.stringify({}), + }); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(1); + expect(report.fresh).toBe(false); + expect(report.lock.missing).toEqual([]); + expect(report.lock.changed).toEqual([]); + expect(report.locales.es.missing).toEqual(["Hello world"]); + }); + + test("deleted source string reports orphaned lock and locale keys", () => { + const dir = makeFixture({ + ...baseFixture, + "src/App.tsx": `export function App() {\n return null;\n}\n`, + "locales/en.json": JSON.stringify({}), + }); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(1); + expect(report.lock.orphaned).toEqual(["Hello world"]); + expect(report.locales.es.orphaned).toEqual(["Hello world"]); + }); + + test("changed context prop reports changed key", () => { + const dir = makeFixture({ + ...baseFixture, + "src/App.tsx": `export function App() {\n return Hello world;\n}\n`, + }); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(1); + expect(report.lock.changed).toEqual(["Hello world"]); + }); + + test("missing target locale file reports every key missing", () => { + const { "locales/es.json": _es, ...withoutEs } = baseFixture; + const dir = makeFixture(withoutEs); + const { exitCode, report } = runCheck(dir); + expect(exitCode).toBe(1); + expect(report.locales.es.fileExists).toBe(false); + expect(report.locales.es.missing).toEqual(["Hello world"]); + }); +}); diff --git a/tests/lazy-provider.test.ts b/tests/lazy-provider.test.ts new file mode 100644 index 0000000..c0d09b6 --- /dev/null +++ b/tests/lazy-provider.test.ts @@ -0,0 +1,177 @@ +import { describe, test, expect } from "bun:test"; +import { createRoot, createComponent } from "solid-js"; +import { + TranslationProvider, + useTranslation, + type TranslationProviderProps, + type TranslationContextValue, + type LazyTranslations, +} from "../src/index"; + +/** Mount a TranslationProvider and capture its context value */ +function createProvider(props: Omit): { + ctx: TranslationContextValue; + dispose: () => void; +} { + let ctx!: TranslationContextValue; + const dispose = createRoot((d) => { + createComponent(TranslationProvider, { + ...props, + get children() { + ctx = useTranslation(); + return null; + }, + }); + return d; + }); + return { ctx, dispose }; +} + +/** Flush pending microtasks / loader promises */ +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function makeManifest(overrides?: Partial): { + manifest: LazyTranslations; + resolveEs: (dict: Record) => void; + esLoadCount: () => number; +} { + let resolveEs!: (dict: Record) => void; + const esPromise = new Promise>((r) => { + resolveEs = r; + }); + let loads = 0; + const manifest: LazyTranslations = { + sourceLocale: "en", + locales: ["en", "es"], + loaders: { + es: () => { + loads++; + return esPromise; + }, + }, + ...overrides, + }; + return { manifest, resolveEs, esLoadCount: () => loads }; +} + +describe("TranslationProvider with lazy manifest", () => { + test("source locale renders immediately without any dict", () => { + const { manifest } = makeManifest(); + const { ctx, dispose } = createProvider({ + locale: "en", + translations: manifest, + }); + expect(ctx.t("Hello world")).toBe("Hello world"); + expect(ctx.t("Hello {{name}}", { name: "Alice" })).toBe("Hello Alice"); + dispose(); + }); + + test("availableLocales derives from manifest.locales", () => { + const { manifest } = makeManifest(); + const { ctx, dispose } = createProvider({ + locale: "en", + translations: manifest, + }); + expect(ctx.availableLocales()).toEqual(["en", "es"]); + expect(ctx.sourceLocale).toBe("en"); + dispose(); + }); + + test("switching locale falls back to source text while pending, then swaps", async () => { + const { manifest, resolveEs } = makeManifest(); + const { ctx, dispose } = createProvider({ + locale: "en", + translations: manifest, + }); + + ctx.setLocale("es"); + // Loader still pending — never throw, never suspend, show source text + expect(ctx.locale()).toBe("es"); + expect(ctx.t("Hello world")).toBe("Hello world"); + + resolveEs({ "Hello world": "Hola mundo" }); + await tick(); + expect(ctx.t("Hello world")).toBe("Hola mundo"); + dispose(); + }); + + test("initial non-source locale starts loading on mount", async () => { + const { manifest, resolveEs, esLoadCount } = makeManifest(); + const { ctx, dispose } = createProvider({ + locale: "es", + translations: manifest, + }); + + expect(esLoadCount()).toBe(1); + expect(ctx.t("Hello world")).toBe("Hello world"); + + resolveEs({ "Hello world": "Hola mundo" }); + await tick(); + expect(ctx.t("Hello world")).toBe("Hola mundo"); + dispose(); + }); + + test("loaded dicts are cached — loader runs once per locale", async () => { + const { manifest, resolveEs, esLoadCount } = makeManifest(); + const { ctx, dispose } = createProvider({ + locale: "en", + translations: manifest, + }); + + ctx.setLocale("es"); + ctx.setLocale("es"); + resolveEs({ "Hello world": "Hola mundo" }); + await tick(); + ctx.setLocale("en"); + ctx.setLocale("es"); + expect(esLoadCount()).toBe(1); + expect(ctx.t("Hello world")).toBe("Hola mundo"); + dispose(); + }); + + test("loader failure falls back to source text without throwing", async () => { + const manifest: LazyTranslations = { + sourceLocale: "en", + locales: ["en", "es"], + loaders: { + es: () => Promise.reject(new Error("network down")), + }, + }; + const { ctx, dispose } = createProvider({ + locale: "en", + translations: manifest, + }); + + ctx.setLocale("es"); + await tick(); + expect(ctx.locale()).toBe("es"); + expect(ctx.t("Hello world")).toBe("Hello world"); + dispose(); + }); + + test("sourceLocale defaults from the manifest", () => { + const manifest: LazyTranslations = { + sourceLocale: "fr", + locales: ["fr", "es"], + loaders: { es: () => Promise.resolve({}) }, + }; + const { ctx, dispose } = createProvider({ + locale: "fr", + translations: manifest, + }); + expect(ctx.sourceLocale).toBe("fr"); + dispose(); + }); + + test("eager translations record still works unchanged", () => { + const { ctx, dispose } = createProvider({ + locale: "es", + translations: { es: { "Hello world": "Hola mundo" } }, + }); + expect(ctx.t("Hello world")).toBe("Hola mundo"); + expect(ctx.availableLocales()).toEqual(["es"]); + dispose(); + }); +}); diff --git a/tests/persist-locale.test.ts b/tests/persist-locale.test.ts new file mode 100644 index 0000000..4c0035a --- /dev/null +++ b/tests/persist-locale.test.ts @@ -0,0 +1,114 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { createRoot, createComponent } from "solid-js"; +import { + TranslationProvider, + useTranslation, + type TranslationProviderProps, + type TranslationContextValue, +} from "../src/index"; + +const DEFAULT_KEY = "solid-translate:locale"; + +function createProvider(props: Omit): { + ctx: TranslationContextValue; + dispose: () => void; +} { + let ctx!: TranslationContextValue; + const dispose = createRoot((d) => { + createComponent(TranslationProvider, { + ...props, + get children() { + ctx = useTranslation(); + return null; + }, + }); + return d; + }); + return { ctx, dispose }; +} + +const translations = { + en: { "Hello world": "Hello world" }, + es: { "Hello world": "Hola mundo" }, +}; + +describe("persistLocale", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("disabled by default — setLocale does not write storage", () => { + const { ctx, dispose } = createProvider({ translations }); + ctx.setLocale("es"); + expect(localStorage.getItem(DEFAULT_KEY)).toBeNull(); + dispose(); + }); + + test("valid persisted locale wins over detection", () => { + localStorage.setItem(DEFAULT_KEY, "es"); + const { ctx, dispose } = createProvider({ + translations, + persistLocale: true, + }); + expect(ctx.locale()).toBe("es"); + dispose(); + }); + + test("invalid persisted locale falls back to detection", () => { + localStorage.setItem(DEFAULT_KEY, "xx"); + const { ctx, dispose } = createProvider({ + translations, + persistLocale: true, + }); + expect(ctx.locale()).not.toBe("xx"); + expect(["en", "es"]).toContain(ctx.locale()); + dispose(); + }); + + test("explicit locale prop takes precedence over persisted value", () => { + localStorage.setItem(DEFAULT_KEY, "es"); + const { ctx, dispose } = createProvider({ + translations, + locale: "en", + persistLocale: true, + }); + expect(ctx.locale()).toBe("en"); + dispose(); + }); + + test("setLocale writes through to storage", () => { + const { ctx, dispose } = createProvider({ + translations, + locale: "en", + persistLocale: true, + }); + ctx.setLocale("es"); + expect(localStorage.getItem(DEFAULT_KEY)).toBe("es"); + expect(ctx.locale()).toBe("es"); + dispose(); + }); + + test("supports a custom storage key", () => { + localStorage.setItem("my-app:locale", "es"); + const { ctx, dispose } = createProvider({ + translations, + persistLocale: { key: "my-app:locale" }, + }); + expect(ctx.locale()).toBe("es"); + ctx.setLocale("en"); + expect(localStorage.getItem("my-app:locale")).toBe("en"); + expect(localStorage.getItem(DEFAULT_KEY)).toBeNull(); + dispose(); + }); + + test("persisted source locale is accepted even without a dict entry", () => { + localStorage.setItem(DEFAULT_KEY, "en"); + const { ctx, dispose } = createProvider({ + translations: { es: { "Hello world": "Hola mundo" } }, + sourceLocale: "en", + persistLocale: true, + }); + expect(ctx.locale()).toBe("en"); + dispose(); + }); +}); diff --git a/tests/vite-virtual.test.ts b/tests/vite-virtual.test.ts new file mode 100644 index 0000000..d2395fe --- /dev/null +++ b/tests/vite-virtual.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { solidTranslate } from "../src/vite"; + +const dirs: string[] = []; + +afterAll(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makePlugin(locales: Record>) { + const root = mkdtempSync(join(tmpdir(), "st-vite-")); + dirs.push(root); + const localesDir = join(root, "locales"); + mkdirSync(localesDir, { recursive: true }); + for (const [locale, dict] of Object.entries(locales)) { + writeFileSync(join(localesDir, `${locale}.json`), JSON.stringify(dict)); + } + + const plugin: any = solidTranslate({ + sourceLocale: "en", + targetLocales: ["es", "fr"], + localesDir: "./locales", + model: null as any, + }); + plugin.configResolved({ root }); + return plugin; +} + +describe("virtual modules", () => { + test("eager module resolves and inlines all locales", () => { + const plugin = makePlugin({ + en: { Hello: "Hello" }, + es: { Hello: "Hola" }, + }); + const resolved = plugin.resolveId("virtual:solid-translate"); + expect(resolved).toBe("\0virtual:solid-translate"); + const code = plugin.load(resolved); + expect(code).toContain('"es":{"Hello":"Hola"}'); + expect(code).toContain('"en":{"Hello":"Hello"}'); + }); + + test("lazy module exports manifest with per-locale dynamic imports", () => { + const plugin = makePlugin({ en: { Hello: "Hello" } }); + const resolved = plugin.resolveId("virtual:solid-translate/lazy"); + expect(resolved).toBe("\0virtual:solid-translate/lazy"); + const code = plugin.load(resolved); + expect(code).toContain('export const sourceLocale = "en";'); + expect(code).toContain('export const locales = ["en","es","fr"];'); + expect(code).toContain( + 'import("virtual:solid-translate/locale/es")', + ); + expect(code).toContain( + 'import("virtual:solid-translate/locale/fr")', + ); + expect(code).toContain( + "export default { sourceLocale, locales, loaders };", + ); + }); + + test("per-locale module serves a single dictionary", () => { + const plugin = makePlugin({ + en: { Hello: "Hello" }, + es: { Hello: "Hola" }, + }); + const resolved = plugin.resolveId("virtual:solid-translate/locale/es"); + expect(resolved).toBe("\0virtual:solid-translate/locale/es"); + const code = plugin.load(resolved); + expect(code).toBe('export default {"Hello":"Hola"};'); + }); + + test("per-locale module for a missing file serves an empty dict", () => { + const plugin = makePlugin({ en: { Hello: "Hello" } }); + const code = plugin.load("\0virtual:solid-translate/locale/fr"); + expect(code).toBe("export default {};"); + }); + + test("path-unsafe locale ids are not resolved", () => { + const plugin = makePlugin({ en: { Hello: "Hello" } }); + expect( + plugin.resolveId("virtual:solid-translate/locale/../../etc/passwd"), + ).toBeUndefined(); + expect( + plugin.resolveId("virtual:solid-translate/locale/es/extra"), + ).toBeUndefined(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9f7f345..abf6574 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,12 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "tests/**/*.tsx", + "virtual.d.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/virtual.d.ts b/virtual.d.ts new file mode 100644 index 0000000..0f28d46 --- /dev/null +++ b/virtual.d.ts @@ -0,0 +1,45 @@ +/** + * Ambient types for the solid-translate virtual modules. + * + * Add to your project via a triple-slash reference: + * + * ```ts + * /// + * ``` + * + * or in `tsconfig.json`: + * + * ```json + * { "compilerOptions": { "types": ["solid-translate/virtual"] } } + * ``` + */ + +declare module "virtual:solid-translate" { + /** All translations keyed by locale code (eager — inlined at build time) */ + const translations: Record>; + export default translations; +} + +declare module "virtual:solid-translate/lazy" { + /** Source locale code */ + export const sourceLocale: string; + /** All available locale codes (source + targets) */ + export const locales: string[]; + /** Per-locale dictionary loaders (each is its own code-split chunk) */ + export const loaders: Record< + string, + () => Promise> + >; + const manifest: { + sourceLocale: string; + locales: string[]; + loaders: Record Promise>>; + }; + export default manifest; +} + +declare module "virtual:solid-translate/locale/*" { + /** A single locale's translation dictionary */ + const dictionary: Record; + export default dictionary; +} From 4a2c4c988dac85eee10403e245be77c96c3c1e0a Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Sun, 19 Jul 2026 16:05:58 -0400 Subject: [PATCH 2/2] fix: restore LockFile type import after rebase --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 0ccabbe..66cd324 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ import { translateBatch, translateMarkdown } from "./translate.js"; import { hashContent } from "./hash.js"; import { extractStringsFromSource } from "./extract.js"; import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; -import type { CLIConfig } from "./types.js"; +import type { CLIConfig, LockFile } from "./types.js"; const CONFIG_FILENAMES = [ "solid-translate.config.json",