#!/usr/bin/env node import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import {format, inspect} from 'node:util'; import fs from 'fs-extra'; import parser from 'yargs-parser'; import {bold, dim, green, magenta, red} from 'colorette'; import {nanoid} from 'nanoid'; import {resolveConfig} from 'vitepress'; import {default as createExec} from '../utils/create-exec.js'; import {default as detectRuntime} from '../utils/detect-runtime.js'; import {default as getStdOut} from '../utils/parse-stdout.js'; import {default as getBranch} from '../utils/get-branch.js'; import {default as getTags} from '../utils/get-tags.js'; import {default as traverseUp} from '../utils/traverse-up.js'; import Debug from 'debug'; // debugger const debug = Debug('@lando/mvb'); // eslint-disable-line // enable debug if applicable if (process.argv.includes('--debug') || process.env.RUNNER_DEBUG === '1') { Debug.enable(process.env.DEBUG ?? '*'); } // kenny loggin utils const log = (message = '', ...args) => { message = typeof message === 'string' ? message : inspect(message); if (debug.enabled) debug(message, ...args); else process.stdout.write(format(message, ...args) + '\n'); }; // lets start by getting argv const argv = parser(process.argv.slice(2)); // source dir const srcDir = argv._[0] ?? 'docs'; // orginal absolute path to source const osource = path.resolve(process.cwd(), srcDir); // help const help = argv.h || argv.help; // get site config // @TODO: if no site then throw error? how do we determine that? const siteConfig = await resolveConfig(osource, 'build', 'production'); const {site} = siteConfig; // build default options const defaults = { ag: site?.themeConfig?.multiVersionBuild?.ag ?? false, base: site?.base ?? '/', build: site?.themeConfig?.multiVersionBuild?.build ?? 'stable', cache: site?.themeConfig?.multiVersionBuild?.cache ?? true, match: site?.themeConfig?.multiVersionBuild?.match ?? 'v[0-9].*', outDir: path.relative(process.cwd(), siteConfig.outDir) ?? './.vitepress/dist', runtime: detectRuntime() === 'bun' ? 'bun' : 'npm', satisfies: site?.themeConfig?.multiVersionBuild?.satisfies ?? '*', versionBase: site?.themeConfig?.multiVersionBuild?.base ?? '/v/', }; // show help/usage if requested if (help) { log(` Usage: ${dim('[CI=1]')} ${bold(`${path.basename(process.argv[1])} `)} ${dim('[--base ] [--build ] [--match ""] [--no-cache] [--out-dir ] [--satisifes ""] [--version-base ] [--debug] [--help]')} ${green('Options')}: --base sets site base ${dim(`[default: ${defaults.base}`)}] --build uses this version alias for main/root build ${dim(`[default: ${defaults.build}`)}] --match filters versions from git tags ${dim(`[default: "${defaults.match}"`)}] --no-cache builds versioned docs every build ${dim(`[default: "${!defaults.cache}"`)}] --out-dir builds into this location ${dim(`[default: ${defaults.outDir}`)}] --runtime builds versioned docs using npx or bunx ${dim(`[default: "${defaults.runtime}"`)}] --satisfies builds versioned docs in this semantic range ${dim(`[default: "${defaults.satisfies}"`)}] --version-base builds versioned docs in this location ${dim(`[default: ${defaults.versionBase}`)}] --debug shows debug messages -h, --help displays this message ${green('Environment Variables')}: CI installs in CI mode (e.g. does not prompt for user input) `); process.exit(0); } // intial feedback debug('received argv %o', argv); debug('default options %o', defaults); log('found site %s at %s', magenta(site.title), magenta(osource)); // resolve options with argv input const options = { ...defaults, ...argv, cacheDir: path.resolve(process.env?.NETLIFY === 'true' ? '/opt/build/cache' : siteConfig.cacheDir, '@lando', 'mvb'), tmpDir: path.resolve(os.tmpdir(), nanoid()), }; debug('multiversion build from %o using resolved build options: %O', srcDir, options); // executor const runtimeX = options.runtime === 'bun' ? 'bunx' : 'npx'; const pkgInstaller = options.runtime === 'bun' ? ['bun', ['install', '--frozen-localfile']] : ['npm', ['clean-install']]; debug('multiversion build runtime %o with %O', options.runtime, {runtimeX, pkgInstaller}); // determine gitdir const gitDir = path.resolve(traverseUp(['.git'], osource).find(dir => fs.existsSync(dir)), '..'); debug('determined git-dir: %o', gitDir); // do the initial setup fs.removeSync(options.tmpDir, {force: true, maxRetries: 10, recursive: true}); fs.mkdirSync(options.tmpDir, {recursive: true}); // create execers for source and tmp opts const oexec = createExec({cwd: process.cwd(), debug}); const exec = createExec({cwd: options.tmpDir, debug}); // start it up log('setting up mvb build environment using %s...', magenta(gitDir)); // lets make sure the source repo at least has all the tag information it needs const updateArgs = ['fetch', options.ag === false ? 'origin' : '--all', '--tags', '--no-filter', '--force']; // if shallow then add to update refs if (getStdOut('git rev-parse --is-shallow-repository', {trim: true}) === 'true') updateArgs.push('--unshallow'); // fetch await oexec('git', updateArgs); // and then copy the repo in tmpdir so we can operate on it fs.copySync(gitDir, options.tmpDir); // checkout await exec('git', ['checkout', getBranch(), '--force']); // reset await exec('git', ['reset', 'HEAD', '--hard']); // pull await exec('git', ['pull', 'origin', getBranch()]); // also get if (options.ag !== false) await exec('git', ['merge', options.ag, '-X', 'theirs']); // get extended version information const {extended} = await getTags(options.tmpDir, options); debug('determined versions to build: %o', extended); // if we cant find the base build then reset it to dev if (extended.find(version => version.alias === options.build) === undefined) { debug('could not find a ref for %o, resetting to %o', options.build, 'dev'); options.build = 'dev'; } // set the base build extended.unshift(extended.find(version => version.alias === options.build)); debug('determined main/root build is %o %o', options.build, extended[0]); // now loop through extended and construct the build metadata const builds = extended.map((version, index) => { if (index === 0) { version.base = options.base; version.outDir = options.outDir; } else { version.base = path.resolve(`/${options.base}/${options.versionBase}/${version.alias ?? version.version}`) + '/'; version.outDir = path.join(options.outDir, options.versionBase, version.alias ?? version.version); } // if caching then also suggest a cache location if (options.cache && version.base !== '/') { version.cacheKey = path.join(options.base, options.versionBase, version.version, version.base); version.cachePath = path.join(options.cacheDir, crypto.createHash('sha256').update(version.cacheKey).digest('hex')); } // return return {...version, srcDir}; }); // get the dev build so we can set downstream envvars const devBuild = builds.find(build => build.alias === 'dev'); debug('determined dev build %o', devBuild); // report log('%s build at %s using alias %s, ref %s', magenta('primary'), magenta(options.base), magenta(builds[0]?.alias), magenta(builds[0]?.ref)); log('%s %s builds at %s', magenta(builds.length - 1), magenta('versioned'), magenta(`${options.base}${options.versionBase}`.replace(/\/{2,}/g, '/'))); log('%s builds queued up!', magenta(builds.length)); log(''); // and now build them all for (const build of builds) { // separate out our stuff const {alias, cachePath, ref, semantic, srcDir, version, ...config} = build; // if we have cache then lets just copy it over if (cachePath && fs.existsSync(cachePath)) { log('restoring version %s from %s at %s...', magenta(alias ?? version), magenta('cache'), magenta(cachePath)); fs.removeSync(path.resolve(config.outDir), {force: true, maxRetries: 10, recursive: true}); fs.mkdirSync(config.outDir, {recursive: true}); fs.copySync(cachePath, path.resolve(config.outDir)); continue; } // if we get here we need to actually do a build debug('building %o version %o with config %o', srcDir, `${alias ?? version}@${ref}`, config); log('building version %s, ref %s, from %s to %s...', magenta(alias ?? version), magenta(ref), magenta(srcDir), magenta(`${config.outDir}/`)); // reset HEAD HARD await exec('git', ['reset', 'HEAD', '--hard']); // checkout new ref await exec('git', ['checkout', ref]); // reinstall await exec(...pkgInstaller); // update package.json if needed const pjsonPath = path.join(options.tmpDir, 'package.json'); const pjson = JSON.parse(fs.readFileSync(pjsonPath, {encoding: 'utf8'})); if (pjson.version !== semantic) { // update version pjson.version = semantic; // rewrite fs.writeFileSync(pjsonPath, JSON.stringify(pjson, null, 2)); // log debug('updated %o version to %o', pjsonPath, semantic); } // build the version try { await exec( runtimeX, ['vitepress', 'build', srcDir, '--outDir', config.outDir, '--base', config.base], {env: { VPL_MVB_BASE: site.base, VPL_MVB_BUILD: 1, VPL_MVB_DEV_VERSION: devBuild?.version ?? 'dev', VPL_MVB_BRANCH: getBranch(gitDir), VPL_MVB_SOURCE: process.cwd(), }}, ); } catch (error) { error.message = red(`Build failed for version ${version} with error: ${error.message}`); error.build = build; throw error; } // clean original fs.removeSync(path.resolve(config.outDir), {force: true, maxRetries: 10, recursive: true}); // copy tmp to original fs.copySync(path.join(options.tmpDir, config.outDir), path.resolve(config.outDir)); // save cache if its on if (cachePath && options.cache) { debug('saving version %s to %s at %s...', version, 'cache', cachePath); fs.copySync(path.resolve(config.outDir), cachePath); } } // FIN log(''); log('%s %s builds at %s!', green('completed'), magenta(builds.length), magenta(siteConfig.outDir));