stack/scripts/generate-dashboard-reference-docs.ts

297 lines
10 KiB
TypeScript

import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import { ALL_APPS } from "../packages/stack-shared/src/apps/apps-config";
import {
buildDashboardReferenceApps,
DASHBOARD_REFERENCE_PLACEHOLDER_BODY,
getDashboardReferenceDocPath,
listDashboardReferencePages,
type DiscoveredNavigableApp,
} from "./lib/dashboard-reference-docs";
import { extractDashboardReferenceJSDocs } from "./lib/extract-dashboard-reference-jsdoc";
import { writeFileSyncIfChanged } from "./utils";
const repoRoot = path.resolve(__dirname, "..");
const appsFrontendPath = path.join(repoRoot, "apps/dashboard/src/lib/apps-frontend.tsx");
const generatedMdxComment = "This file is auto-generated by scripts/generate-dashboard-reference-docs.ts. Do not edit it manually; update the @dashboardReference JSDoc on the dashboard page component.";
const mdxRoot = path.join(repoRoot, "docs-mintlify/guides/dashboard-references");
const docsJsonPath = path.join(repoRoot, "docs-mintlify/docs.json");
type DocsJson = {
navigation: {
tabs: Array<{
tab: string,
pages: unknown[],
}>,
},
redirects: Array<{ source: string, destination: string }>,
};
type NavigationItem = {
displayName: string,
external: boolean,
};
function readBody(
jsdocDocs: ReturnType<typeof extractDashboardReferenceJSDocs>,
appId: string,
slug: string,
): string {
const fromJsdoc = jsdocDocs.get(`${appId}/${slug}`);
if (fromJsdoc != null) {
return fromJsdoc.body.replace(/\r\n/g, "\n").replace(/\n+$/, "") + "\n";
}
return `${DASHBOARD_REFERENCE_PLACEHOLDER_BODY}\n`;
}
function mergePageMetadataFromJsdoc<T extends { title: string, description: string, sidebarTitle?: string }>(
appId: string,
slug: string,
page: T,
jsdocDocs: ReturnType<typeof extractDashboardReferenceJSDocs>,
): T {
const fromJsdoc = jsdocDocs.get(`${appId}/${slug}`);
if (fromJsdoc == null) {
return page;
}
return {
...page,
title: fromJsdoc.title ?? page.title,
description: fromJsdoc.description ?? page.description,
sidebarTitle: fromJsdoc.title != null ? (page.sidebarTitle ?? page.title) : page.sidebarTitle,
};
}
function renderMdx(
page: { title: string, description: string, sidebarTitle?: string },
body: string,
): string {
const sidebarTitle = page.sidebarTitle ?? page.title;
return [
"---",
"hidden: true",
`title: ${JSON.stringify(page.title)}`,
`description: ${JSON.stringify(page.description)}`,
`sidebarTitle: ${JSON.stringify(sidebarTitle)}`,
"---",
"",
`{/* ${generatedMdxComment} */}`,
"",
body.endsWith("\n") ? body : `${body}\n`,
].join("\n");
}
function buildDashboardReferenceNavGroup(dashboardReferenceApps: ReturnType<typeof buildDashboardReferenceApps>) {
return {
group: "Dashboard reference",
hidden: true,
searchable: true,
pages: dashboardReferenceApps.map((app) => ({
group: app.groupLabel,
icon: app.icon,
pages: app.pages.map((page) => getDashboardReferenceDocPath(app.appId, page.slug)),
})),
};
}
function patchDocsJson(
dashboardReferenceNavGroup: ReturnType<typeof buildDashboardReferenceNavGroup>,
dashboardReferenceApps: ReturnType<typeof buildDashboardReferenceApps>,
) {
const docsJson = JSON.parse(fs.readFileSync(docsJsonPath, "utf-8")) as DocsJson;
const documentationTab = docsJson.navigation.tabs.find((tab) => tab.tab === "Documentation");
if (documentationTab == null) {
throw new Error('docs.json: "Documentation" tab not found');
}
const pages = documentationTab.pages;
const integrationsIndex = pages.findIndex(
(entry) => typeof entry === "object" && entry != null && "group" in entry && (entry as { group: string }).group === "Integrations",
);
if (integrationsIndex === -1) {
throw new Error('docs.json: "Integrations" group not found under Documentation');
}
const dashboardRefIndex = pages.findIndex(
(entry) => typeof entry === "object" && entry != null && "group" in entry && (entry as { group: string }).group === "Dashboard reference",
);
const nextPages = [...pages];
if (dashboardRefIndex === -1) {
nextPages.splice(integrationsIndex, 0, dashboardReferenceNavGroup);
} else {
nextPages[dashboardRefIndex] = dashboardReferenceNavGroup;
}
documentationTab.pages = nextPages;
const legacyRedirects: Array<{ source: string, destination: string }> = [];
for (const { app, page } of listDashboardReferencePages(dashboardReferenceApps)) {
const legacySource = `/guides/apps/${app.appId}/${page.slug}`;
const destination = `/${getDashboardReferenceDocPath(app.appId, page.slug)}`;
legacyRedirects.push({ source: legacySource, destination });
}
const redirectSources = new Set(docsJson.redirects.map((r) => r.source));
for (const redirect of legacyRedirects) {
if (!redirectSources.has(redirect.source)) {
docsJson.redirects.push(redirect);
redirectSources.add(redirect.source);
}
}
writeFileSyncIfChanged(docsJsonPath, `${JSON.stringify(docsJson, null, 2)}\n`);
}
function sliceAppObject(content: string, appKeyMatchIndex: number): string {
const objectStart = content.indexOf("{", appKeyMatchIndex);
let depth = 0;
for (let i = objectStart; i < content.length; i++) {
if (content[i] === "{") {
depth++;
} else if (content[i] === "}") {
depth--;
if (depth === 0) {
return content.slice(objectStart, i + 1);
}
}
}
throw new Error("Could not parse app object in apps-frontend.tsx");
}
function extractNavigationItemsFromAppsFrontend(appId: string): NavigationItem[] {
const content = fs.readFileSync(appsFrontendPath, "utf-8");
const escapedAppId = appId.replace(/-/g, "\\-");
const appKeyPattern = new RegExp(`\\n\\s*(?:"${escapedAppId}"|${escapedAppId}):\\s*\\{`);
const appMatch = appKeyPattern.exec(content);
if (appMatch == null) {
throw new Error(`Could not find app "${appId}" in apps-frontend.tsx`);
}
const appObject = sliceAppObject(content, appMatch.index);
const navItemsKeywordIndex = appObject.indexOf("navigationItems:");
if (navItemsKeywordIndex === -1) {
throw new Error(`App "${appId}" has no navigationItems in apps-frontend.tsx`);
}
const arrayStart = appObject.indexOf("[", navItemsKeywordIndex);
let depth = 0;
let arrayEnd = -1;
for (let i = arrayStart; i < appObject.length; i++) {
if (appObject[i] === "[") {
depth++;
} else if (appObject[i] === "]") {
depth--;
if (depth === 0) {
arrayEnd = i;
break;
}
}
}
if (arrayEnd === -1) {
throw new Error(`Could not parse navigationItems for app "${appId}" in apps-frontend.tsx`);
}
const navBlock = appObject.slice(arrayStart, arrayEnd + 1);
const items: NavigationItem[] = [];
const displayNamePattern = /displayName:\s*"([^"]+)"/g;
let labelMatch = displayNamePattern.exec(navBlock);
while (labelMatch != null) {
const objectStart = labelMatch.index;
const objectEnd = navBlock.indexOf("},", objectStart);
const objectSlice = navBlock.slice(objectStart, objectEnd === -1 ? navBlock.length : objectEnd + 2);
items.push({
displayName: labelMatch[1],
external: /external:\s*true/.test(objectSlice),
});
labelMatch = displayNamePattern.exec(navBlock);
}
return items;
}
function discoverNavigableAppsFromAppsFrontend(): DiscoveredNavigableApp[] {
const discovered: DiscoveredNavigableApp[] = [];
for (const appId of Object.keys(ALL_APPS)) {
let navItems: NavigationItem[];
try {
navItems = extractNavigationItemsFromAppsFrontend(appId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("has no navigationItems") || message.includes("Could not find app")) {
continue;
}
throw error;
}
const internalNavLabels = navItems
.filter((item) => !item.external)
.map((item) => item.displayName);
if (internalNavLabels.length === 0) {
continue;
}
discovered.push({ appId, navLabels: internalNavLabels });
}
discovered.sort((a, b) => a.appId.localeCompare(b.appId));
return discovered;
}
function removeOrphanGeneratedMdx(dashboardReferenceApps: ReturnType<typeof buildDashboardReferenceApps>) {
const expectedPaths = new Set(
listDashboardReferencePages(dashboardReferenceApps).map(({ app, page }) => path.join(mdxRoot, app.appId, `${page.slug}.mdx`)),
);
for (const app of dashboardReferenceApps) {
const appDir = path.join(mdxRoot, app.appId);
if (!fs.existsSync(appDir)) {
continue;
}
for (const entry of fs.readdirSync(appDir)) {
if (!entry.endsWith(".mdx")) {
continue;
}
const fullPath = path.join(appDir, entry);
if (!expectedPaths.has(fullPath)) {
fs.unlinkSync(fullPath);
console.log(`Removed orphan generated file: ${fullPath}`);
}
}
}
}
function main() {
const jsdocDocs = extractDashboardReferenceJSDocs(repoRoot);
const discoveredApps = discoverNavigableAppsFromAppsFrontend();
const dashboardReferenceApps = buildDashboardReferenceApps(discoveredApps);
const allPages = listDashboardReferencePages(dashboardReferenceApps);
for (const { app, page } of allPages) {
const body = readBody(jsdocDocs, app.appId, page.slug);
const pageMetadata = mergePageMetadataFromJsdoc(app.appId, page.slug, page, jsdocDocs);
const mdxPath = path.join(mdxRoot, app.appId, `${page.slug}.mdx`);
fs.mkdirSync(path.dirname(mdxPath), { recursive: true });
writeFileSyncIfChanged(mdxPath, renderMdx(pageMetadata, body));
}
removeOrphanGeneratedMdx(dashboardReferenceApps);
patchDocsJson(buildDashboardReferenceNavGroup(dashboardReferenceApps), dashboardReferenceApps);
execSync("pnpm run generate-setup-prompt-docs", {
cwd: repoRoot,
stdio: "inherit",
});
const documentedCount = allPages.filter(({ app, page }) => jsdocDocs.has(`${app.appId}/${page.slug}`)).length;
console.log(
`Generated dashboard reference docs for ${dashboardReferenceApps.length} apps, `
+ `${allPages.length} pages (${documentedCount} with JSDoc, ${allPages.length - documentedCount} placeholder).`,
);
}
main();