UNPKG

vite-plugin-lib

Version:

Vite plugin for build configuration, automatic aliases, and type declarations.

317 lines (316 loc) 11.1 kB
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