Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/generator/api-reference-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import chalk from "chalk";
import fs from "fs-extra";
import path from "node:path";

import { GeneratedArtifacts } from "../lib/generated-artifacts.js";
import { buildEndpointDoc, OpenApiRegistry } from "../lib/openapi.js";
import type { OperationDescriptor } from "../lib/openapi-types.js";
import { resolveOutputPath } from "../lib/output-safety.js";
import type { MDXFile, PageMeta } from "../lib/types.js";
import { writeFileAtomic } from "../lib/utils.js";
import { mergePages as mergePageCatalog } from "./page-catalog.js";

type GeneratePage = (
mdxFile: MDXFile,
options?: { apiOperation?: OperationDescriptor },
) => Promise<void>;

type RemoveOwnedRoute = (slug: string) => Promise<void>;

type WriteAllowlist = () => Promise<void>;

type CleanupStalePages = (
nextRoutes: Map<string, string>,
realSlugs: Set<string>,
) => Promise<void>;

export class ApiReferenceGenerator {
constructor(
private readonly outputDir: string,
private readonly artifacts: GeneratedArtifacts,
) {}

mergePages(registry: OpenApiRegistry, realPages: PageMeta[]): PageMeta[] {
return mergePageCatalog(
realPages,
registry.isEmpty ? [] : registry.syntheticPages(),
);
}

async writePages(
registry: OpenApiRegistry,
apiBaseSlug: string,
realPages: PageMeta[],
generatePage: GeneratePage,
writeAllowlist: WriteAllowlist,
cleanupStalePages: CleanupStalePages,
): Promise<void> {
const realSlugs = new Set(realPages.map((page) => page.slug));
const nextRoutes = new Map<string, string>();

const indexPage = registry
.syntheticPages()
.find((page) => page.slug === apiBaseSlug);
if (indexPage && !realSlugs.has(indexPage.slug)) {
try {
await generatePage({
path: indexPage.path,
content: registry.bodyForSlug(indexPage.slug) ?? "",
frontmatter: {
title: indexPage.title,
description: indexPage.description,
},
slug: indexPage.slug,
});
nextRoutes.set(`@openapi/${indexPage.slug}`, indexPage.slug);
} catch (error) {
console.error(
chalk.red(`❌ Error generating API index ${indexPage.slug}:`),
error,
);
}
}

for (const op of registry.all) {
const methodUpper = op.method.toUpperCase();
const mdxFile: MDXFile = {
path: `@openapi/${op.specName}/${op.method}${op.path}`,
content: buildEndpointDoc(op),
frontmatter: {
title: op.summary ?? `${methodUpper} ${op.path}`,
description: op.summary ?? "",
},
slug: op.slug,
};
if (realSlugs.has(op.slug)) {
console.log(
chalk.yellow(
`⚠️ API page ${op.slug} is shadowed by a hand-written page; skipping`,
),
);
continue;
}
try {
await generatePage(mdxFile, { apiOperation: op });
nextRoutes.set(`@openapi/${op.slug}`, op.slug);
} catch (error) {
console.error(
chalk.red(`❌ Error generating API page ${op.slug}:`),
error,
);
}
}

await writeAllowlist();
await cleanupStalePages(nextRoutes, realSlugs);

if (registry.all.length > 0) {
console.log(
chalk.green(`🧩 Generated ${nextRoutes.size} API reference page(s)`),
);
}
}

async writeAllowlist(registry: OpenApiRegistry): Promise<void> {
const target = resolveOutputPath(
this.outputDir,
"services",
"openapi",
"playground-allowlist.json",
);
await fs.ensureDir(path.dirname(target));
await writeFileAtomic(
target,
`${JSON.stringify(registry.allowlist(), null, 2)}\n`,
);
}

async cleanupStalePages(
nextRoutes: Map<string, string>,
realSlugs: Set<string>,
removeOwnedRoute: RemoveOwnedRoute,
): Promise<void> {
const nextSlugs = new Set(nextRoutes.values());
for (const previous of this.artifacts.routesFor("openapi")) {
if (nextRoutes.has(previous.source) || nextSlugs.has(previous.slug)) {
continue;
}
// A hand-written page may have taken ownership of this route since the
// previous OpenAPI pass. Never remove an output now claimed by MDX.
if (realSlugs.has(previous.slug)) continue;
try {
await removeOwnedRoute(previous.slug);
} catch {
// ignore
}
}

this.artifacts.replaceRoutes(
"openapi",
[...nextRoutes].map(([source, slug]) => ({ source, slug })),
);
await this.artifacts.save();
}
}
82 changes: 82 additions & 0 deletions src/generator/app-scaffolder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import fs from "fs-extra";
import path from "node:path";

import {
appStructure,
obsoleteFiles,
startingDocsStructure,
} from "../lib/structures.js";
import { resolveOutputPath } from "../lib/output-safety.js";
import type { AnalyticsConfig } from "../lib/types.js";
import { writeFileAtomic } from "../lib/utils.js";
import { robotsTemplate } from "../templates/app/robots.js";
import { nextConfigTemplate } from "../templates/next.config.js";
import { pnpmWorkspaceTemplate } from "../templates/pnpmWorkspace.js";
import { proxyTemplate } from "../templates/proxy.js";

interface AppStructureCallbacks {
generateRootLayout(): Promise<string>;
generateSiteLayout(): Promise<string>;
updateSitemap(): Promise<void>;
updateLlmsFiles(): Promise<void>;
}

interface StarterDocumentCallbacks {
getAllMdxFiles(): Promise<string[]>;
ensureSafeStarterPath(relativePath: string): Promise<string>;
}

