build.ts 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. /** Production documentation-site build with project-owned output preparation. */
  2. import { lstatSync, realpathSync, rmSync, unlinkSync } from 'node:fs'
  3. import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. import { build } from 'vitepress'
  6. const websiteRoot = resolve(import.meta.dirname)
  7. type DocSiteBuildOptions = NonNullable<Parameters<typeof build>[1]>
  8. function escapesRoot(root: string, candidate: string): boolean {
  9. const child = relative(root, candidate)
  10. return child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)
  11. }
  12. function nearestExistingAncestor(path: string): string {
  13. let ancestor = path
  14. for (;;) {
  15. if (lstatSync(ancestor, { throwIfNoEntry: false }) !== undefined) return ancestor
  16. const parent = dirname(ancestor)
  17. if (parent === ancestor) {
  18. throw new Error(`website/build: no existing ancestor found for ${JSON.stringify(path)}.`)
  19. }
  20. ancestor = parent
  21. }
  22. }
  23. /**
  24. * Remove one documentation build output without traversing a link-shaped output or an outside parent.
  25. * @param siteRoot - VitePress site root that owns the output.
  26. * @param outDir - Resolved VitePress output directory.
  27. * @throws When `outDir` is not a proper child of `siteRoot` or its existing parent resolves outside it.
  28. */
  29. export function cleanDocSiteOutput(siteRoot: string, outDir: string): void {
  30. const root = resolve(siteRoot)
  31. const output = resolve(outDir)
  32. const child = relative(root, output)
  33. if (child === '' || escapesRoot(root, output)) {
  34. throw new Error(`website/build: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`)
  35. }
  36. const realRoot = realpathSync(root)
  37. const realParent = realpathSync(nearestExistingAncestor(dirname(output)))
  38. if (escapesRoot(realRoot, realParent)) {
  39. throw new Error(`website/build: output directory ${JSON.stringify(output)} must resolve inside site root ${JSON.stringify(realRoot)}.`)
  40. }
  41. const outputStats = lstatSync(output, { throwIfNoEntry: false })
  42. if (outputStats?.isSymbolicLink()) {
  43. unlinkSync(output)
  44. return
  45. }
  46. rmSync(output, { recursive: true, force: true })
  47. }
  48. /**
  49. * Create VitePress build options that remove the resolved output directory before bundling.
  50. * @param siteRoot - VitePress site root to build.
  51. * @param mpa - Whether to use VitePress's multi-page application build.
  52. * @returns VitePress options with project-owned output preparation.
  53. */
  54. export function docSiteBuildOptions(siteRoot: string, mpa: boolean): DocSiteBuildOptions {
  55. const root = resolve(siteRoot)
  56. return {
  57. ...mpa ? { mpa: 'true' } : {},
  58. onAfterConfigResolve(siteConfig) {
  59. cleanDocSiteOutput(root, siteConfig.outDir)
  60. },
  61. }
  62. }
  63. async function buildDocSite(siteRoot: string, mpa: boolean): Promise<void> {
  64. const root = resolve(siteRoot)
  65. await build(root, docSiteBuildOptions(root, mpa))
  66. }
  67. function parseMpa(args: string[]): boolean {
  68. if (args.length === 0) return false
  69. if (args.length === 1 && args[0] === '--mpa') return true
  70. throw new Error(`website/build: expected no arguments or --mpa, got ${JSON.stringify(args)}.`)
  71. }
  72. const invokedPath = process.argv[1]
  73. if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) {
  74. await buildDocSite(websiteRoot, parseMpa(process.argv.slice(2)))
  75. }