vite-plugin-lib
Version:
Vite plugin for build configuration, automatic aliases, and type declarations.
317 lines (316 loc) • 11.1 kB
JavaScript
import { builtinModules } from "node:module";
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import c from "picocolors";
import ts from "typescript";
import * as dts from "vite-plugin-dts";
import dts$1 from "vite-plugin-dts";
import { normalizePath } from "vite";
//#region src/logger.ts
function log(text) {
console.log(`${c.cyan("[vite:lib]")} ${text}`);
}
function logWarn(text) {
console.warn(`${c.yellow("[vite:lib]")} ${text}`);
}
function logError(text) {
console.error(`${c.red("[vite:lib]")} ${text}`);
}
//#endregion
//#region src/es-declarations.ts
async function generateMTSDeclarations(typesDir, deleteSourceFiles, verbose) {
const files = await collectFiles(typesDir);
for (const file of files) {
await createMTSImports(file, verbose);
if (deleteSourceFiles) await unlink(file);
}
log(`Generated ${files.length} MTS declarations.`);
}
async function collectFiles(dir) {
const entries = await readdir(dir, {
recursive: false,
withFileTypes: true
});
const files = entries.filter((entry) => entry.isFile());
const nestedFiles = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => collectFiles(normalizePath(path.join(dir, entry.name)))));
return files.map((file) => normalizePath(path.join(dir, file.name))).concat(...nestedFiles);
}
async function createMTSImports(file, verbose) {
const modified = (await readFile(file, "utf-8")).split("\n").map((line) => transformLine(file, line, verbose));
await writeFile(file.replace(".d.ts", ".d.mts"), modified.join("\n"));
}
function transformLine(file, line, verbose) {
return transformStaticImport(file, line, "'", verbose) ?? transformStaticImport(file, line, "\"", verbose) ?? transformExport(file, line, "'", verbose) ?? transformExport(file, line, "\"", verbose) ?? line;
}
function transformStaticImport(file, line, quote, verbose) {
const importPathMarker = `from ${quote}`;
if (!(line.includes("import ") && line.includes(`${importPathMarker}.`))) return;
const importStartIndex = line.lastIndexOf(importPathMarker);
const importPath = line.substring(importStartIndex + importPathMarker.length, line.length - 2);
const resolvedImport = path.resolve(path.dirname(file), importPath);
if (existsSync(resolvedImport)) {
if (verbose) log(`got index import ${resolvedImport}`);
return `${line.substring(0, line.length - 2)}/index.mjs${quote};`;
}
return `${line.substring(0, line.length - 2)}.mjs${quote};`;
}
function transformExport(file, line, quote, verbose) {
const exportPathMarker = ` from ${quote}`;
if (!(line.includes("export ") && line.includes(`${exportPathMarker}.`))) return;
const exportStartIndex = line.lastIndexOf(exportPathMarker);
const exportPath = line.substring(exportStartIndex + exportPathMarker.length, line.length - 2);
const resolvedExport = path.resolve(path.dirname(file), exportPath);
if (existsSync(resolvedExport)) {
if (verbose) log(`got index export ${resolvedExport}`);
return `${line.substring(0, line.length - 2)}/index.mjs${quote};`;
}
return `${line.substring(0, line.length - 2)}.mjs${quote};`;
}
//#endregion
//#region src/index.ts
const typesDir = "dist/types";
const COMMON_DEFAULTS = { verbose: false };
const TS_CONFIG_PATHS_OPTIONS = {
...COMMON_DEFAULTS,
tsconfig: "./tsconfig.json"
};
const BUNDLE_DEFAULTS = {
builtin: false,
dependencies: false,
devDependencies: true,
peerDependencies: false,
exclude: [],
include: [],
nodeModules: false
};
const LIBRARY_DEFAULTS = {
...TS_CONFIG_PATHS_OPTIONS,
cleanup: true,
entry: "src/index.ts",
bundle: {},
formats: ["es"],
manifest: "package.json"
};
function mergeWithDefaults(options) {
return {
...LIBRARY_DEFAULTS,
...options
};
}
function tsconfigPaths(options = {}) {
const tsconfig = options.tsconfig ?? TS_CONFIG_PATHS_OPTIONS.tsconfig;
const verbose = options.verbose ?? TS_CONFIG_PATHS_OPTIONS.verbose;
return {
name: "vite-plugin-lib:alias",
enforce: "pre",
config: async (config) => {
const tsconfigPath = path.resolve(config.root ?? ".", tsconfig);
const { baseUrl, paths } = await readConfig(tsconfigPath);
if (!baseUrl || !paths) {
log(`No paths found in ${tsconfig}.`);
return config;
}
const pathToAlias = pathToAliasFactory(tsconfigPath, baseUrl, verbose);
const aliasOptions = Object.entries(paths).map(pathToAlias).filter((alias) => alias !== void 0);
if (aliasOptions.length > 0) logInjectedAliases(aliasOptions, config, verbose);
const existingAlias = transformExistingAlias(config.resolve?.alias);
return {
...config,
resolve: {
...config.resolve,
alias: [...existingAlias, ...aliasOptions]
}
};
}
};
}
function buildConfig({ entry, formats, manifest, name, bundle, verbose }) {
const bundleWithDefaults = {
...BUNDLE_DEFAULTS,
...bundle
};
const packagesToExternalize = [
...getBuiltinModules(bundleWithDefaults),
...getDependencies(manifest, bundleWithDefaults, verbose),
...bundleWithDefaults.exclude
];
if (!bundleWithDefaults.nodeModules) {
packagesToExternalize.push(/node_modules/);
if (verbose) log(`Externalized node_modules.`);
}
return {
name: "vite-plugin-lib:build",
enforce: "pre",
apply: "build",
config: (config) => {
return {
...config,
build: {
...config.build,
lib: {
...config.build?.lib,
entry: path.resolve(config.root ?? ".", entry),
formats,
name,
fileName: (format) => formatToFileName(entry, format)
},
rollupOptions: { external: (source, _importer, _isResolved) => {
const shouldBeExternalized = packagesToExternalize.some((rule) => matchesRule(source, rule));
const shouldBeBundled = bundleWithDefaults.include.some((rule) => matchesRule(source, rule));
return shouldBeExternalized && !shouldBeBundled;
} }
}
};
}
};
}
function matchesRule(source, rule) {
return typeof rule === "string" ? rule === source : rule.test(source);
}
function getDependencies(manifest, bundle, verbose) {
try {
const content = readFileSync(manifest, { encoding: "utf-8" });
const { dependencies = {}, devDependencies = {}, peerDependencies = {} } = JSON.parse(content);
const dependenciesToExternalize = [];
if (!bundle.dependencies) {
const names = Object.keys(dependencies);
dependenciesToExternalize.push(...names);
if (verbose) log(`Externalized ${names.length} dependencies.`);
}
if (!bundle.devDependencies) {
const names = Object.keys(devDependencies);
dependenciesToExternalize.push(...names);
if (verbose) log(`Externalized ${names.length} devDependencies.`);
}
if (!bundle.peerDependencies) {
const names = Object.keys(peerDependencies);
dependenciesToExternalize.push(...names);
if (verbose) log(`Externalized ${names.length} peerDependencies.`);
}
return dependenciesToExternalize;
} catch (error) {
const message = getErrorMessage(error);
logError(`Could not read ${c.green(manifest)}: ${message}`);
throw error;
}
}
function getBuiltinModules(bundle) {
if (bundle.builtin) return [];
log("Externalized builtin modules.");
return [
...builtinModules,
/node:/,
/bun:/,
/deno:/
];
}
function logInjectedAliases(aliasOptions, config, verbose) {
log(`Injected ${c.green(aliasOptions.length)} aliases.`);
if (!verbose) return;
const base = `${path.resolve(config.root ?? ".")}/`;
aliasOptions.map(({ find, replacement }) => ` ${c.gray(">")} ${c.green(find.toString())} ${c.gray(c.bold("->"))} ${c.green(replacement.replace(base, ""))}`).forEach(log);
}
function pathToAliasFactory(tsconfigPath, baseUrl, verbose) {
return ([alias, replacements]) => {
if (replacements.length === 0) {
if (verbose) logWarn(`No replacements for alias ${c.green(alias)}.`);
return;
}
if (verbose && replacements.length > 1) {
logWarn(`Found more than one replacement for alias ${c.green(alias)}.`);
logWarn("Using the first existing replacement.");
}
const find = alias.replace("/*", "");
const replacement = getFirstExistingReplacement(tsconfigPath, baseUrl, replacements, find);
if (!replacement) {
if (verbose) logWarn(`No replacement found for alias ${c.green(alias)}.`);
return;
}
return {
find,
replacement
};
};
}
function getFirstExistingReplacement(tsconfigPath, baseUrl, replacements, find, verbose) {
for (const replacement of replacements) {
const resolvedReplacement = path.resolve(tsconfigPath, baseUrl, replacement.replace("/*", "") ?? find);
if (existsSync(resolvedReplacement)) return resolvedReplacement;
else if (verbose) logWarn(`Path ${c.green(replacement)} does not exist.`);
}
}
function formatToFileName(entry, format) {
const entryFileName = entry.substring(entry.lastIndexOf("/") + 1, entry.lastIndexOf("."));
if (format === "es") return `${entryFileName}.mjs`;
if (format === "cjs") return `${entryFileName}.cjs`;
return `${entryFileName}.${format}.js`;
}
function library(options = {}) {
const mergedOptions = mergeWithDefaults(options);
const plugins = [
tsconfigPaths(mergedOptions),
buildConfig(mergedOptions),
dts$1({
cleanVueFileName: true,
copyDtsFiles: true,
include: `${path.resolve(mergedOptions.entry, "..")}/**`,
outDir: typesDir,
staticImport: true,
tsconfigPath: mergedOptions.tsconfig,
afterBuild: async () => {
if (includesESFormat(mergedOptions.formats)) await generateMTSDeclarations(typesDir, mergedOptions.formats?.length === 1, options.verbose);
}
})
];
if (mergedOptions.cleanup) plugins.push(cleanup(mergedOptions));
return plugins;
}
function transformExistingAlias(alias) {
if (!alias) return [];
if (Array.isArray(alias)) return alias;
return Object.entries(alias).map(([find, replacement]) => ({
find,
replacement
}));
}
async function readConfig(configPath) {
try {
const configFileText = await readFile(configPath, { encoding: "utf-8" });
const { config } = ts.parseConfigFileTextToJson(configPath, configFileText);
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
return options;
} catch (error) {
logError(`Could not read ${configPath}: ${getErrorMessage(error)}`);
throw error;
}
}
function includesESFormat(formats) {
return formats?.includes("es") ?? true;
}
function getErrorMessage(error) {
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
return String(error);
}
/**
* Remove any temporary `vite.config.ts.timestamp-*` files.
*/
function cleanup(options = {}) {
const verbose = options.verbose ?? COMMON_DEFAULTS.verbose;
return {
name: "vite-plugin-lib:cleanup",
enforce: "post",
closeBundle: () => {
let deletedCount = 0;
readdirSync(".").forEach((file) => {
if (!file.startsWith("vite.config.ts.timestamp-")) return;
rmSync(`./${file}`);
deletedCount++;
});
if (verbose) log(`Removed ${deletedCount} temporary files.`);
}
};
}
//#endregion
export { cleanup, dts, library, tsconfigPaths };
//# sourceMappingURL=index.mjs.map