unplugin-isolated-decl
Version:
A blazing-fast tool for generating isolated declarations.
382 lines (377 loc) • 13.7 kB
JavaScript
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 };