From 67603cc60b8aa1f2a093264234ae936e04978ded Mon Sep 17 00:00:00 2001 From: David Dakhovich Date: Wed, 29 Jul 2026 22:06:57 +0300 Subject: [PATCH 1/2] fix: support TypeScript 7 and Bun for locale file transpilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript 7 (the Go-based compiler) no longer ships the JavaScript compiler API: 'ts.createProgram' does not exist and the package's main entry only exposes version information. The generator now resolves a transpilation strategy at runtime: 1. Bun: locale files are bundled with the native 'Bun.build' API — no 'typescript' installation needed at all 2. TypeScript <= 6: the existing 'ts.createProgram' path, now behind a lazy import with feature detection 3. TypeScript 7: the installed package's 'tsc' CLI is spawned with the same compiler flags (emit still happens despite '--noLib' type errors, so the exit code is ignored) The TypeScript version used for feature-gating the generated syntax is detected lazily as well ('versionMajorMinor', then the package.json version, then a modern default), so a missing or API-less 'typescript' package no longer crashes the CLI, importer or exporter at import time. 'typescript' is now an optional peer dependency for Bun-only setups. Also fixes a missing 'await' in 'detectLocationOfCompiledBaseTranslation' that made its early-return dead code. Ref: https://github.com/ivanhofer/typesafe-i18n/issues/794 Co-Authored-By: Claude Fable 5 --- package.json | 6 + packages/exporter/src/exporter.mts | 5 +- packages/generator/README.md | 4 + packages/generator/src/generator.mts | 10 +- .../generator/src/parse-language-file.mts | 125 ++++++++++++++++-- .../generator/src/utils/generator.utils.mts | 4 +- .../generator/src/utils/typescript.utils.mts | 55 ++++++++ .../src/utils/typescript.utils.test.ts | 24 ++++ packages/importer/src/importer.mts | 6 +- pnpm-lock.yaml | 15 +++ 10 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 packages/generator/src/utils/typescript.utils.mts create mode 100644 packages/generator/src/utils/typescript.utils.test.ts diff --git a/package.json b/package.json index 7561e5f6..4f31faba 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ }, "devDependencies": { "@size-limit/preset-small-lib": "^8.2.6", + "@types/bun": "^1.3.14", "@types/glob": "^8.1.0", "@types/node": "^18.17.3", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -167,6 +168,11 @@ "peerDependencies": { "typescript": ">=3.5.1" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "bin": { "typesafe-i18n": "cli/typesafe-i18n.mjs" }, diff --git a/packages/exporter/src/exporter.mts b/packages/exporter/src/exporter.mts index b5c421b3..ea3c7b4d 100644 --- a/packages/exporter/src/exporter.mts +++ b/packages/exporter/src/exporter.mts @@ -1,13 +1,12 @@ import fs from 'fs/promises' import { resolve } from 'path' -import ts from 'typescript' import { getConfigWithDefaultValues } from '../../config/src/config.mjs' import type { GeneratorConfigWithDefaultValues, OutputFormats } from '../../config/src/types.mjs' import { configureOutputHandler } from '../../generator/src/output-handler.mjs' import { parseLanguageFile } from '../../generator/src/parse-language-file.mjs' -import { parseTypescriptVersion } from '../../generator/src/utils/generator.utils.mjs' import { createLogger } from '../../generator/src/utils/logger.mjs' import { findAllNamespacesForLocale } from '../../generator/src/utils/namespaces.utils.mjs' +import { getTypescriptVersion } from '../../generator/src/utils/typescript.utils.mjs' import type { BaseTranslation, ExportLocaleMapping, Locale } from '../../runtime/src/core.mjs' import { getAllLocales } from '../../shared/src/file.utils.mjs' @@ -18,7 +17,7 @@ const logger = createLogger(console, true) const setup = async (): Promise => { const config = await getConfigWithDefaultValues() - const version = parseTypescriptVersion(ts.versionMajorMinor) + const version = await getTypescriptVersion() configureOutputHandler(config, version) return config diff --git a/packages/generator/README.md b/packages/generator/README.md index 097b1eaf..ff180972 100644 --- a/packages/generator/README.md +++ b/packages/generator/README.md @@ -20,6 +20,10 @@ Make sure you have installed `node` version `> 12.x` and are using a `typescript > The generator will create a different output depending on your TypeScript version. Older versions don't support all the features `typesafe-i18n` need to provide you with the best types. Make sure to use a TypeScript version `> 4.1.x` to benefit from all the typechecking features. +TypeScript 7 (the Go-based compiler) is supported as well: since it no longer ships the JavaScript compiler API, the generator automatically falls back to invoking the `tsc` CLI of your installed `typescript` package. + +The generator also runs on [Bun](https://bun.sh) without needing `typescript` installed at all — when started via `bunx --bun typesafe-i18n` (or `bun --bun run typesafe-i18n`), your locale files get transpiled with Bun's native bundler. Running `bunx typesafe-i18n` without the `--bun` flag executes the generator with `node` (because of the shebang in the CLI entry point), which works too as long as `typescript` is installed. + Start the generator process in your terminal: ```bash diff --git a/packages/generator/src/generator.mts b/packages/generator/src/generator.mts index 9552dc57..cdb9e1f4 100644 --- a/packages/generator/src/generator.mts +++ b/packages/generator/src/generator.mts @@ -1,7 +1,6 @@ import { watch } from 'chokidar' import fs from 'fs/promises' import { resolve } from 'path' -import ts from 'typescript' import { getConfigWithDefaultValues, readConfig } from '../../config/src/config.mjs' import type { GeneratorConfig, GeneratorConfigWithDefaultValues } from '../../config/src/types.mjs' import type { BaseTranslation } from '../../runtime/src/index.mjs' @@ -10,9 +9,10 @@ import { generate } from './generate-files.mjs' import { configureOutputHandler, shouldGenerateJsDoc } from './output-handler.mjs' import { parseLanguageFile } from './parse-language-file.mjs' import { createPathIfNotExits } from './utils/file.utils.mjs' -import { parseTypescriptVersion, runCommandAfterGenerator, type TypescriptVersion } from './utils/generator.utils.mjs' +import { runCommandAfterGenerator, type TypescriptVersion } from './utils/generator.utils.mjs' import { createLogger, type Logger } from './utils/logger.mjs' import { findAllNamespacesForLocale } from './utils/namespaces.utils.mjs' +import { getTypescriptVersion } from './utils/typescript.utils.mjs' let logger: Logger let firstRunOfGenerator = true @@ -110,7 +110,7 @@ export const startGenerator = async (config?: GeneratorConfig, watchFiles = true const configWithDefaultValues = await getConfigWithDefaultValues(parsedConfig) const { outputPath } = configWithDefaultValues - const version = parseTypescriptVersion(ts.versionMajorMinor) + const version = await getTypescriptVersion() configureOutputHandler(configWithDefaultValues, version) const onChange = parseAndGenerate.bind(null, configWithDefaultValues, version) @@ -121,7 +121,9 @@ export const startGenerator = async (config?: GeneratorConfig, watchFiles = true logger.info( `generating files for ${ - shouldGenerateJsDoc ? 'JavaScript with JSDoc notation' : `TypeScript version: '${ts.versionMajorMinor}.x'` + shouldGenerateJsDoc + ? 'JavaScript with JSDoc notation' + : `TypeScript version: '${version.major}.${version.minor}.x'` }`, ) logger.info(`options:`, parsedConfig) diff --git a/packages/generator/src/parse-language-file.mts b/packages/generator/src/parse-language-file.mts index 06f08fe3..74d04f17 100644 --- a/packages/generator/src/parse-language-file.mts +++ b/packages/generator/src/parse-language-file.mts @@ -1,6 +1,9 @@ -import { resolve, sep } from 'path' +/// +import { execFile } from 'child_process' +import { readdir } from 'fs/promises' +import { dirname, resolve, sep } from 'path' import { isTruthy } from 'typesafe-utils' -import ts from 'typescript' +import { promisify } from 'util' import type { OutputFormats } from '../../config/src/types.mjs' import type { Locale } from '../../runtime/src/core.mjs' import type { BaseTranslation } from '../../runtime/src/index.mjs' @@ -14,6 +17,7 @@ import { importFile, } from './utils/file.utils.mjs' import { logger } from './utils/logger.mjs' +import { createRequireFromProject, getTypescriptCompiler } from './utils/typescript.utils.mjs' /** * looks for the location of the compiled 'index.js' file @@ -26,7 +30,7 @@ const detectLocationOfCompiledBaseTranslation = async ( tempPath: string, typesFileName: string, ): Promise => { - if (!containsFolders(tempPath)) return '' + if (!(await containsFolders(tempPath))) return '' const directory = await getDirectoryStructure(tempPath) @@ -74,6 +78,86 @@ See the example in the official docs: https://github.com/ivanhofer/typesafe-i18n return '' } +const getBunRuntime = (): typeof Bun | undefined => (process.versions.bun ? globalThis.Bun : undefined) + +const transpileWithBun = async (bun: typeof Bun, languageFilePath: string, tempPath: string): Promise => { + try { + const result = await bun.build({ + entrypoints: [languageFilePath], + outdir: tempPath, + target: 'bun', + format: 'esm', + // npm imports stay external; Bun resolves them when the bundle gets imported + packages: 'external', + sourcemap: 'none', + }) + + const outputPath = result.outputs[0]?.path + if (!result.success || !outputPath) { + logger.error(`could not transpile file '${languageFilePath}'`, ...result.logs) + return '' + } + + return outputPath + } catch (error) { + // Bun >= 1.2 throws an 'AggregateError' instead of returning 'success: false' + logger.error(`could not transpile file '${languageFilePath}'`, error) + return '' + } +} + +const execFileAsync = promisify(execFile) + +const findTscExecutable = (): string | undefined => { + try { + const require = createRequireFromProject() + const packageJsonPath = require.resolve('typescript/package.json') + const { bin } = require('typescript/package.json') as { bin?: Record } + const binEntry = bin?.['tsc'] + + return binEntry ? resolve(dirname(packageJsonPath), binEntry) : undefined + } catch (ignore) { + return undefined + } +} + +const transpileWithTscCli = async (languageFilePath: string, tempPath: string): Promise => { + const tscPath = findTscExecutable() + if (!tscPath) return false + + // 'tsc' emits even when it reports type-errors (guaranteed because of '--noLib'), + // so a non-zero exit code gets ignored; the output is only kept for the failure log + let output = '' + await execFileAsync( + process.execPath, + [ + tscPath, + languageFilePath, + '--outDir', + tempPath, + '--allowJs', + '--resolveJsonModule', + '--skipLibCheck', + '--noLib', + '--module', + 'commonjs', + '--target', + 'es2018', + '--pretty', + 'false', + ], + { cwd: resolve() }, + ).catch((error: { stdout?: string; stderr?: string }) => (output = `${error.stdout || ''}${error.stderr || ''}`)) + + const emittedFiles = await readdir(tempPath).catch(() => []) + if (!emittedFiles.length) { + output && logger.error(`running 'tsc' failed with: ${output}`) + return false + } + + return true +} + const transpileTypescriptFiles = async ( outputPath: string, outputFormat: OutputFormats, @@ -82,16 +166,31 @@ const transpileTypescriptFiles = async ( tempPath: string, typesFileName: string, ): Promise => { - const program = ts.createProgram([languageFilePath], { - outDir: tempPath, - allowJs: true, - resolveJsonModule: true, - skipLibCheck: true, - sourceMap: false, - noLib: true, - }) - - program.emit() + const bun = getBunRuntime() + if (bun) { + // Bun bundles the file and its imports natively; no 'typescript' installation needed + return transpileWithBun(bun, languageFilePath, tempPath) + } + + const ts = await getTypescriptCompiler() + if (ts) { + const program = ts.createProgram([languageFilePath], { + outDir: tempPath, + allowJs: true, + resolveJsonModule: true, + skipLibCheck: true, + sourceMap: false, + noLib: true, + }) + + program.emit() + } else if (!(await transpileWithTscCli(languageFilePath, tempPath))) { + logger.error(`could not transpile file '${languageFilePath}'. +No usable TypeScript compiler was found: either no 'typescript' package is installed, or the installed version does not provide the compiler API (TypeScript >= 7 removed it) and running its 'tsc' CLI failed. +Make sure 'typescript' is installed in your project, or run typesafe-i18n with Bun ('bunx --bun typesafe-i18n'). +`) + return '' + } const baseTranslationPath = await detectLocationOfCompiledBaseTranslation( outputPath, diff --git a/packages/generator/src/utils/generator.utils.mts b/packages/generator/src/utils/generator.utils.mts index 1d0f58f0..db58fe50 100644 --- a/packages/generator/src/utils/generator.utils.mts +++ b/packages/generator/src/utils/generator.utils.mts @@ -7,8 +7,8 @@ export type TypescriptVersion = { minor: number } -export const parseTypescriptVersion = (versionMajorMinor: `${number}.${number}`): TypescriptVersion => { - const [major, minor] = versionMajorMinor.split('.').map((item) => +item) as [number, number] +export const parseTypescriptVersion = (version: `${number}.${number}` | string): TypescriptVersion => { + const [major = 0, minor = 0] = version.split('.').map((item) => parseInt(item, 10) || 0) return { major, diff --git a/packages/generator/src/utils/typescript.utils.mts b/packages/generator/src/utils/typescript.utils.mts new file mode 100644 index 00000000..236abd9b --- /dev/null +++ b/packages/generator/src/utils/typescript.utils.mts @@ -0,0 +1,55 @@ +// aliased because the esbuild banner of the CLI bundle already declares 'createRequire' +import { createRequire as createNodeRequire } from 'module' +import { resolve } from 'path' +import type ts from 'typescript' +import { parseTypescriptVersion, type TypescriptVersion } from './generator.utils.mjs' +import { logger } from './logger.mjs' + +type TypescriptModule = typeof ts + +// starting with TypeScript 7 the package's main entry only exposes version information, +// so the module needs to be imported lazily and each API feature-detected before use +const importTypescript = async (): Promise | undefined> => { + try { + const tsModule = (await import('typescript')) as { default?: TypescriptModule } & TypescriptModule + return tsModule.default ?? tsModule + } catch (ignore) { + return undefined + } +} + +let compilerPromise: Promise | undefined + +export const getTypescriptCompiler = (): Promise => + (compilerPromise ??= importTypescript().then((tsModule) => + typeof tsModule?.createProgram === 'function' ? (tsModule as TypescriptModule) : undefined, + )) + +// resolve from the user's project instead of `import.meta.url`; the importer and exporter +// are also shipped as CJS bundles where `import.meta` is empty +export const createRequireFromProject = () => createNodeRequire(resolve(process.cwd(), 'noop.js')) + +const FALLBACK_VERSION: TypescriptVersion = { major: 5, minor: 5 } + +const detectTypescriptVersion = async (): Promise => { + const tsModule = await importTypescript() + if (typeof tsModule?.versionMajorMinor === 'string') { + return parseTypescriptVersion(tsModule.versionMajorMinor) + } + + try { + const { version } = createRequireFromProject()('typescript/package.json') as { version?: string } + if (version) return parseTypescriptVersion(version) + } catch (ignore) { + // 'typescript' is not installed + } + + logger.info( + `could not detect the installed TypeScript version, assuming version >= ${FALLBACK_VERSION.major}.${FALLBACK_VERSION.minor}`, + ) + return FALLBACK_VERSION +} + +let versionPromise: Promise | undefined + +export const getTypescriptVersion = (): Promise => (versionPromise ??= detectTypescriptVersion()) diff --git a/packages/generator/src/utils/typescript.utils.test.ts b/packages/generator/src/utils/typescript.utils.test.ts new file mode 100644 index 00000000..d7de2943 --- /dev/null +++ b/packages/generator/src/utils/typescript.utils.test.ts @@ -0,0 +1,24 @@ +import { suite } from 'uvu' +import * as assert from 'uvu/assert' +import { parseTypescriptVersion, type TypescriptVersion } from './generator.utils.mjs' +import { getTypescriptVersion } from './typescript.utils.mjs' + +const test = suite('typescript-version') + +const cases: [string, TypescriptVersion][] = [ + ['3.5', { major: 3, minor: 5 }], + ['5.1', { major: 5, minor: 1 }], + ['7.0', { major: 7, minor: 0 }], + ['', { major: 0, minor: 0 }], +] + +cases.forEach(([version, expected]) => + test(`parseTypescriptVersion: '${version}'`, () => assert.equal(parseTypescriptVersion(version), expected)), +) + +test('getTypescriptVersion detects the installed version', async () => { + const version = await getTypescriptVersion() + assert.ok(version.major >= 3) +}) + +test.run() diff --git a/packages/importer/src/importer.mts b/packages/importer/src/importer.mts index 68c9c07e..2ccb9595 100644 --- a/packages/importer/src/importer.mts +++ b/packages/importer/src/importer.mts @@ -1,13 +1,13 @@ import fs from 'fs/promises' -import ts from 'typescript' import { getConfigWithDefaultValues } from '../../config/src/config.mjs' import { generateLocaleTemplate } from '../../generator/src/files/generate-template-locale.mjs' import { generateNamespaceTemplate } from '../../generator/src/files/generate-template-namespace.mjs' import { generate } from '../../generator/src/generate-files.mjs' import { configureOutputHandler } from '../../generator/src/output-handler.mjs' import { parseLanguageFile } from '../../generator/src/parse-language-file.mjs' -import { parseTypescriptVersion, runCommandAfterGenerator } from '../../generator/src/utils/generator.utils.mjs' +import { runCommandAfterGenerator } from '../../generator/src/utils/generator.utils.mjs' import { createLogger } from '../../generator/src/utils/logger.mjs' +import { getTypescriptVersion } from '../../generator/src/utils/typescript.utils.mjs' import type { BaseTranslation, ImportLocaleMapping, Locale } from '../../runtime/src/core.mjs' import { getAllLocales } from '../../shared/src/file.utils.mjs' @@ -28,7 +28,7 @@ export const storeTranslationsToDisk = async ( ): Promise => { const config = await getConfigWithDefaultValues() - const version = parseTypescriptVersion(ts.versionMajorMinor) + const version = await getTypescriptVersion() configureOutputHandler(config, version) const createdLocales: Locale[] = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 937d6fd1..bffd9dba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@size-limit/preset-small-lib': specifier: ^8.2.6 version: 8.2.6(size-limit@8.2.6) + '@types/bun': + specifier: ^1.3.14 + version: 1.3.14 '@types/glob': specifier: ^8.1.0 version: 8.1.0 @@ -1093,6 +1096,12 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true + /@types/bun@1.3.14: + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + dependencies: + bun-types: 1.3.14 + dev: true + /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} dev: true @@ -1607,6 +1616,12 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true + /bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + dependencies: + '@types/node': 18.17.3 + dev: true + /bundle-name@3.0.0: resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==} engines: {node: '>=12'} From 6f65ac014eeaa89b60bfe71cd98c23282ff61af0 Mon Sep 17 00:00:00 2001 From: David Dakhovich Date: Sun, 9 Aug 2026 21:58:29 +0300 Subject: [PATCH 2/2] fix: pass '--ignoreConfig' when spawning the TypeScript 7 'tsc' CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript 7 aborts with 'error TS5112' (and emits nothing) when files are passed on the command line while a 'tsconfig.json' exists in the project — a situation that is the norm rather than the exception. Classic 'tsc' silently ignored the config file in this case; the new '--ignoreConfig' flag restores that behavior. The flag only exists in TypeScript >= 7, which is safe because this code path is only reachable when the compiler API is missing — older versions always take the programmatic 'ts.createProgram' branch. Co-Authored-By: Claude Fable 5 --- packages/generator/src/parse-language-file.mts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/generator/src/parse-language-file.mts b/packages/generator/src/parse-language-file.mts index 74d04f17..ba182e4e 100644 --- a/packages/generator/src/parse-language-file.mts +++ b/packages/generator/src/parse-language-file.mts @@ -133,6 +133,10 @@ const transpileWithTscCli = async (languageFilePath: string, tempPath: string): [ tscPath, languageFilePath, + // a 'tsconfig.json' next to the project would otherwise abort the compilation with + // 'error TS5112'; the flag only exists in TypeScript >= 7, which is the only version + // this code path runs for (older versions provide the compiler API instead) + '--ignoreConfig', '--outDir', tempPath, '--allowJs',