UNPKG

mkdist

Version:

Lightweight file-to-file transformer

728 lines (716 loc) 22.7 kB
import { basename, resolve, normalize, extname, join, dirname } from 'pathe'; import fsp from 'node:fs/promises'; import defu from 'defu'; import { pipeline } from 'node:stream'; import { createReadStream, createWriteStream, statSync } from 'node:fs'; import { transform } from 'esbuild'; import jiti from 'jiti'; import { pathToFileURL } from 'node:url'; import cssnano from 'cssnano'; import autoprefixer from 'autoprefixer'; import postcss from 'postcss'; import postcssNested from 'postcss-nested'; import { findStaticImports, findExports, findTypeExports } from 'mlly'; import { createRequire } from 'node:module'; import { readPackageJSON } from 'pkg-types'; import { satisfies } from 'semver'; import { glob } from 'tinyglobby'; function copyFileWithStream(sourcePath, outPath) { const sourceStream = createReadStream(sourcePath); const outStream = createWriteStream(outPath); return new Promise((resolve, reject) => { pipeline(sourceStream, outStream, (error) => { if (error) { reject(error); } else { resolve(); } }); }); } const DECLARATION_RE = /\.d\.[cm]?ts$/; const CM_LETTER_RE = /(?<=\.)(c|m)(?=[jt]s$)/; const KNOWN_EXT_RE = /\.(c|m)?[jt]sx?$/; const TS_EXTS = /* @__PURE__ */ new Set([".ts", ".mts", ".cts"]); const jsLoader = async (input, { options }) => { if (!KNOWN_EXT_RE.test(input.path) || DECLARATION_RE.test(input.path)) { return; } const output = []; let contents = await input.getContents(); if (options.declaration && !input.srcPath?.match(DECLARATION_RE)) { const cm = input.srcPath?.match(CM_LETTER_RE)?.[0] || ""; const extension2 = `.d.${cm}ts`; output.push({ contents, srcPath: input.srcPath, path: input.path, extension: extension2, declaration: true }); } if (TS_EXTS.has(input.extension)) { contents = await transform(contents, { ...options.esbuild, loader: "ts" }).then((r) => r.code); } else if ([".tsx", ".jsx"].includes(input.extension)) { contents = await transform(contents, { loader: input.extension === ".tsx" ? "tsx" : "jsx", ...options.esbuild }).then((r) => r.code); } const isCjs = options.format === "cjs"; if (isCjs) { contents = jiti("").transform({ source: contents, retainLines: false }).replace(/^exports.default = /gm, "module.exports = ").replace(/^var _default = exports.default = /gm, "module.exports = ").replace("module.exports = void 0;", ""); } let extension = isCjs ? ".js" : ".mjs"; if (options.ext) { extension = options.ext.startsWith(".") ? options.ext : `.${options.ext}`; } output.push({ contents, path: input.path, extension }); return output; }; function defineVueLoader(options) { const blockLoaders = options?.blockLoaders || {}; return async (input, context) => { if (input.extension !== ".vue") { return; } const { compileScript, parse } = await import('vue/compiler-sfc'); let modified = false; let fakeScriptBlock = false; const raw = await input.getContents(); const sfc = parse(raw, { filename: input.srcPath, ignoreEmpty: true }); if (sfc.errors.length > 0) { for (const error of sfc.errors) { console.error(error); } return; } const output = []; const addOutput = (...files) => output.push(...files); const blocks = [ sfc.descriptor.template, ...sfc.descriptor.styles, ...sfc.descriptor.customBlocks ].filter((item) => !!item); if (sfc.descriptor.script || sfc.descriptor.scriptSetup) { if (sfc.descriptor.scriptSetup && sfc.descriptor.scriptSetup.lang) { const merged = compileScript(sfc.descriptor, { id: input.srcPath }); merged.setup = false; merged.attrs = toOmit(merged.attrs, "setup"); blocks.unshift(merged); } else { const scriptBlocks = [ sfc.descriptor.script, sfc.descriptor.scriptSetup ].filter((item) => !!item); blocks.unshift(...scriptBlocks); } } else { blocks.unshift({ type: "script", content: "export default {}", attrs: {} }); fakeScriptBlock = true; } const results = await Promise.all( blocks.map(async (data) => { const blockLoader = blockLoaders[data.type]; const result = await blockLoader?.(data, { ...context, rawInput: input, addOutput }); if (result) { modified = true; } return result || data; }) ); if (!modified) { return; } const contents = results.map((block) => { if (block.type === "script" && fakeScriptBlock) { return void 0; } const attrs = Object.entries(block.attrs).map(([key, value]) => { if (!value) { return void 0; } return value === true ? key : `${key}="${value}"`; }).filter((item) => !!item).join(" "); const header = `<${`${block.type} ${attrs}`.trim()}>`; const footer = `</${block.type}>`; return `${header} ${cleanupBreakLine(block.content)} ${footer} `; }).filter((item) => !!item).join("\n"); addOutput({ path: input.path, srcPath: input.srcPath, extension: ".vue", contents, declaration: false }); return output; }; } function defineDefaultBlockLoader(options) { return async (block, { loadFile, rawInput, addOutput }) => { if (options.type !== block.type) { return; } const lang = typeof block.attrs.lang === "string" ? block.attrs.lang : options.outputLang; const extension = `.${lang}`; const files = await loadFile({ getContents: () => block.content, path: `${rawInput.path}${extension}`, srcPath: `${rawInput.srcPath}${extension}`, extension }) || []; const blockOutputFile = files.find( (f) => f.extension === `.${options.outputLang}` || options.validExtensions?.includes(f.extension) ); if (!blockOutputFile?.contents) { return; } addOutput(...files.filter((f) => f !== blockOutputFile)); return { type: block.type, attrs: toOmit(block.attrs, "lang"), content: blockOutputFile.contents }; }; } const styleLoader = defineDefaultBlockLoader({ outputLang: "css", type: "style" }); const scriptLoader = defineDefaultBlockLoader({ outputLang: "js", type: "script", validExtensions: [".js", ".mjs"] }); const vueLoader = defineVueLoader({ blockLoaders: { style: styleLoader, script: scriptLoader } }); function cleanupBreakLine(str) { return str.replaceAll(/(\n\n)\n+/g, "\n\n").replace(/^\s*\n|\n\s*$/g, ""); } function toOmit(record, toRemove) { return Object.fromEntries( Object.entries(record).filter(([key]) => key !== toRemove) ); } const sassLoader = async (input) => { if (![".sass", ".scss"].includes(input.extension)) { return; } if (basename(input.srcPath).startsWith("_")) { return [ { contents: "", path: input.path, skip: true } ]; } const compileString = await import('sass').then( (r) => r.compileString || r.default.compileString ); const output = []; const contents = await input.getContents(); output.push({ contents: compileString(contents, { loadPaths: ["node_modules"], url: pathToFileURL(input.srcPath) }).css, path: input.path, extension: ".css" }); return output; }; const postcssLoader = async (input, ctx) => { if (ctx.options.postcss === false || ![".css"].includes(input.extension)) { return; } const output = []; const contents = await input.getContents(); const transformed = await postcss( [ ctx.options.postcss?.nested !== false && postcssNested(ctx.options.postcss?.nested), ctx.options.postcss?.autoprefixer !== false && autoprefixer(ctx.options.postcss?.autoprefixer), ctx.options.postcss?.cssnano !== false && cssnano(ctx.options.postcss?.cssnano), ...ctx.options.postcss?.plugins || [] ].filter(Boolean) ).process(contents, { ...ctx.options.postcss?.processOptions, from: input.srcPath }); output.push({ contents: transformed.content, path: input.path, extension: ".css" }); return output; }; const loaders = { js: jsLoader, vue: vueLoader, sass: sassLoader, postcss: postcssLoader }; const defaultLoaders = ["js", "vue", "sass", "postcss"]; function resolveLoader(loader) { if (typeof loader === "string") { return loaders[loader]; } return loader; } function resolveLoaders(loaders2 = defaultLoaders) { return loaders2.map((loaderName) => { const _loader = resolveLoader(loaderName); if (!_loader) { console.warn("Unknown loader:", loaderName); } return _loader; }).filter(Boolean); } function createLoader(loaderOptions = {}) { const loaders = resolveLoaders(loaderOptions.loaders); const loadFile = async function(input) { const context = { loadFile, options: loaderOptions }; for (const loader of loaders) { const outputs = await loader(input, context); if (outputs?.length) { return outputs; } } return [ { path: input.path, srcPath: input.srcPath, raw: true } ]; }; return { loadFile }; } async function normalizeCompilerOptions(_options) { const ts = await import('typescript').then((r) => r.default || r); return ts.convertCompilerOptionsFromJson(_options, process.cwd()).options; } async function getDeclarations(vfs, opts) { const ts = await import('typescript').then((r) => r.default || r); const inputFiles = [...vfs.keys()]; const tsHost = ts.createCompilerHost(opts.typescript.compilerOptions); tsHost.writeFile = (fileName, declaration) => { vfs.set(fileName, declaration); }; const _readFile = tsHost.readFile; tsHost.readFile = (filename) => { if (vfs.has(filename)) { return vfs.get(filename); } return _readFile(filename); }; const program = ts.createProgram( inputFiles, opts.typescript.compilerOptions, tsHost ); const result = program.emit(); const output = extractDeclarations(vfs, inputFiles, opts); augmentWithDiagnostics(result, output, tsHost, ts); return output; } const JS_EXT_RE = /\.(m|c)?(ts|js)$/; const JSX_EXT_RE = /\.(m|c)?(ts|js)x?$/; const RELATIVE_RE = /^\.{1,2}[/\\]/; function extractDeclarations(vfs, inputFiles, opts) { const output = {}; for (const filename of inputFiles) { const dtsFilename = filename.replace(JSX_EXT_RE, ".d.$1ts"); let contents = vfs.get(dtsFilename) || ""; if (opts?.addRelativeDeclarationExtensions) { const ext = filename.match(JS_EXT_RE)?.[0].replace(/ts$/, "js") || ".js"; const imports = findStaticImports(contents); const exports = findExports(contents); const typeExports = findTypeExports(contents); for (const spec of [...exports, ...typeExports, ...imports]) { if (!spec.specifier || !RELATIVE_RE.test(spec.specifier)) { continue; } const srcPath = resolve(filename, "..", spec.specifier); const srcDtsPath = srcPath + ext.replace(JS_EXT_RE, ".d.$1ts"); let specifier = spec.specifier; try { if (!vfs.get(srcDtsPath)) { const stat = statSync(srcPath); if (stat.isDirectory()) { specifier += "/index"; } } } catch { } contents = contents.replace( spec.code, spec.code.replace(spec.specifier, specifier + ext) ); } } output[filename] = { contents }; vfs.delete(filename); } return output; } function augmentWithDiagnostics(result, output, tsHost, ts) { if (result.diagnostics?.length) { for (const diagnostic of result.diagnostics) { const filename = diagnostic.file?.fileName; if (filename in output) { output[filename].errors = output[filename].errors || []; output[filename].errors.push( new TypeError(ts.formatDiagnostics([diagnostic], tsHost), { cause: diagnostic }) ); } } console.error(ts.formatDiagnostics(result.diagnostics, tsHost)); } } const require = createRequire(import.meta.url); async function getVueDeclarations(vfs, opts) { const fileMapping = getFileMapping(vfs); const srcFiles = Object.keys(fileMapping); const originFiles = Object.values(fileMapping); if (originFiles.length === 0) { return; } const pkgInfo = await readPackageJSON("vue-tsc").catch(() => { }); if (!pkgInfo) { console.warn( "[mkdist] Please install `vue-tsc` to generate Vue SFC declarations." ); return; } const { version } = pkgInfo; let output; switch (true) { case satisfies(version, "^1.8.27"): { output = await emitVueTscV1(vfs, srcFiles, originFiles, opts); break; } case satisfies(version, "~v2.0.0"): { output = await emitVueTscV2(vfs, srcFiles, originFiles, opts); break; } default: { output = await emitVueTscLatest(vfs, srcFiles, originFiles, opts); } } return output; } const SFC_EXT_RE = /\.vue\.[cm]?[jt]s$/; function getFileMapping(vfs) { const files = /* @__PURE__ */ Object.create(null); for (const [srcPath] of vfs) { if (SFC_EXT_RE.test(srcPath)) { files[srcPath.replace(SFC_EXT_RE, ".vue")] = srcPath; } } return files; } async function emitVueTscV1(vfs, inputFiles, originFiles, opts) { const vueTsc = await import('vue-tsc').then((r) => r.default || r).catch(() => void 0); const ts = require("typescript"); const tsHost = ts.createCompilerHost(opts.typescript.compilerOptions); const _tsSysWriteFile = ts.sys.writeFile; ts.sys.writeFile = (filename, content) => { vfs.set(filename, content); }; const _tsSysReadFile = ts.sys.readFile; ts.sys.readFile = (filename, encoding) => { if (vfs.has(filename)) { return vfs.get(filename); } return _tsSysReadFile(filename, encoding); }; try { const program = vueTsc.createProgram({ rootNames: inputFiles, options: opts.typescript.compilerOptions, host: tsHost }); const result = program.emit(); const output = extractDeclarations(vfs, originFiles, opts); augmentWithDiagnostics(result, output, tsHost, ts); return output; } finally { ts.sys.writeFile = _tsSysWriteFile; ts.sys.readFile = _tsSysReadFile; } } async function emitVueTscV2(vfs, inputFiles, originFiles, opts) { const { resolve: resolveModule } = await import('mlly'); const ts = await import('typescript').then( (r) => r.default || r ); const vueTsc = await import('vue-tsc'); const requireFromVueTsc = createRequire(await resolveModule("vue-tsc")); const vueLanguageCore = requireFromVueTsc("@vue/language-core"); const volarTs = requireFromVueTsc("@volar/typescript"); const tsHost = ts.createCompilerHost(opts.typescript.compilerOptions); tsHost.writeFile = (filename, content) => { vfs.set(filename, vueTsc.removeEmitGlobalTypes(content)); }; const _tsReadFile = tsHost.readFile.bind(tsHost); tsHost.readFile = (filename) => { if (vfs.has(filename)) { return vfs.get(filename); } return _tsReadFile(filename); }; const _tsFileExist = tsHost.fileExists.bind(tsHost); tsHost.fileExists = (filename) => { return vfs.has(filename) || _tsFileExist(filename); }; const programOptions = { rootNames: inputFiles, options: opts.typescript.compilerOptions, host: tsHost }; const createProgram = volarTs.proxyCreateProgram( ts, ts.createProgram, (ts2, options) => { const vueLanguagePlugin = vueLanguageCore.createVueLanguagePlugin( ts2, (id) => id, () => "", (fileName) => { const fileMap = /* @__PURE__ */ new Set(); for (const vueFileName of options.rootNames.map( (rootName) => normalize(rootName) )) { fileMap.add(vueFileName); } return fileMap.has(fileName); }, options.options, vueLanguageCore.resolveVueCompilerOptions({}) ); return [vueLanguagePlugin]; } ); const program = createProgram(programOptions); const result = program.emit(); const output = extractDeclarations(vfs, originFiles, opts); augmentWithDiagnostics(result, output, tsHost, ts); return output; } async function emitVueTscLatest(vfs, inputFiles, originFiles, opts) { const { resolve: resolveModule } = await import('mlly'); const ts = await import('typescript').then( (r) => r.default || r ); const requireFromVueTsc = createRequire(await resolveModule("vue-tsc")); const vueLanguageCore = requireFromVueTsc("@vue/language-core"); const volarTs = requireFromVueTsc("@volar/typescript"); const tsHost = ts.createCompilerHost(opts.typescript.compilerOptions); tsHost.writeFile = (filename, content) => { vfs.set(filename, content); }; const _tsReadFile = tsHost.readFile.bind(tsHost); tsHost.readFile = (filename) => { if (vfs.has(filename)) { return vfs.get(filename); } return _tsReadFile(filename); }; const _tsFileExist = tsHost.fileExists.bind(tsHost); tsHost.fileExists = (filename) => { return vfs.has(filename) || _tsFileExist(filename); }; const programOptions = { rootNames: inputFiles, options: opts.typescript.compilerOptions, host: tsHost }; const createProgram = volarTs.proxyCreateProgram( ts, ts.createProgram, (ts2, options) => { const vueLanguagePlugin = vueLanguageCore.createVueLanguagePlugin( ts2, options.options, vueLanguageCore.createParsedCommandLineByJson( ts2, ts2.sys, opts.rootDir, {}, void 0, true ).vueOptions, (id) => id ); return [vueLanguagePlugin]; } ); const program = createProgram(programOptions); const result = program.emit(); const output = extractDeclarations(vfs, originFiles, opts); augmentWithDiagnostics(result, output, tsHost, ts); return output; } async function mkdist(options = {}) { options.rootDir = resolve(process.cwd(), options.rootDir || "."); options.srcDir = resolve(options.rootDir, options.srcDir || "src"); options.distDir = resolve(options.rootDir, options.distDir || "dist"); if (options.cleanDist !== false) { await fsp.unlink(options.distDir).catch(() => { }); await fsp.rm(options.distDir, { recursive: true, force: true }); await fsp.mkdir(options.distDir, { recursive: true }); } const filePaths = await glob(options.pattern || "**", { absolute: false, ignore: ["**/node_modules", "**/coverage", "**/.git"], cwd: options.srcDir, dot: true, ...options.globOptions }); const files = filePaths.map((path) => { const sourcePath = resolve(options.srcDir, path); return { path, srcPath: sourcePath, extension: extname(path), getContents: () => fsp.readFile(sourcePath, { encoding: "utf8" }) }; }); options.typescript ||= {}; if (options.typescript.compilerOptions) { options.typescript.compilerOptions = await normalizeCompilerOptions( options.typescript.compilerOptions ); } options.typescript.compilerOptions = defu( { noEmit: false }, options.typescript.compilerOptions, { allowJs: true, declaration: true, skipLibCheck: true, strictNullChecks: true, emitDeclarationOnly: true, allowImportingTsExtensions: true, allowNonTsExtensions: true } ); const { loadFile } = createLoader(options); const outputs = []; for (const file of files) { outputs.push(...await loadFile(file) || []); } for (const output of outputs.filter((o) => o.extension)) { const renamed = basename(output.path, extname(output.path)) + output.extension; output.path = join(dirname(output.path), renamed); if (outputs.some((o) => o !== output && o.path === output.path)) { output.skip = true; } } const dtsOutputs = outputs.filter((o) => o.declaration && !o.skip); if (dtsOutputs.length > 0) { const vfs = new Map(dtsOutputs.map((o) => [o.srcPath, o.contents || ""])); const declarations = /* @__PURE__ */ Object.create(null); for (const loader of [getVueDeclarations, getDeclarations]) { Object.assign(declarations, await loader(vfs, options)); } for (const output of dtsOutputs) { const result = declarations[output.srcPath]; output.contents = result?.contents || ""; if (result.errors) { output.errors = result.errors; } } } const outPaths = new Set(outputs.map((o) => o.path)); const resolveId = (from, id = "", resolveExtensions) => { if (!id.startsWith(".")) { return id; } for (const extension of resolveExtensions) { if (outPaths.has(join(dirname(from), id + extension))) { return id + extension; } } return id; }; const esmResolveExtensions = [ "", "/index.mjs", "/index.js", ".mjs", ".ts", ".js" ]; for (const output of outputs.filter( (o) => o.extension === ".mjs" || o.extension === ".js" )) { output.contents = output.contents.replace( /(import|export)(\s+(?:.+|{[\s\w,]+})\s+from\s+["'])(.*)(["'])/g, (_, type, head, id, tail) => type + head + resolveId(output.path, id, esmResolveExtensions) + tail ).replace( /import\((["'])(.*)(["'])\)/g, (_, head, id, tail) => "import(" + head + resolveId(output.path, id, esmResolveExtensions) + tail + ")" ); } const cjsResolveExtensions = ["", "/index.cjs", ".cjs"]; for (const output of outputs.filter((o) => o.extension === ".cjs")) { output.contents = output.contents.replace( /require\((["'])(.*)(["'])\)/g, (_, head, id, tail) => "require(" + head + resolveId(output.path, id, cjsResolveExtensions) + tail + ")" ); } const writtenFiles = []; const errors = []; await Promise.all( outputs.filter((o) => !o.skip).map(async (output) => { const outFile = join(options.distDir, output.path); await fsp.mkdir(dirname(outFile), { recursive: true }); await (output.raw ? copyFileWithStream(output.srcPath, outFile) : fsp.writeFile(outFile, output.contents, "utf8")); writtenFiles.push(outFile); if (output.errors) { errors.push({ filename: outFile, errors: output.errors }); } }) ); return { errors, writtenFiles }; } export { mkdist as m };