Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -167,6 +168,11 @@
"peerDependencies": {
"typescript": ">=3.5.1"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"bin": {
"typesafe-i18n": "cli/typesafe-i18n.mjs"
},
Expand Down
5 changes: 2 additions & 3 deletions packages/exporter/src/exporter.mts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -18,7 +17,7 @@ const logger = createLogger(console, true)
const setup = async (): Promise<GeneratorConfigWithDefaultValues> => {
const config = await getConfigWithDefaultValues()

const version = parseTypescriptVersion(ts.versionMajorMinor)
const version = await getTypescriptVersion()
configureOutputHandler(config, version)

return config
Expand Down
4 changes: 4 additions & 0 deletions packages/generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions packages/generator/src/generator.mts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
129 changes: 116 additions & 13 deletions packages/generator/src/parse-language-file.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { resolve, sep } from 'path'
/// <reference types="bun" />
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'
Expand All @@ -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
Expand All @@ -26,7 +30,7 @@ const detectLocationOfCompiledBaseTranslation = async (
tempPath: string,
typesFileName: string,
): Promise<string> => {
if (!containsFolders(tempPath)) return ''
if (!(await containsFolders(tempPath))) return ''

const directory = await getDirectoryStructure(tempPath)

Expand Down Expand Up @@ -74,6 +78,90 @@ 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<string> => {
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<string, string> }
const binEntry = bin?.['tsc']

return binEntry ? resolve(dirname(packageJsonPath), binEntry) : undefined
} catch (ignore) {
return undefined
}
}

const transpileWithTscCli = async (languageFilePath: string, tempPath: string): Promise<boolean> => {
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs --ignoreConfig. With TypeScript 7.0.2, passing a source file while the project contains a tsconfig.json produces TS5112 and emits nothing. I confirmed that adding the flag makes the same command emit the expected files.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thank you! Reproduced it — with a tsconfig.json present, tsc 7.0.2 aborts with TS5112 and emits nothing, so the fallback reported "No usable TypeScript compiler was found".

Fixed in 6f65ac0 by adding --ignoreConfig to the spawned arguments. The flag is passed unconditionally because this code path is only reachable on TypeScript >= 7 (older versions still provide the compiler API and take the programmatic ts.createProgram branch, which never read the user's tsconfig.json either — so behavior stays consistent).

Verified with TypeScript 7.0.2 + tsconfig.json (previously failing, now generates correctly) and re-ran the TypeScript 5 and Bun scenarios as regression.

// 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',
'--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,
Expand All @@ -82,16 +170,31 @@ const transpileTypescriptFiles = async (
tempPath: string,
typesFileName: string,
): Promise<string> => {
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,
Expand Down
4 changes: 2 additions & 2 deletions packages/generator/src/utils/generator.utils.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/generator/src/utils/typescript.utils.mts
Original file line number Diff line number Diff line change
@@ -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<Partial<TypescriptModule> | undefined> => {
try {
const tsModule = (await import('typescript')) as { default?: TypescriptModule } & TypescriptModule
return tsModule.default ?? tsModule
} catch (ignore) {
return undefined
}
}

let compilerPromise: Promise<TypescriptModule | undefined> | undefined

export const getTypescriptCompiler = (): Promise<TypescriptModule | undefined> =>
(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<TypescriptVersion> => {
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<TypescriptVersion> | undefined

export const getTypescriptVersion = (): Promise<TypescriptVersion> => (versionPromise ??= detectTypescriptVersion())
24 changes: 24 additions & 0 deletions packages/generator/src/utils/typescript.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 3 additions & 3 deletions packages/importer/src/importer.mts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -28,7 +28,7 @@ export const storeTranslationsToDisk = async (
): Promise<Locale[]> => {
const config = await getConfigWithDefaultValues()

const version = parseTypescriptVersion(ts.versionMajorMinor)
const version = await getTypescriptVersion()
configureOutputHandler(config, version)

const createdLocales: Locale[] = []
Expand Down
Loading