UNPKG

unplugin-isolated-decl

Version:

A blazing-fast tool for generating isolated declarations.

382 lines (377 loc) 13.7 kB
import { appendMapUrl, generateDtsMap, oxcTransform, swcTransform, tsTransform } from "./transformer-Bp3NCHCk.js"; import path from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import MagicString from "magic-string"; import { parseAsync } from "oxc-parser"; import { createUnplugin } from "unplugin"; import { createFilter } from "unplugin-utils"; import Debug from "debug"; //#region src/core/utils.ts const debug = Debug("unplugin-isolated-decl"); function lowestCommonAncestor(...filepaths) { if (filepaths.length === 0) return ""; if (filepaths.length === 1) return path.dirname(filepaths[0]); filepaths = filepaths.map((p) => p.replaceAll("\\", "/")); const [first, ...rest] = filepaths; let ancestor = first.split("/"); for (const filepath of rest) { const directories = filepath.split("/", ancestor.length); let index = 0; for (const directory of directories) if (directory === ancestor[index]) index += 1; else { ancestor = ancestor.slice(0, index); break; } ancestor = ancestor.slice(0, index); } return ancestor.length <= 1 && ancestor[0] === "" ? `/${ancestor[0]}` : ancestor.join("/"); } function stripExt(filename) { return filename.replace(/\.(.?)[jt]sx?$/, ""); } function resolveEntry(input, userInputBase) { if (typeof input === "string") input = [input]; const entryMap = !Array.isArray(input) ? Object.fromEntries(Object.entries(input).map(([k, v]) => [path.resolve(stripExt(v)), k])) : void 0; const arr = Array.isArray(input) && input ? input : Object.values(input); const inputBase = userInputBase || lowestCommonAncestor(...arr); return { entryMap, inputBase }; } function endsWithIndex(s) { return /(?:^|[/\\])index(?:\..+)?$/.test(s); } function shouldAddIndex(id, resolved) { return !endsWithIndex(id) && endsWithIndex(resolved); } //#endregion //#region src/core/ast.ts function filterImports(program) { return program.body.filter((node) => (node.type === "ImportDeclaration" || node.type === "ExportAllDeclaration" || node.type === "ExportNamedDeclaration") && !!node.source); } function rewriteImports(s, imports, entryMap, inputBase, entryFileNames, srcFilename) { const srcRel = path.relative(inputBase, srcFilename); const srcDir = path.dirname(srcFilename); const srcDirRel = path.relative(inputBase, srcDir); let entryAlias = entryMap?.[srcFilename]; if (entryAlias && path.normalize(entryAlias) === srcRel) entryAlias = void 0; const entry = entryAlias || srcRel; const resolvedEntry = entryFileNames.replaceAll("[name]", entry); const emitDir = path.dirname(resolvedEntry); const emitDtsName = resolvedEntry.replace(/\.(.)?[jt]sx?$/, (_, s$1) => `.d.${s$1 || ""}ts`); const offset = path.relative(emitDir, srcDirRel); for (const i of imports) { const { source } = i; let srcIdRel = source.value; if (srcIdRel[0] !== ".") continue; if (i.shouldAddIndex) srcIdRel += "/index"; const srcId = path.resolve(srcDir, srcIdRel); const importAlias = entryMap?.[stripExt(srcId)]; let id; if (importAlias) { const resolved = entryFileNames.replaceAll("[name]", stripExt(importAlias)); id = pathRelative(srcDirRel, resolved); } else id = entryFileNames.replaceAll("[name]", stripExt(srcIdRel)); let final = path.normalize(path.join(offset, id)); if (final !== path.normalize(source.value)) { debug("Patch import in", srcRel, ":", srcIdRel, "->", final); final = final.replaceAll("\\", "/"); if (final.startsWith("/")) final = `.${final}`; if (!/^\.\.?\//.test(final)) final = `./${final}`; s.overwrite(i.source.start + 1, i.source.end - 1, final); } } return emitDtsName; } function pathRelative(from, to) { return path.join(path.relative(from, path.dirname(to)), path.basename(to)); } //#endregion //#region src/core/options.ts function resolveOptions(options) { return { include: options.include || [/\.[cm]?tsx?$/], exclude: options.exclude || [/node_modules/], enforce: "enforce" in options ? options.enforce : "pre", sourceMap: options.sourceMap || false, transformer: options.transformer || "oxc", ignoreErrors: options.ignoreErrors || false, extraOutdir: options.extraOutdir, patchCjsDefaultExport: options.patchCjsDefaultExport || false, rewriteImports: options.rewriteImports, inputBase: options.inputBase, transformOptions: options.transformOptions }; } //#endregion //#region src/index.ts const IsolatedDecl = createUnplugin((rawOptions = {}) => { const options = resolveOptions(rawOptions); const filter = createFilter(options.include, options.exclude); let farmPluginContext; let outputFiles = Object.create(null); function addOutput(filename, output) { const name = stripExt(filename); const ext = path.extname(filename); debug("Add output:", name); outputFiles[name] = { ...output, ext }; } const rollup = { renderStart: rollupRenderStart }; const farm = { renderStart: { executor: farmRenderStart } }; return { name: "unplugin-isolated-decl", buildStart() { farmPluginContext = this; outputFiles = Object.create(null); }, transformInclude: (id) => filter(id), transform(code, id) { return transform(this, code, id); }, esbuild: { setup: esbuildSetup }, rollup, rolldown: rollup, vite: { apply: "build", enforce: "pre", ...rollup }, farm }; async function transform(context, code, id) { const label = debug.enabled && `[${options.transformer}]`; debug(label, "transform", id); let result; switch (options.transformer) { case "oxc": result = await oxcTransform(id, code, { ...options.transformOptions, sourcemap: options.sourceMap }); break; case "swc": result = await swcTransform(id, code); break; case "typescript": result = await tsTransform(id, code, options.transformOptions, options.sourceMap); break; } const { code: dts, errors, map } = result; debug(label, "transformed", id, errors.length ? "with errors" : "successfully"); if (errors.length) if (options.ignoreErrors) context.warn(errors[0]); else { context.error(errors[0]); return; } const { program } = await parseAsync(id, dts); const imports = filterImports(program); const s = new MagicString(dts); for (const i of imports) { const { source } = i; let { value } = source; if (options.rewriteImports) { const result$1 = options.rewriteImports(value, id); if (typeof result$1 === "string") { source.value = value = result$1; s.overwrite(source.start + 1, source.end - 1, result$1); } } if (path.isAbsolute(value) || value[0] === ".") { const resolved = await resolve(context, value, stripExt(id)); if (!resolved || resolved.external) continue; i.shouldAddIndex = shouldAddIndex(value, resolved.id); } } addOutput(id, { s, imports, map }); const typeImports = program.body.filter((node) => { if (!("source" in node) || !node.source) return false; if ("importKind" in node && node.importKind === "type") return true; if ("exportKind" in node && node.exportKind === "type") return true; if (node.type === "ImportDeclaration") return !!node.specifiers && node.specifiers.every((spec) => spec.type === "ImportSpecifier" && spec.importKind === "type"); return node.type === "ExportNamedDeclaration" && node.specifiers && node.specifiers.every((spec) => spec.exportKind === "type"); }); for (const { source } of typeImports) { const resolved = (await resolve(context, source.value, id))?.id; if (resolved && filter(resolved) && !outputFiles[stripExt(resolved)]) { let source$1; try { source$1 = await readFile(resolved, "utf8"); } catch { continue; } debug("transform type import:", resolved); await transform(context, source$1, resolved); } } } function rollupRenderStart(outputOptions, inputOptions) { const { input } = inputOptions; const { inputBase, entryMap } = resolveEntry(input, options.inputBase); debug("[rollup] input base:", inputBase); const { entryFileNames = "[name].js", dir: outDir } = outputOptions; if (typeof entryFileNames !== "string") return this.error("entryFileNames must be a string"); for (const [srcFilename, { s, imports, map, ext }] of Object.entries(outputFiles)) { let emitName = rewriteImports(s, imports, entryMap, inputBase, entryFileNames, srcFilename); let source = s.toString(); if (options.patchCjsDefaultExport && emitName.endsWith(".d.cts")) source = patchCjsDefaultExport(source); if (options.extraOutdir) emitName = path.join(options.extraOutdir || "", emitName); debug("[rollup] emit dts file:", emitName); const originalFileName = srcFilename + ext; if (options.sourceMap && map && outDir) { source = appendMapUrl(source, emitName); this.emitFile({ type: "asset", fileName: `${emitName}.map`, source: generateDtsMap(map, originalFileName, path.join(outDir, emitName)), originalFileName }); } this.emitFile({ type: "asset", fileName: emitName, source, originalFileName }); } } function farmRenderStart(config) { const { input = {}, output = {} } = config; const { inputBase, entryMap } = resolveEntry(input, options.inputBase); debug("[farm] input base:", inputBase); if (output && typeof output.entryFilename !== "string") return console.error("entryFileName must be a string"); const extFormatMap = new Map([ ["cjs", "cjs"], ["esm", "js"], ["mjs", "js"] ]); output.entryFilename = "[entryName].[ext]"; output.entryFilename = output.entryFilename.replace("[ext]", extFormatMap.get(output.format || "esm") || "js"); const entryFileNames = output.entryFilename; for (const [srcFilename, { s, imports, map, ext }] of Object.entries(outputFiles)) { let emitName = rewriteImports(s, imports, entryMap, inputBase, entryFileNames, srcFilename); let source = s.toString(); if (options.patchCjsDefaultExport && emitName.endsWith(".d.cts")) source = patchCjsDefaultExport(source); if (options.extraOutdir) emitName = path.join(options.extraOutdir || "", emitName); debug("[farm] emit dts file:", emitName); const outDir = output.path; if (options.sourceMap && map && outDir) { source = appendMapUrl(source, emitName); farmPluginContext.emitFile({ type: "asset", fileName: `${emitName}.map`, source: generateDtsMap(map, srcFilename + ext, path.join(outDir, emitName)) }); } farmPluginContext.emitFile({ type: "asset", fileName: emitName, source }); } } function esbuildSetup(build) { build.onEnd(async (result) => { const esbuildOptions = build.initialOptions; const entries = esbuildOptions.entryPoints; if (!entries || !Array.isArray(entries) || !entries.every((entry) => typeof entry === "string")) throw new Error("unsupported entryPoints, must be an string[]"); const inputBase = options.inputBase || lowestCommonAncestor(...entries); debug("[esbuild] input base:", inputBase); const jsExt = esbuildOptions.outExtension?.[".js"]; let outExt; switch (jsExt) { case ".cjs": outExt = "cts"; break; case ".mjs": outExt = "mts"; break; default: outExt = "ts"; break; } const write = build.initialOptions.write ?? true; if (write) { if (!build.initialOptions.outdir) throw new Error("outdir is required when write is true"); } else result.outputFiles ||= []; const textEncoder = new TextEncoder(); for (const [srcFilename, { s, map, ext }] of Object.entries(outputFiles)) { const outDir = build.initialOptions.outdir; let outName = `${path.relative(inputBase, srcFilename)}.d.${outExt}`; if (options.extraOutdir) outName = path.join(options.extraOutdir, outName); const outPath = outDir ? path.resolve(outDir, outName) : outName; let source = s.toString(); if (options.patchCjsDefaultExport && outPath.endsWith(".d.cts")) source = patchCjsDefaultExport(source); if (write) { await mkdir(path.dirname(outPath), { recursive: true }); if (options.sourceMap && map) { source = appendMapUrl(source, outPath); await writeFile(`${outPath}.map`, generateDtsMap(map, srcFilename + ext, path.join(outPath))); } await writeFile(outPath, source); debug("[esbuild] write dts file:", outPath); } else { debug("[esbuild] emit dts file:", outPath); result.outputFiles.push({ path: outPath, contents: textEncoder.encode(source), hash: "", text: source }); } } }); } }); async function resolve(context, id, importer) { const nativeContext = context.getNativeBuildContext?.(); try { switch (nativeContext?.framework) { case "esbuild": { const resolved = await nativeContext?.build.resolve(id, { importer, resolveDir: path.dirname(importer), kind: "import-statement" }); return { id: resolved?.path, external: resolved?.external }; } case "farm": { const resolved = await nativeContext?.context.resolve({ source: id, importer, kind: "import" }, { meta: {}, caller: "unplugin-isolated-decl" }); return { id: resolved.resolvedPath, external: !!resolved.external }; } default: { const resolved = await context.resolve(id, importer); if (!resolved) return; return { id: resolved.id, external: !!resolved.external }; } } } catch {} } function patchCjsDefaultExport(source) { return source.replace(/(?<=(?:[;}]|^)\s*export\s*)(?:\{\s*([\w$]+)\s*as\s+default\s*\}|default\s+([\w$]+))/, (_, s1, s2) => `= ${s1 || s2}`); } //#endregion export { IsolatedDecl };