You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vitepress/tsdown.config.ts

242 lines
7.4 KiB

import { readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { defineConfig, type Rolldown, type UserConfig } from 'tsdown'
import { vueSfcPlugin } from 'vue-sfc-transformer/rolldown'
const ROOT = import.meta.dirname
// wipe all of dist (not just the outDirs) once per invocation; the configs
// set `clean: false` so watch-mode rebuilds never clear it mid-session
rmSync(path.join(ROOT, 'dist'), {
recursive: true,
force: true,
maxRetries: 10
})
const normalizePath = (p: string): string => {
const normalized = p.replaceAll('\\', '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
const TYPES_DIR = normalizePath(path.join(ROOT, 'types')) + '/'
function isRootTypes(id: string, importer: string | undefined): boolean {
if (!importer || !/^\.\.?\//.test(id)) return false
const resolved = normalizePath(path.resolve(path.dirname(importer), id))
return resolved.startsWith(TYPES_DIR)
}
// keep .d.ts files under types/* external so module augmentations in the
// output still target the same files users reference, spelled with the .js
// extension so they resolve under node16 too
function rootTypesSpecifiers(): Rolldown.Plugin {
return {
name: 'vitepress:root-types-specifiers',
resolveId: {
order: 'pre',
handler(id, importer) {
if (
importer &&
/\.d\.[cm]?ts$/.test(importer) &&
isRootTypes(id, importer)
) {
return { id: id.replace(/\.js$/, '') + '.js', external: 'relative' }
}
}
}
}
}
// src/shared is compiled twice, once per environment, via gitignored copies.
// Copies are written only when their content differs — they are watched
// modules, so an unconditional write on every buildStart would retrigger the
// watcher in an endless loop.
function syncSharedFiles(
dest: 'client' | 'node',
track?: (file: string) => void
): void {
const src = path.join(ROOT, 'src/shared')
for (const entry of readdirSync(src, {
recursive: true,
encoding: 'utf8'
})) {
if (!entry.endsWith('.ts')) continue
const file = path.join(src, entry)
track?.(file)
const content = readFileSync(file)
const copy = path.join(ROOT, 'src', dest, entry)
let stale = true
try {
stale = !content.equals(readFileSync(copy))
} catch {}
if (stale) writeFileSync(copy, content)
}
}
// seed the copies now, before the entry globs below are resolved
syncSharedFiles('client')
syncSharedFiles('node')
// keeps the copies fresh across watch-mode rebuilds
function syncShared(dest: 'client' | 'node'): Rolldown.Plugin {
return {
name: 'vitepress:sync-shared',
buildStart() {
syncSharedFiles(dest, (file) => this.addWatchFile(file))
}
}
}
// Ships styles and fonts as-is, keeps css imports as relative specifiers in
// the output for the consumer's bundler, and drops them from declaration
// files where they would dangle.
function clientAssets(): Rolldown.Plugin {
const src = path.join(ROOT, 'src/client')
return {
name: 'vitepress:client-assets',
resolveId(id) {
if (id.endsWith('.css') && id[0] === '.') {
return { id, external: 'relative' }
}
},
buildStart() {
for (const entry of readdirSync(src, {
recursive: true,
encoding: 'utf8'
})) {
if (!/\.(css|woff2)$/.test(entry)) continue
const file = path.join(src, entry)
this.addWatchFile(file)
this.emitFile({
type: 'asset',
fileName: normalizePath(entry),
source: readFileSync(file)
})
}
}
}
}
// Declarations must resolve under node16, so relative specifiers need the .js
// extension: vue-tsc keeps the SFC's extensionless ones in .d.vue.ts files,
// and rolldown-plugin-dts drops the extension from chunk imports on Windows.
// Dangling side-effect css imports are removed along the way.
function fixDeclarationSpecifiers(): Rolldown.Plugin {
const stripCssImports = (code: string) =>
code.replace(/^\s*import\s+["'][^"']+\.css["'];?\s*\r?\n/gm, '')
const addJsExtensions = (code: string) =>
code.replace(
/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(\.\.?\/[^'"]*?)\2/g,
(match, keyword, quote, spec) =>
/\.[^/.]+$/.test(spec) ? match : `${keyword}${quote}${spec}.js${quote}`
)
const fix = (code: string) => addJsExtensions(stripCssImports(code))
return {
name: 'vitepress:fix-declaration-specifiers',
generateBundle(_options, bundle) {
for (const file of Object.values(bundle)) {
if (!/\.d\.(?:vue\.)?ts$/.test(file.fileName)) continue
if (file.type === 'chunk') file.code = fix(file.code)
else if (typeof file.source === 'string') file.source = fix(file.source)
}
}
}
}
// Rebuilds re-emit every output; dropping byte-identical files before they
// are written keeps their mtimes stable, so watchers of dist (the docs dev
// server's vite) only react to files that actually changed.
function skipUnchanged(): Rolldown.Plugin {
return {
name: 'vitepress:skip-unchanged',
generateBundle: {
order: 'post',
handler(options, bundle) {
for (const [key, file] of Object.entries(bundle)) {
const next =
file.type === 'chunk'
? Buffer.from(file.code)
: Buffer.from(file.source)
try {
const existing = readFileSync(
path.resolve(ROOT, options.dir ?? '.', file.fileName)
)
if (existing.equals(next)) delete bundle[key]
} catch {}
}
}
}
}
}
function withStableOutputs(config: UserConfig): UserConfig {
return {
...config,
clean: false,
plugins: [
...(config.plugins as Rolldown.Plugin[]),
fixDeclarationSpecifiers(),
skipUnchanged()
]
}
}
const client: UserConfig = {
entry: ['src/client/**/*.ts', '!src/client/**/*.d.ts'],
outDir: 'dist/client',
platform: 'neutral',
unbundle: true,
fixedExtension: false,
dts: { vue: true },
tsconfig: 'tsconfig.client.json',
deps: {
// self-imports and dev-server virtual modules, resolved at site build time
neverBundle: [
/^vitepress(?:\/|$)/,
'@siteData',
'@theme/index',
'@localSearchIndex'
]
},
plugins: [
syncShared('client'),
rootTypesSpecifiers(),
clientAssets(),
vueSfcPlugin({
srcDir: 'src/client',
cwd: ROOT,
tsconfig: './tsconfig.client.json'
})
],
checks: { pluginTimings: false }
}
const node: UserConfig = {
entry: ['src/node/index.ts', 'src/node/cli.ts'],
outDir: 'dist/node',
platform: 'node',
target: 'node22',
fixedExtension: false,
dts: true,
tsconfig: 'tsconfig.node.json',
// polyfill broken browser check from bundled deps
define: {
'navigator.userAgentData': 'undefined',
'navigator.userAgent': 'undefined'
},
deps: {
// devDependencies are bundled by design
onlyBundle: false,
// markdown-it types are provided by @types/markdown-it (a runtime dep)
dts: { neverBundle: /^markdown-it(?:\/|$)/ }
},
// code-level compression only — no name mangling or whitespace removal
minify: { compress: true, mangle: false, codegen: false },
outputOptions: { chunkFileNames: 'chunk-[hash].js' },
checks: { eval: false, pluginTimings: false },
plugins: [syncShared('node'), rootTypesSpecifiers()]
}
export default defineConfig([client, node].map(withStableOutputs))