@mintlify/previewing
Version:
Preview Mintlify docs locally
251 lines (250 loc) • 9.89 kB
JavaScript
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');
}
};