export class AppScaffolder {
constructor(private readonly outputDir: string) {}

private outputPath(...segments: string[]): string {
return resolveOutputPath(this.outputDir, ...segments);
}

async createNextJsStructure(
analyticsConfig: AnalyticsConfig | null,
callbacks: AppStructureCallbacks,
): Promise<void> {
// Everything under app/ is generated, so clear stale routes before writing
// the current structure. Other generated directories remain untouched.
await fs.remove(this.outputPath("app"));

await Promise.all(
obsoleteFiles.map((file) => fs.remove(this.outputPath(file))),
);

const structure: Record<string, string | Promise<string>> = {
...appStructure,
"next.config.ts": nextConfigTemplate(analyticsConfig),
"pnpm-workspace.yaml": pnpmWorkspaceTemplate,
"proxy.ts": proxyTemplate(analyticsConfig),
"analytics.json": `{}\n`,
"config.json": `{}\n`,
"links.json": `[]\n`,
"navigation.json": `[]\n`,
"sections.json": `[]\n`,
"theme.json": `{}\n`,
"app/robots.ts": robotsTemplate,
"app/layout.tsx": callbacks.generateRootLayout(),
"app/(site)/layout.tsx": callbacks.generateSiteLayout(),
};

for (const [filePath, content] of Object.entries(structure)) {
const fullPath = this.outputPath(filePath);
await fs.ensureDir(path.dirname(fullPath));
await writeFileAtomic(fullPath, String(await content));
}

await callbacks.updateSitemap();
await callbacks.updateLlmsFiles();
}

async createStartingDocs(callbacks: StarterDocumentCallbacks): Promise<void> {
if ((await callbacks.getAllMdxFiles()).length > 0) return;

for (const [filePath, content] of Object.entries(startingDocsStructure)) {
const fullPath = await callbacks.ensureSafeStarterPath(filePath);
await writeFileAtomic(fullPath, String(content));
}
}
}
116 changes: 116 additions & 0 deletions src/generator/generated-route-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import chalk from "chalk";
import fs from "fs-extra";
import path from "node:path";

import { GeneratedArtifacts } from "../lib/generated-artifacts.js";
import { resolveOutputPath } from "../lib/output-safety.js";
import type { PageMeta } from "../lib/types.js";

export class GeneratedRouteManager {
private generatedSectionIndexSlugs = new Set<string>();

constructor(
private readonly outputDir: string,
private readonly artifacts: GeneratedArtifacts,
) {}

private outputPath(...segments: string[]): string {
return resolveOutputPath(this.outputDir, ...segments);
}

routeForMdxSource(source: string): string | undefined {
return this.artifacts.routeFor("mdx", source);
}

async replaceMdxRoutes(realPages: PageMeta[]): Promise<void> {
this.artifacts.replaceRoutes(
"mdx",
realPages
.filter((page) => page.slug !== "")
.map((page) => ({ source: page.path, slug: page.slug })),
);
await this.artifacts.save();
}

async removeMdxRoute(source: string): Promise<void> {
this.artifacts.removeRoute("mdx", source);
await this.artifacts.save();
}

async removeOwnedRoute(slug: string): Promise<void> {
if (!slug) return;
const siteDir = this.outputPath("app", "(site)");
const routeDir = resolveOutputPath(siteDir, slug);
await Promise.all([
fs.remove(resolveOutputPath(siteDir, slug, "page.tsx")),
fs.remove(resolveOutputPath(siteDir, slug, "rss.xml")),
]);
await this.removeEmptyDirsUpTo(routeDir, siteDir);
}

async removeStaleMdxRoutes(
realPages: PageMeta[],
removeOwnedRoute: (slug: string) => Promise<void>,
): Promise<void> {
const nextBySource = new Map(
realPages
.filter((page) => page.slug !== "")
.map((page) => [page.path.replace(/\\/g, "/"), page.slug]),
);
const nextSlugs = new Set(nextBySource.values());

for (const previous of this.artifacts.routesFor("mdx")) {
if (nextBySource.get(previous.source) === previous.slug) continue;
if (nextSlugs.has(previous.slug)) continue;
await removeOwnedRoute(previous.slug);
}
}

async cleanupStaleSectionIndexPages(
nextSlugs: Set<string>,
removeEmptyDirs: (dir: string, stopDir: string) => Promise<void>,
): Promise<void> {
for (const stale of this.generatedSectionIndexSlugs) {
if (nextSlugs.has(stale)) continue;
const pagePath = resolveOutputPath(
this.outputDir,
"app",
"(site)",
stale,
"page.tsx",
);
try {
if (!(await fs.pathExists(pagePath))) continue;
const content = await fs.readFile(pagePath, "utf8");
if (!content.includes("function SectionIndex()")) continue;
await fs.remove(pagePath);
await removeEmptyDirs(
path.dirname(pagePath),
this.outputPath("app", "(site)"),
);
console.log(
chalk.blue(`🧹 Removed stale section index redirect: /${stale}`),
);
} catch {
// ignore
}
}
this.generatedSectionIndexSlugs = nextSlugs;
}

/** Best-effort removal of now-empty directories up to (not incl.) stopDir. */
async removeEmptyDirsUpTo(dir: string, stopDir: string): Promise<void> {
const stop = path.resolve(stopDir);
let current = path.resolve(dir);
while (current !== stop && current.startsWith(stop + path.sep)) {
try {
const entries = await fs.readdir(current);
if (entries.length > 0) return;
await fs.remove(current);
} catch {
return;
}
current = path.dirname(current);
}
}
}
Loading