UNPKG

@mintlify/previewing

Version:

Preview Mintlify docs locally

251 lines (250 loc) • 9.89 kB
import { jsx as _jsx } from "react/jsx-runtime"; import { getAllPathsInDecoratedNav, getAllPathsInDocsNav } from '@mintlify/common'; import { prebuild } from '@mintlify/prebuild'; import AdmZip from 'adm-zip'; import express from 'express'; import fse, { pathExists } from 'fs-extra'; import { createServer } from 'http'; import isOnline from 'is-online'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { CLIENT_PATH, DOT_MINTLIFY, CMD_EXEC_PATH, VERSION_PATH, NEXT_PUBLIC_PATH, NEXT_PROPS_PATH, } from '../constants.js'; import { addLog, clearLogs } from '../logging-state.js'; import { ErrorLog, SpinnerLog, SuccessLog } from '../logs.js'; import { getGroupFilteredRoutes } from './getGroupFilteredRoutes.js'; import { setupNext } from './setupNext.js'; import { silentUpdateClient } from './update.js'; const CONCURRENCY = 10; const EXPORT_SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'export-scripts'); function resolveRequestRoute(route, rootRoute) { if (route === '/' && rootRoute) return rootRoute; return route; } function prepareRoutesForExport(routes, groups) { if (!groups?.length || routes.includes('/')) return { routes }; const firstAccessibleRoute = routes.find((route) => route !== '/'); if (!firstAccessibleRoute) return { routes }; return { routes: ['/', ...routes], rootRoute: firstAccessibleRoute, }; } async function withExportConsoleSilenced(task) { const originalConsole = { log: console.log, warn: console.warn, }; const noop = (..._args) => undefined; console.log = noop; console.warn = noop; try { return await task(); } finally { console.log = originalConsole.log; console.warn = originalConsole.warn; } } async function fatal(message) { clearLogs(); addLog(_jsx(ErrorLog, { message: message })); await new Promise((resolve) => setTimeout(resolve, 50)); process.exit(1); } async function collectRoutes(groups) { const shouldFilterByGroups = groups !== undefined && groups.length > 0; const routes = new Set(shouldFilterByGroups ? [] : ['/']); const docsConfigPath = path.join(CMD_EXEC_PATH, 'docs.json'); if (!(await pathExists(docsConfigPath))) { return [...routes]; } const config = await fse.readJson(docsConfigPath); if (!shouldFilterByGroups && config.navigation) { for (const navPath of getAllPathsInDocsNav(config.navigation)) { routes.add(`/${navPath}`); } } // Also collect routes from the generated decorated nav, which includes OpenAPI/AsyncAPI // pages and filters to real page entries (ignoring non-page hrefs in nav divisions). const generatedNavPath = path.join(NEXT_PROPS_PATH, 'generatedDocsNav.json'); if (await pathExists(generatedNavPath)) { const generatedNav = await fse.readJson(generatedNavPath); if (shouldFilterByGroups) return getGroupFilteredRoutes(generatedNav, groups); for (const navPath of getAllPathsInDecoratedNav(generatedNav)) { routes.add(`/${navPath}`); } } else if (shouldFilterByGroups) { throw new Error('failed to collect group-filtered routes'); } return [...routes]; } export async function generatePages(baseUrl, routes, outputDir, rootRoute) { const queue = [...routes]; let failedPageCount = 0; async function worker() { while (queue.length > 0) { const route = queue.shift(); try { const requestRoute = resolveRequestRoute(route, rootRoute); const res = await fetch(`${baseUrl}${requestRoute}`); if (!res.ok) { failedPageCount += 1; addLog(_jsx(ErrorLog, { message: `failed to generate ${route} (status ${res.status})` })); continue; } const filePath = route === '/' ? path.join(outputDir, 'index.html') : path.join(outputDir, route, 'index.html'); await fse.ensureDir(path.dirname(filePath)); await fse.writeFile(filePath, await res.text(), 'utf8'); } catch (err) { failedPageCount += 1; addLog(_jsx(ErrorLog, { message: `failed to generate ${route}: ${err instanceof Error ? err.message : 'unknown error'}` })); } } } await Promise.all(Array.from({ length: Math.min(CONCURRENCY, routes.length) }, () => worker())); return failedPageCount; } async function startServer() { const app = express(); const requestHandler = await setupNext(); app.use('/', express.static(NEXT_PUBLIC_PATH)); app.all('*', (req, res) => requestHandler(req, res)); const server = createServer(app); const port = await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, () => { const addr = server.address(); if (!addr || typeof addr === 'string') { reject(new Error('failed to determine listening port')); return; } resolve(addr.port); }); }); return { server, port }; } export async function writeStaticUserInfo(tmpDir, groups) { if (!groups?.length) return; await fse.outputJson(path.join(tmpDir, '_mintlify', 'api', 'user', 'index.html'), { user: { groups }, }); } async function bundleStaticAssets(tmpDir, groups) { const nextStaticSrc = path.join(CLIENT_PATH, '.next', 'static'); if (await pathExists(nextStaticSrc)) { await fse.copy(nextStaticSrc, path.join(tmpDir, '_next', 'static')); } if (await pathExists(NEXT_PUBLIC_PATH)) { const publicFiles = await fse.readdir(NEXT_PUBLIC_PATH); for (const file of publicFiles) { await fse.copy(path.join(NEXT_PUBLIC_PATH, file), path.join(tmpDir, file), { overwrite: false, }); } } await writeStaticUserInfo(tmpDir, groups); await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'serve.js'), path.join(tmpDir, 'serve.js')); await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'start-docs.sh'), path.join(tmpDir, 'Start Docs.command')); await fse.copy(path.join(EXPORT_SCRIPTS_DIR, 'start-docs.bat'), path.join(tmpDir, 'Start Docs.bat')); } async function closeServer(server) { await new Promise((resolve) => { server.close(() => resolve()); }); } async function generateExportArchive(routes, outputPath, groups) { const { server, port } = await startServer(); const { routes: routesToGenerate, rootRoute } = prepareRoutesForExport(routes, groups); let tmpDir; try { clearLogs(); addLog(_jsx(SpinnerLog, { message: `generating ${routesToGenerate.length} pages...` })); tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'mint-export-')); const exportDir = tmpDir; const failedPageCount = await withExportConsoleSilenced(async () => { return generatePages(`http://localhost:${port}`, routesToGenerate, exportDir, rootRoute); }); if (failedPageCount > 0) { throw new Error(`failed to export: ${failedPageCount} page(s) could not be generated.`); } addLog(_jsx(SpinnerLog, { message: "collecting static assets..." })); await bundleStaticAssets(exportDir, groups); addLog(_jsx(SpinnerLog, { message: "creating zip archive..." })); const zip = new AdmZip(); zip.addLocalFolder(exportDir); zip.writeZip(outputPath); clearLogs(); addLog(_jsx(SuccessLog, { message: `exported to ${outputPath}` })); } finally { if (tmpDir) { await fse.remove(tmpDir); } await closeServer(server); } } export const exportSite = async (argv) => { const hasInternet = await isOnline(); const clientVersion = argv['client-version']; const groups = argv.groups; const cliVersion = argv.cliVersion; const disableOpenApi = argv.disableOpenapi; const outputPath = path.resolve(argv.output); await fse.ensureDir(DOT_MINTLIFY); const versionString = (await pathExists(VERSION_PATH)) ? fse.readFileSync(VERSION_PATH, 'utf8') : null; if (!versionString && !hasInternet) { await fatal('export requires an internet connection on first run.'); } addLog(_jsx(SpinnerLog, { message: "preparing export..." })); const { error } = await silentUpdateClient({ versionString, clientVersion, cliVersion, localClientVersion: undefined, }); if (error) await fatal(error); try { fse.emptyDirSync(NEXT_PUBLIC_PATH); fse.emptyDirSync(NEXT_PROPS_PATH); } catch (err) { await fatal(err instanceof Error && err.message ? err.message : 'failed to prepare export directories'); } process.chdir(CLIENT_PATH); addLog(_jsx(SpinnerLog, { message: "running prebuild..." })); try { await withExportConsoleSilenced(async () => { await prebuild(CMD_EXEC_PATH, { groups, disableOpenApi }); }); } catch (err) { await fatal(err instanceof Error && err.message ? err.message : 'prebuild step failed'); } let routes = []; try { routes = await collectRoutes(groups); } catch (err) { await fatal(err instanceof Error && err.message ? err.message : 'failed to collect routes'); } addLog(_jsx(SpinnerLog, { message: "starting local server..." })); try { await generateExportArchive(routes, outputPath, groups); } catch (err) { await fatal(err instanceof Error && err.message ? err.message : 'export failed'); } };