mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
297 lines
10 KiB
TypeScript
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();
|