vite-plugin-lib
Version:
Vite plugin for build configuration, automatic aliases, and type declarations.
346 lines (341 loc) • 10.2 kB
JavaScript
import { existsSync, readdirSync, rmSync, readFileSync } from 'node:fs';
import { unlink, readdir, readFile, writeFile } from 'node:fs/promises';
import { builtinModules } from 'node:module';
import path from 'node:path';
import process from 'node:process';
import c from 'picocolors';
import ts from 'typescript';
import dts__default from 'vite-plugin-dts';
import * as dts from 'vite-plugin-dts';
export { dts };
import { normalizePath } from 'vite';
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}`);
}
async function generateMTSDeclarations(typesDir, deleteSourceFiles, verbose) {
const files = await collectFiles(typesDir);
for (const file of files) {
await createMTSImports(file, verbose);
if (deleteSourceFiles) {
unlink(file);
}
}
log(`Generated ${files.length} MTS declarations.`);
}
async function collectFiles(dir) {
const entries = await readdir(dir, {
recursive: false,
// does not provide full path to nested files
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 content = await readFile(file, "utf-8");
const lines = content.split("\n");
const modified = lines.map((line) => transformLine(file, line, verbose));
const targetFile = file.replace(".d.ts", ".d.mts");
await writeFile(targetFile, modified.join("\n"));
}
function transformLine(file, line, verbose) {
return (
// eslint-disable-next-line style/quotes
transformStaticImport(file, line, "'", verbose) ?? transformStaticImport(file, line, '"', verbose) ?? // eslint-disable-next-line style/quotes
transformExport(file, line, "'", verbose) ?? transformExport(file, line, '"', verbose) ?? line
);
}
function transformStaticImport(file, line, quote, verbose) {
const importPathMarker = `from ${quote}`;
const isStaticImport = line.includes("import ") && line.includes(`${importPathMarker}.`);
if (!isStaticImport) {
return void 0;
}
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}`;
const isStaticExport = line.includes("export ") && line.includes(`${exportPathMarker}.`);
if (!isStaticExport) {
return void 0;
}
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};`;
}
const typesDir = "dist/types";
const coverage = {
enabled: !!process.env.COVERAGE,
all: true,
include: ["src/**/*.*"],
provider: "v8"
};
const defaults = {
entry: "src/index.ts",
formats: ["es"],
manifest: "package.json",
cleanup: true
};
function mergeWithDefaults(options) {
return {
...defaults,
...options
};
}
function tsconfigPaths(options = {}) {
const { verbose } = mergeWithDefaults(options);
return {
name: "vite-plugin-lib:alias",
enforce: "pre",
config: async (config) => {
const tsconfigPath = path.resolve(config.root ?? ".", "tsconfig.json");
const { baseUrl, paths } = await readConfig(tsconfigPath);
if (!baseUrl || !paths) {
log("No paths found in tsconfig.json.");
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,
externalPackages
}) {
if (!externalPackages) {
log("Externalized all packages.");
}
return {
name: "vite-plugin-lib:build",
enforce: "pre",
config: async (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: externalPackages ?? [
/node_modules/,
...builtinModules,
/node:/,
...getDependencies(manifest)
]
}
}
};
}
};
}
function getDependencies(manifest) {
try {
const content = readFileSync(manifest, { encoding: "utf-8" });
const { dependencies = {}, peerDependencies = {} } = JSON.parse(content);
return Object.keys({ ...dependencies, ...peerDependencies });
} catch (error) {
const message = getErrorMessage(error);
logError(`Could not read ${c.green(manifest)}: ${message}`);
throw error;
}
}
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 void 0;
}
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 void 0;
}
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;
}
}
return void 0;
}
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(),
buildConfig(mergedOptions),
dts__default({
cleanVueFileName: true,
copyDtsFiles: true,
include: `${path.resolve(mergedOptions.entry, "..")}/**`,
outDir: typesDir,
staticImport: true,
afterBuild: async () => {
if (includesESFormat(mergedOptions.formats)) {
await generateMTSDeclarations(
typesDir,
mergedOptions.formats?.length === 1,
options.verbose
);
}
}
})
];
if (mergedOptions.cleanup) {
plugins.push(cleanup());
}
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) {
const message = getErrorMessage(error);
logError(`Could not read tsconfig.json: ${message}`);
throw error;
}
}
function includesESFormat(formats) {
return formats?.includes("es") ?? true;
}
function getErrorMessage(error) {
const isObject = typeof error === "object" && error !== null && "message" in error;
return isObject ? error.message : String(error);
}
function cleanup() {
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++;
});
log(`Removed ${deletedCount} temporary files.`);
}
};
}
export { cleanup, coverage, library, tsconfigPaths };