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

686 lines
24 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 {}
}
}
}
}
}
// The node bundle inlines its devDependencies by design, so the published
// artifact redistributes third-party code. Every module that ends up in an
// emitted chunk is traced back to its package here, and the aggregate
// notices — license texts with their copyright lines, plus any NOTICE files
// (Apache-2.0 §4(d)) — are written to THIRD-PARTY-NOTICES.md. npm does not
// auto-include that name, so it ships only via the files list in
// package.json; `pnpm check` fails when the committed copy goes stale. The
// file is regenerated by the build — do not edit it by hand. Entries with
// byte-identical texts are grouped: the copyright notices the licenses
// require live inside the quoted texts, so grouping loses nothing.
const NOTICES_FILE = 'THIRD-PARTY-NOTICES.md'
// licenses whose redistribution obligations this file's mechanism fully
// discharges: carrying the verbatim license text and copyright notice along
// (plus NOTICE propagation for Apache-2.0, and the modifications statement
// in the header). Anything else fails the build so its obligations get
// reviewed deliberately instead of silently shipped — copyleft (GPL),
// source-offer duties (MPL/LGPL), unparseable ids, and also e.g. Zlib and
// Python-2.0, whose mark-your-changes clauses an aggregate notices file
// cannot satisfy on its own.
const PERMITTED_LICENSES = new Set([
'0BSD',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'BlueOak-1.0.0',
'CC0-1.0',
'ISC',
'MIT',
'OFL-1.1',
'Unlicense'
])
// canonical license bodies for packages that declare an id but ship no text
// file. MIT/ISC require the copyright and permission notice to accompany
// copies, so a bare identifier would not be compliant; the reconstruction
// pairs the fixed license wording with the package's own author line and is
// surfaced as a warning on every full build.
const LICENSE_TEMPLATES: Record<string, (holder: string) => string> = {
MIT: (holder) => `MIT License
Copyright (c) ${holder}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.`,
ISC: (holder) => `ISC License
Copyright (c) ${holder}
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.`
}
// copyright holders for packages that ship neither a license file nor an
// author field, verified against their repositories
const AUTHOR_FALLBACKS: Record<string, string> = {
'@polka/compression': 'Luke Edwards <luke@lukeed.com> (https://lukeed.com)'
}
interface BundledPackage {
name: string
version?: string
license: string
authors?: string
repository?: string
licenseText: string
noticeText?: string
reconstructed?: boolean
}
// third-party content vendored into src rather than resolved from
// node_modules: the Inter font files shipped under theme-default/fonts
const VENDORED_PACKAGES: BundledPackage[] = [
{
name: 'Inter',
license: 'OFL-1.1',
authors: 'The Inter Project Authors',
repository: 'https://github.com/rsms/inter',
licenseText: readFileSync(
path.join(ROOT, 'src/client/theme-default/fonts/LICENSE.txt'),
'utf8'
)
.replaceAll('\r\n', '\n')
.trim()
}
]
// localeCompare consults the host locale; the output must be byte-stable
// across machines, so sort by code units everywhere
const compareStrings = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const blockquote = (text: string) =>
text
.split('\n')
.map((l) => (l ? `> ${l}` : '>'))
.join('\n')
function formatPerson(person: unknown): string | undefined {
if (typeof person === 'string') return person
const { name, email, url } = (person ?? {}) as Record<string, string>
if (!name) return undefined
return name + (email ? ` <${email}>` : '') + (url ? ` (${url})` : '')
}
// render repository fields as plain https URLs, whatever form the manifest
// uses (git+/git@/ssh, github: and owner/repo shorthands)
function normalizeRepoUrl(url: string): string {
const repo = url
.replace(/^git\+/, '')
.replace(/\.git$/, '')
.replace(/^git@([^:]+):/, 'https://$1/')
.replace(/^(?:git|ssh):\/\/(?:[^@/]+@)?/, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/^gitlab:/, 'https://gitlab.com/')
.replace(/^bitbucket:/, 'https://bitbucket.org/')
return /^[\w.-]+\/[\w.-]+$/.test(repo) ? `https://github.com/${repo}` : repo
}
type PackageResolution = { info: BundledPackage } | { error: string } | null
const packageCache = new Map<string, PackageResolution>()
// a module's package root is everything up to the first path segment (two
// for scoped packages) after the last node_modules/ in its id — this holds
// for pnpm's .pnpm layout too, where the real package dir always sits under
// a nested node_modules/
function resolveBundledPackage(moduleId: string): PackageResolution {
if (moduleId.startsWith('\0')) return null
const id = moduleId.replaceAll('\\', '/').replace(/\?.*$/, '')
const base = id.lastIndexOf('/node_modules/')
if (base === -1) return null
const segments = id.slice(base + '/node_modules/'.length).split('/')
const name =
segments[0][0] === '@' ? segments.slice(0, 2).join('/') : segments[0]
if (name[0] === '.') return null
const root = id.slice(0, base) + '/node_modules/' + name
const cached = packageCache.get(root)
if (cached !== undefined) return cached
const resolution = readPackageNotices(root, name)
packageCache.set(root, resolution)
return resolution
}
function readPackageNotices(root: string, dirName: string): PackageResolution {
let pkg: Record<string, any>
try {
pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
} catch {
return { error: `${dirName}: no readable package.json at ${root}` }
}
const name: string = pkg.name ?? dirName
const license: string | undefined =
typeof pkg.license === 'string'
? pkg.license
: (pkg.license?.type ??
(Array.isArray(pkg.licenses)
? pkg.licenses
.map((l: any) => (typeof l === 'string' ? l : l?.type))
.filter(Boolean)
.join(' OR ')
: undefined))
if (!license) {
return { error: `${name}: declares no license in its package.json` }
}
// an SPDX OR expression satisfies the policy if any alternative does; AND
// and WITH combine obligations, so those always need review as a whole
const alternatives = /\s(?:AND|WITH)\s/.test(license)
? []
: license.replace(/[()]/g, '').split(/\s+OR\s+/)
if (!alternatives.some((l) => PERMITTED_LICENSES.has(l.trim()))) {
return {
error:
`${name} is licensed under "${license}", which is not in ` +
'PERMITTED_LICENSES — review its obligations before extending the set'
}
}
const people = [
pkg.author,
...(Array.isArray(pkg.contributors) ? pkg.contributors : []),
...(Array.isArray(pkg.maintainers) ? pkg.maintainers : [])
]
.map(formatPerson)
.filter((p): p is string => !!p)
const authors = [...new Set(people)].join(', ') || AUTHOR_FALLBACKS[name]
const repositoryField =
typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url
const repository = repositoryField
? normalizeRepoUrl(repositoryField)
: undefined
const tryReadText = (file: string): string | undefined => {
try {
return readFileSync(path.join(root, file), 'utf8')
.replace(/^\uFEFF/, '')
.replaceAll('\r\n', '\n')
.trim()
} catch {
return undefined
}
}
let entries: string[]
try {
entries = readdirSync(root)
} catch {
entries = []
}
// NOTICE files carry their own propagation obligation and ride along even
// when a license file exists too
const noticeText =
entries
.filter((f) => /^notices?(?:$|[-._])/i.test(f))
.sort(compareStrings)
.map(tryReadText)
.filter(Boolean)
.join('\n\n') || undefined
const rank = (f: string) => (/^licen[cs]e/i.test(f) ? 0 : 1)
const licenseFile = entries
.filter((f) =>
/^(?:(?:un)?licen[cs]e|copying|(?:mit|bsd|apache|isc)[-_.]licen[cs]e)(?:$|[-._])/i.test(
f
)
)
.sort((a, b) => rank(a) - rank(b) || compareStrings(a, b))[0]
let licenseText = licenseFile ? tryReadText(licenseFile) : undefined
let reconstructed = false
if (!licenseText) {
const template = LICENSE_TEMPLATES[license]
if (!template || !authors) {
return {
error:
`${name} (${license}) ships no license text and it cannot be ` +
'reconstructed — vendor its license text in tsdown.config.ts'
}
}
licenseText = template(authors)
reconstructed = true
}
return {
info: {
name,
version: pkg.version,
license,
authors,
repository,
licenseText,
noticeText,
reconstructed
}
}
}
function renderNotices(packages: BundledPackage[]): string {
// group packages whose texts match byte-for-byte (same license wording
// and same copyright holder), so shared boilerplate appears once
interface NoticeGroup {
names: string[]
licenses: string[]
authors: string[]
repositories: string[]
licenseText: string
noticeText?: string
}
const groups = new Map<string, NoticeGroup>()
for (const p of packages) {
// texts without a copyright notice line (BlueOak, CC0, …) cannot name
// their holders themselves, so different holders must not merge — the
// By: line is the only attribution such an entry has
const hasHolder = /copyright\s+(?:\(c\)|©|[0-9])/i.test(p.licenseText)
const key =
p.licenseText +
'\0' +
(p.noticeText ?? '') +
(hasHolder ? '' : '\0' + (p.authors ?? p.name))
let group = groups.get(key)
if (!group) {
group = {
names: [],
licenses: [],
authors: [],
repositories: [],
licenseText: p.licenseText,
noticeText: p.noticeText
}
groups.set(key, group)
}
if (!group.names.includes(p.name)) group.names.push(p.name)
if (!group.licenses.includes(p.license)) group.licenses.push(p.license)
if (p.authors && !group.authors.includes(p.authors)) {
group.authors.push(p.authors)
}
if (p.repository && !group.repositories.includes(p.repository)) {
group.repositories.push(p.repository)
}
}
const sorted = [...groups.values()]
for (const g of sorted) {
g.names.sort(compareStrings)
g.licenses.sort(compareStrings)
g.authors.sort(compareStrings)
g.repositories.sort(compareStrings)
}
// same leading name can occur twice when two bundled versions of a package
// carry different texts; tie-break on the text to keep the order stable
sorted.sort(
(a, b) =>
compareStrings(a.names[0], b.names[0]) ||
compareStrings(a.licenseText, b.licenseText) ||
compareStrings(a.noticeText ?? '', b.noticeText ?? '')
)
const ids = [...new Set(packages.map((p) => p.license))].sort(compareStrings)
let out =
'# Third-party notices\n\n' +
'VitePress is published under the MIT license (see LICENSE). The ' +
'published vitepress package additionally bundles code and font files ' +
'from the projects listed below, used under the following licenses: ' +
`${ids.join(', ')}. The bundled code has been mechanically ` +
'transformed from its original source form (concatenated, tree-shaken, ' +
'and minified), and the font files are subsets of their original ' +
'releases.\n'
for (const g of sorted) {
out += `\n## ${g.names.join(', ')}\n\n`
out += `License: ${g.licenses.join(', ')}\n`
if (g.authors.length) out += `By: ${g.authors.join(', ')}\n`
if (g.repositories.length) {
out += `Repository: ${g.repositories.join(', ')}\n`
}
out += '\n' + blockquote(g.licenseText) + '\n'
if (g.noticeText) out += '\nNotice:\n\n' + blockquote(g.noticeText) + '\n'
}
return out
}
const collectedPackages = new Map<string, Map<string, BundledPackage>>()
// assigned where the configs are assembled at the bottom of this file; the
// notices file is only written once every config has reported, so building
// a lone config can never shrink it to a subset
let expectedOutputs = 0
// …and a build where some output never reports (a lone config, or drift in
// how the configs are assembled) must fail loudly instead of silently
// leaving a stale file behind. Watch mode never collects, so it never trips
// this.
process.on('beforeExit', () => {
if (collectedPackages.size > 0 && collectedPackages.size < expectedOutputs) {
console.error(
`${NOTICES_FILE} was not regenerated: only ` +
`${collectedPackages.size} of ${expectedOutputs} build outputs reported`
)
process.exitCode = 1
}
})
function thirdPartyNotices(): Rolldown.Plugin {
return {
name: 'vitepress:third-party-notices',
// default-order generateBundle runs before skipUnchanged's post handler
// prunes byte-identical files, so the module lists are still complete
generateBundle(options, bundle) {
// build-only: dev rebuilds shouldn't churn a tracked file — full
// builds and the check script keep it fresh
if (this.meta.watchMode) return
const errors = new Set<string>()
const found = new Map<string, BundledPackage>()
for (const file of Object.values(bundle)) {
if (file.type !== 'chunk') continue
for (const [id, mod] of Object.entries(file.modules)) {
// fully tree-shaken modules contribute nothing to the output
// (declaration chunks report real lengths too)
if (mod.renderedLength === 0) continue
const resolution = resolveBundledPackage(id)
if (!resolution) continue
if ('error' in resolution) {
errors.add(resolution.error)
continue
}
const { info } = resolution
found.set(`${info.name}@${info.version}`, info)
}
}
if (errors.size) {
this.error([...errors].sort(compareStrings).join('\n'))
}
collectedPackages.set(options.dir ?? '.', found)
if (collectedPackages.size < expectedOutputs) return
const union = new Map<string, BundledPackage>()
for (const packages of collectedPackages.values()) {
for (const [key, info] of packages) union.set(key, info)
}
const reconstructed = [...union.values()]
.filter((p) => p.reconstructed)
.map((p) => p.name)
.sort(compareStrings)
if (reconstructed.length) {
console.warn(
`license text for ${reconstructed.join(', ')} is reconstructed ` +
'from their manifests — they ship none'
)
}
const content = renderNotices([...VENDORED_PACKAGES, ...union.values()])
const dest = path.join(ROOT, NOTICES_FILE)
// same mtime-stability contract as skipUnchanged above
try {
if (readFileSync(dest, 'utf8') === content) return
} catch {}
writeFileSync(dest, content)
console.warn(`\n${NOTICES_FILE} updated — commit the regenerated file`)
}
}
}
function withStableOutputs(config: UserConfig): UserConfig {
return {
...config,
clean: false,
plugins: [
...(config.plugins as Rolldown.Plugin[]),
fixDeclarationSpecifiers(),
thirdPartyNotices(),
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()]
}
const configs = [client, node].map(withStableOutputs)
expectedOutputs = configs.length
export default defineConfig(configs)