UNPKG

@inlang/paraglide-js

Version:

[![NPM Downloads](https://img.shields.io/npm/dw/%40inlang%2Fparaglide-js?logo=npm&logoColor=red&label=npm%20downloads)](https://www.npmjs.com/package/@inlang/paraglide-js) [![GitHub Issues](https://img.shields.io/github/issues-closed/opral/paraglide-js?lo

353 lines (350 loc) 15.9 kB
import path from "node:path"; import fs from "node:fs/promises"; import os from "node:os"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolveTscJsPath } from "./resolve-tsc-js-path.js"; const execFileAsync = promisify(execFile); const missingTypeScriptMessage = `Paraglide's "emitTsDeclarations" option requires the "typescript" package. Install TypeScript in your project, or disable "emitTsDeclarations".`; const failedToLoadTypeScriptMessage = `Paraglide's "emitTsDeclarations" option requires the "typescript" package. TypeScript appears to be installed, but failed to load. See the error cause for details.`; /** * Generates `.d.ts` files for the compiled Paraglide output using the TypeScript compiler. * * @param output - The generated compiler output keyed by relative file path. * @returns The generated declaration files keyed by relative path. * * @example * const declarations = await emitTsDeclarations(output); * // Merge them into the compiler output before writing to disk */ export async function emitTsDeclarations(output) { const ts = await importTypeScript(); const jsEntries = Object.entries(output).filter(([fileName]) => fileName.endsWith(".js")); if (jsEntries.length === 0) { return {}; } // TypeScript 7+ (the Go-based compiler) no longer ships the in-process // compiler API — its main entry point only exports version metadata. // https://github.com/opral/paraglide-js/issues/711 const compilerApi = resolveCompilerApi(ts); if (compilerApi === undefined) { return emitWithTscCli(jsEntries); } return emitWithCompilerApi(compilerApi, jsEntries); } /** * Returns the in-process compiler API (TypeScript 5/6), or `undefined` when * the installed TypeScript does not provide one (TypeScript 7+). * * Unwraps default-only interop shapes (`{ default: ts }`) that bundlers and * CJS interop can produce. Without the unwrap, TypeScript 5/6 would be * misrouted to the CLI path, whose TypeScript 5 declaration emitter produces * syntactically invalid quoted export aliases. */ function resolveCompilerApi(ts) { if (typeof ts.createProgram === "function") { return ts; } const defaultExport = ts.default; if (defaultExport && typeof defaultExport.createProgram === "function") { return defaultExport; } return undefined; } /** * Emits declarations with the in-process TypeScript compiler API (TypeScript 5/6). */ async function emitWithCompilerApi(ts, jsEntries) { const virtualRoot = path.join(process.cwd(), "__paraglide_virtual_output"); const normalizeFileName = (fileName) => path.normalize(path.isAbsolute(fileName) ? fileName : path.join(virtualRoot, fileName)); const files = new Map(jsEntries.map(([fileName, content]) => [ normalizeFileName(fileName), content, ])); const quotedExportAliases = collectQuotedExportAliases(ts, files); const virtualDirectories = new Set(Array.from(files.keys()).flatMap((filePath) => { const directories = []; let current = path.dirname(filePath); while (current.startsWith(virtualRoot) && current !== virtualRoot) { directories.push(current); const parent = path.dirname(current); if (parent === current) break; current = parent; } return directories; })); // Ensure the virtual root itself is treated as existing virtualDirectories.add(virtualRoot); const compilerOptions = { allowJs: true, checkJs: true, declaration: true, emitDeclarationOnly: true, isolatedDeclarations: true, esModuleInterop: true, lib: ["ESNext", "DOM"], module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, noEmitOnError: false, outDir: virtualRoot, rootDir: virtualRoot, skipLibCheck: true, target: ts.ScriptTarget.ESNext, }; const defaultHost = ts.createCompilerHost(compilerOptions, true); const declarations = {}; const host = { ...defaultHost, fileExists: (fileName) => { const normalized = normalizeFileName(fileName); return files.has(normalized) || defaultHost.fileExists(fileName); }, directoryExists: (directoryName) => { const normalized = normalizeFileName(directoryName); return (virtualDirectories.has(normalized) || defaultHost.directoryExists?.(directoryName) === true); }, getDirectories: (directoryName) => { const normalized = normalizeFileName(directoryName); const children = Array.from(virtualDirectories).filter((dir) => path.dirname(dir) === normalized); return [ ...(defaultHost.getDirectories?.(directoryName) ?? []), ...children.map((dir) => path.basename(dir)), ]; }, readFile: (fileName) => { const normalized = normalizeFileName(fileName); return files.get(normalized) ?? defaultHost.readFile(fileName); }, getSourceFile: (fileName, languageVersion, onError, shouldCreateNewFile) => { const normalized = normalizeFileName(fileName); const sourceText = files.get(normalized); if (sourceText !== undefined) { return ts.createSourceFile(fileName, sourceText, languageVersion, true); } return defaultHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewFile); }, writeFile: (fileName, text) => { const relativePath = path .relative(virtualRoot, fileName) .split(path.sep) .join(path.posix.sep); if (!relativePath.startsWith("..")) { declarations[relativePath] = text; } }, }; const program = ts.createProgram(Array.from(files.keys()), compilerOptions, host); program.emit(undefined, undefined, undefined, true, { afterDeclarations: [ createQuotedExportAliasTransformer(ts, quotedExportAliases, normalizeFileName), ], }); return declarations; } const failedToEmitWithTscMessage = `Paraglide's "emitTsDeclarations" option failed to generate declaration files with the installed TypeScript version. TypeScript 7+ no longer provides the in-process compiler API, so Paraglide invokes its "tsc" CLI instead — which did not produce a declaration file for every compiled file. As a workaround, install TypeScript 5 or 6, or disable "emitTsDeclarations".`; /** * Emits declarations by invoking the `tsc` CLI of the installed TypeScript * package (TypeScript 7+, where the in-process compiler API is unavailable). * * The compiled output is written to a temporary directory because the * Go-based compiler cannot read from a virtual filesystem. */ async function emitWithTscCli(jsEntries) { // entries escaping the output root cannot be declared safely; the // compiler-API path silently drops them too (see the writeFile hook). // Checked against both separator conventions because path.join interprets // backslashes on Windows (`foo\..\..\x.js` would escape the temp dir). const safeEntries = jsEntries.filter(([fileName]) => [path.posix, path.win32].every((platformPath) => { const normalized = platformPath.normalize(fileName); return (normalized.startsWith("..") === false && platformPath.isAbsolute(normalized) === false); })); // resolved before spawning so a missing tsc entry point surfaces its own // pointed error instead of the generic "no declaration files" one const tscJsPath = resolveTscJsPath(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paraglide-dts-")); try { const srcDir = path.join(tempDir, "src"); const outDir = path.join(tempDir, "dist"); await fs.mkdir(outDir, { recursive: true }); for (const [fileName, content] of safeEntries) { const filePath = path.join(srcDir, fileName); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content); } const tsconfigPath = path.join(tempDir, "tsconfig.json"); await fs.writeFile(tsconfigPath, JSON.stringify({ compilerOptions: { allowJs: true, checkJs: true, declaration: true, emitDeclarationOnly: true, esModuleInterop: true, lib: ["ESNext", "DOM"], module: "ESNext", moduleResolution: "bundler", // Declaration emit does not need a typecheck; skipping it also // suppresses diagnostics about the project's environment (e.g. // a missing @types/node) that are irrelevant to the output. noCheck: true, noEmitOnError: false, outDir: "./dist", rootDir: "./src", skipLibCheck: true, target: "ESNext", }, files: safeEntries.map(([fileName]) => `./src/${fileName}`), })); let tscError; try { await execFileAsync(process.execPath, [tscJsPath, "--project", tsconfigPath], { maxBuffer: 64 * 1024 * 1024, // Electron-based hosts must execute tsc with their embedded // Node instead of launching another instance of the host app. // Harmless under node and bun. env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, }); } catch (error) { // tsc exits non-zero when the input has diagnostics even though the // declaration files are still emitted (noEmitOnError is false). // Success is judged by the presence of declaration files below. tscError = error; } const declarations = {}; for (const entry of await fs.readdir(outDir, { recursive: true })) { if (entry.endsWith(".d.ts")) { declarations[entry.split(path.sep).join(path.posix.sep)] = await fs.readFile(path.join(outDir, entry), "utf-8"); } } // tsc emits exactly one .d.ts per input .js (even for inputs with // syntax errors), so a count mismatch means the compiler died mid-emit // and the result would be silently incomplete. if (Object.keys(declarations).length !== safeEntries.length) { throw new Error(withTscOutput(failedToEmitWithTscMessage, tscError), { cause: tscError, }); } return declarations; } finally { await fs.rm(tempDir, { recursive: true, force: true }); } } /** * Appends the spawned compiler's output to the error message. * * tsc writes diagnostics to stdout, which `execFile` errors expose only as a * property — not in the message — so error reporters that print only * message/stack would otherwise hide the reason the emit failed. */ function withTscOutput(message, tscError) { const { stdout, stderr } = (tscError ?? {}); const output = [stdout, stderr] .filter((value) => typeof value === "string") .join("\n") .trim(); if (output === "") { return message; } const truncated = output.length > 4000 ? output.slice(0, 4000) + "\n… (truncated)" : output; return `${message}\n\nCompiler output:\n${truncated}`; } function collectQuotedExportAliases(ts, files) { const aliases = new Map(); for (const [fileName, content] of files) { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS); const fileAliases = new Map(); const visit = (node) => { if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) { for (const specifier of node.exportClause.elements) { if (ts.isStringLiteral(specifier.name)) { const localName = moduleExportNameText(specifier.propertyName ?? specifier.name); const exportedName = specifier.name.text; const localAliases = fileAliases.get(localName) ?? new Set(); localAliases.add(exportedName); fileAliases.set(localName, localAliases); } } } ts.forEachChild(node, visit); }; visit(sourceFile); if (fileAliases.size > 0) { aliases.set(fileName, fileAliases); } } return aliases; } function createQuotedExportAliasTransformer(ts, quotedExportAliases, normalizeFileName) { return (context) => { const visitSourceFile = (sourceFile) => { const fileAliases = quotedExportAliases.get(normalizeFileName(sourceFile.fileName)); if (!fileAliases) { return sourceFile; } const visit = (node) => { if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) { const elements = []; const emitted = new Set(); for (const specifier of node.exportClause.elements) { const localName = moduleExportNameText(specifier.propertyName ?? specifier.name); const exportedName = moduleExportNameText(specifier.name); const shouldQuote = fileAliases.get(localName)?.has(exportedName); const nextSpecifier = shouldQuote ? ts.factory.updateExportSpecifier(specifier, specifier.isTypeOnly, specifier.propertyName, ts.factory.createStringLiteral(exportedName)) : specifier; const emittedKey = `${moduleExportNameText(nextSpecifier.propertyName ?? nextSpecifier.name)}\0${moduleExportNameText(nextSpecifier.name)}`; if (!emitted.has(emittedKey)) { elements.push(nextSpecifier); emitted.add(emittedKey); } } return ts.factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, ts.factory.updateNamedExports(node.exportClause, elements), node.moduleSpecifier, node.attributes); } return ts.visitEachChild(node, visit, context); }; return ts.visitEachChild(sourceFile, visit, context); }; return (sourceFileOrBundle) => { if ("fileName" in sourceFileOrBundle) { return visitSourceFile(sourceFileOrBundle); } return sourceFileOrBundle; }; }; } function moduleExportNameText(name) { return name.text; } async function importTypeScript() { try { return await import("typescript"); } catch (cause) { if (isMissingTypeScriptPackageError(cause)) { throw new Error(missingTypeScriptMessage); } throw new Error(failedToLoadTypeScriptMessage, { cause }); } } function isMissingTypeScriptPackageError(cause) { if (cause instanceof Error === false) { return false; } if ("code" in cause && cause.code !== "ERR_MODULE_NOT_FOUND") { return false; } return cause.message.includes("Cannot find package 'typescript'"); } //# sourceMappingURL=emit-ts-declarations.js.map