diff --git a/src/generator/generated-page-publisher.ts b/src/generator/generated-page-publisher.ts new file mode 100644 index 0000000..14ca6cd --- /dev/null +++ b/src/generator/generated-page-publisher.ts @@ -0,0 +1,201 @@ +import fs from "fs-extra"; +import path from "node:path"; + +import type { MDXFile } from "../lib/types.js"; +import type { OperationDescriptor } from "../lib/openapi-types.js"; +import { + readOutputFileIfPresent, + resolveOutputPath, +} from "../lib/output-safety.js"; +import { writeFileAtomic } from "../lib/utils.js"; +import { + renderHomepage, + renderMdxPage, + renderSectionPage, + type HomepageSource, + type RenderedPage, +} from "./page-renderer.js"; +import { GeneratedRouteManager } from "./generated-route-manager.js"; + +export interface GeneratedPageCommit { + rollback(): Promise; +} + +export class GeneratedPagePublisher { + constructor( + private readonly outputDir: string, + private readonly routeManager: GeneratedRouteManager, + ) {} + + private generatedFileSegments(filePath: string): string[] { + return path + .relative(fs.realpathSync(this.outputDir), filePath) + .split(path.sep) + .filter(Boolean); + } + + private async readGeneratedFile(filePath: string): Promise { + return readOutputFileIfPresent( + this.outputDir, + ...this.generatedFileSegments(filePath), + ); + } + + private async restoreGeneratedFile( + filePath: string, + content: string | null, + ): Promise { + const target = resolveOutputPath( + this.outputDir, + ...this.generatedFileSegments(filePath), + ); + if (content === null) { + await fs.remove(target); + } else { + await writeFileAtomic(target, content); + } + } + + private async commitRenderedPage( + pagePath: string, + rendered: RenderedPage, + ): Promise { + if (rendered.rssRoute.action === "preserve") { + const previousPage = await this.readGeneratedFile(pagePath); + await writeFileAtomic(pagePath, rendered.pageContent); + return { + rollback: () => this.restoreGeneratedFile(pagePath, previousPage), + }; + } + + const rssDir = resolveOutputPath(path.dirname(pagePath), "rss.xml"); + const rssPath = resolveOutputPath( + path.dirname(pagePath), + "rss.xml", + "route.ts", + ); + const [previousPage, previousRss] = await Promise.all([ + this.readGeneratedFile(pagePath), + this.readGeneratedFile(rssPath), + ]); + let pageChanged = false; + let rssChanged = false; + + try { + if (rendered.rssRoute.action === "write") { + await writeFileAtomic(rssPath, rendered.rssRoute.content); + rssChanged = true; + await writeFileAtomic(pagePath, rendered.pageContent); + pageChanged = true; + } else { + await writeFileAtomic(pagePath, rendered.pageContent); + pageChanged = true; + await fs.remove(rssPath); + rssChanged = true; + await this.routeManager.removeEmptyDirsUpTo( + rssDir, + path.dirname(pagePath), + ); + } + } catch (error) { + const rollbackErrors: unknown[] = []; + if (rssChanged) { + try { + await this.restoreGeneratedFile(rssPath, previousRss); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (pageChanged) { + try { + await this.restoreGeneratedFile(pagePath, previousPage); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + `Unable to publish or restore generated page ${pagePath}`, + ); + } + throw error; + } + + return { + rollback: async () => { + const rollbackErrors: unknown[] = []; + try { + await this.restoreGeneratedFile(rssPath, previousRss); + } catch (error) { + rollbackErrors.push(error); + } + try { + await this.restoreGeneratedFile(pagePath, previousPage); + } catch (error) { + rollbackErrors.push(error); + } + if (rollbackErrors.length > 0) { + throw new AggregateError( + rollbackErrors, + `Unable to restore generated page ${pagePath}`, + ); + } + }, + }; + } + + async generatePageFromMdx( + mdxFile: MDXFile, + options?: { apiOperation?: OperationDescriptor }, + ): Promise { + const rendered = renderMdxPage(mdxFile, options); + const pagePath = resolveOutputPath( + this.outputDir, + "app", + "(site)", + mdxFile.slug, + "page.tsx", + ); + await fs.ensureDir(path.dirname(pagePath)); + return this.commitRenderedPage(pagePath, rendered); + } + + async updateHomepage( + source: HomepageSource | null, + apiOperation?: OperationDescriptor, + ): Promise { + const rendered = renderHomepage(source, apiOperation); + const pagePath = resolveOutputPath( + this.outputDir, + "app", + "(site)", + "page.tsx", + ); + await fs.ensureDir(path.dirname(pagePath)); + return this.commitRenderedPage(pagePath, rendered); + } + + async updateSectionIndex( + sectionSlug: string, + frontmatter: Record, + mdxContent: string, + sourcePath?: string, + ): Promise { + const rendered = renderSectionPage( + sectionSlug, + frontmatter, + mdxContent, + sourcePath, + ); + const pagePath = resolveOutputPath( + this.outputDir, + "app", + "(site)", + sectionSlug, + "page.tsx", + ); + await fs.ensureDir(path.dirname(pagePath)); + return this.commitRenderedPage(pagePath, rendered); + } +} diff --git a/src/generator/mdx-pass-builder.ts b/src/generator/mdx-pass-builder.ts new file mode 100644 index 0000000..499b0a4 --- /dev/null +++ b/src/generator/mdx-pass-builder.ts @@ -0,0 +1,92 @@ +import type { Stats } from "node:fs"; + +import type { PageMeta, SectionConfig } from "../lib/types.js"; +import { safeMatter } from "../lib/utils.js"; +import { buildRealPagesMeta, parseMdxPageMeta } from "./page-catalog.js"; +import { discoverSections } from "./section-resolver.js"; +import { SecureSourceFs } from "./secure-source-fs.js"; + +export interface MdxSourceSnapshot { + content: string; + stat: Stats; +} + +export interface MdxPassSnapshot { + files: string[]; + pages: PageMeta[]; + sources: ReadonlyMap; + sections: SectionConfig[] | null; +} + +interface MdxPassBuilderOptions { + sourceFs: SecureSourceFs; + getAllMdxFiles(): Promise; + getSections(): SectionConfig[] | null; + loadSectionsConfig(): Promise; + withApiReferenceSection( + sections: SectionConfig[] | null, + ): SectionConfig[] | null; + determineSectionForFile( + filePath: string, + frontmatter: Record, + sections: SectionConfig[] | null, + ): { sectionSlug: string; pageSlug: string }; + resolveHttpMethod(reference: string): string | undefined; +} + +export class MdxPassBuilder { + constructor(private readonly options: MdxPassBuilderOptions) {} + + async capture( + files?: string[], + seededSources: ReadonlyMap = new Map(), + refreshSections = false, + ): Promise { + const resolvedFiles = files ?? (await this.options.getAllMdxFiles()); + const sources = new Map(seededSources); + await Promise.all( + resolvedFiles.map(async (file) => { + const source = file.replace(/\\/g, "/"); + if (!sources.has(source)) { + sources.set( + source, + await this.options.sourceFs.readMdxSourceFile(file), + ); + } + }), + ); + + let sections = this.options.getSections(); + if (refreshSections) { + const configuredSections = await this.options.loadSectionsConfig(); + if (configuredSections !== null) { + sections = this.options.withApiReferenceSection(configuredSections); + } else { + const documents = resolvedFiles.map((filePath) => { + const source = sources.get(filePath.replace(/\\/g, "/")); + if (!source) throw new Error(`Unable to snapshot ${filePath}`); + return { + filePath, + frontmatter: safeMatter(source.content, filePath).data, + }; + }); + sections = this.options.withApiReferenceSection( + discoverSections(documents), + ); + } + } + + const pages = await buildRealPagesMeta(resolvedFiles, async (file) => { + const source = sources.get(file.replace(/\\/g, "/")); + if (!source) throw new Error(`Unable to snapshot ${file}`); + return parseMdxPageMeta( + file, + async () => source, + (filePath, frontmatter) => + this.options.determineSectionForFile(filePath, frontmatter, sections), + (reference) => this.options.resolveHttpMethod(reference), + ); + }); + return { files: resolvedFiles, pages, sources, sections }; + } +} diff --git a/src/generator/openapi-refresh-coordinator.ts b/src/generator/openapi-refresh-coordinator.ts new file mode 100644 index 0000000..4920275 --- /dev/null +++ b/src/generator/openapi-refresh-coordinator.ts @@ -0,0 +1,244 @@ +import chalk from "chalk"; +import path from "node:path"; + +import { + normalizeOpenApiConfig, + validateConfig, +} from "../lib/config-manager.js"; +import type { RouteArtifact } from "../lib/generated-artifacts.js"; +import { OpenApiRegistry } from "../lib/openapi.js"; +import type { + DoccupineConfig, + NormalizedOpenApiSpec, + PageMeta, + SectionConfig, +} from "../lib/types.js"; +import { SecureSourceFs } from "./secure-source-fs.js"; + +export interface ApiPageWriteOptions { + writtenRoutes?: Map; + additionalPreviousRoutes?: Iterable; +} + +interface OpenApiRefreshCoordinatorOptions { + rootDir: string; + watchDir: string; + outputDir: string; + configFile: string; + apiBaseSlug: string; + sourceFs: SecureSourceFs; + getRegistry(): OpenApiRegistry; + setRegistry(registry: OpenApiRegistry): void; + getSpecs(): NormalizedOpenApiSpec[]; + setSpecs(specs: NormalizedOpenApiSpec[]): void; + getSections(): SectionConfig[] | null; + setSections(sections: SectionConfig[] | null): void; + getOpenApiRoutes(): RouteArtifact[]; + getSuccessfulMdxPages(): PageMeta[]; + resolveSections(): Promise; + writeApiPages( + realPages?: PageMeta[], + options?: ApiPageWriteOptions, + ): Promise>; + refreshSiteAggregates(): Promise; + syncWatcher(): Promise; + removeOwnedRoute(slug: string): Promise; +} + +export class OpenApiRefreshCoordinator { + constructor(private readonly options: OpenApiRefreshCoordinatorOptions) {} + + async loadInitialRegistry(): Promise { + const specs = this.options.getSpecs(); + if (specs.length === 0) return; + const { registry } = await this.loadStableRegistry(specs); + this.options.setRegistry(registry); + if (!registry.isEmpty) { + console.log( + chalk.blue( + `📘 Loaded ${registry.all.length} API endpoint(s) from ${specs.length} spec(s)`, + ), + ); + } + } + + private async sourceState(registry: OpenApiRegistry): Promise { + return ( + await Promise.all( + registry.sourceFiles.map(async (sourcePath) => { + return `${sourcePath}:${await this.options.sourceFs.pathState(sourcePath, true)}`; + }), + ) + ).join("\n"); + } + + private async loadStableRegistry( + specs: NormalizedOpenApiSpec[], + ): Promise<{ registry: OpenApiRegistry; sourceState: string }> { + for (let attempt = 0; attempt < 3; attempt += 1) { + const registry = new OpenApiRegistry(); + await registry.load( + specs, + this.options.rootDir, + this.options.apiBaseSlug, + ); + const current = await this.sourceState(registry); + if (registry.sourceFingerprint === current) { + return { registry, sourceState: current }; + } + } + throw new Error("OpenAPI sources changed repeatedly while being loaded"); + } + + private async applyStableRefresh( + specs: NormalizedOpenApiSpec[], + ): Promise { + let candidate = await this.loadStableRegistry(specs); + await this.applyRefresh(candidate.registry, specs); + + // Recheck after watcher readiness so changes in the retargeting window are + // replayed explicitly instead of being missed by ignoreInitial watchers. + for (let attempt = 0; attempt < 3; attempt += 1) { + if ( + (await this.sourceState(candidate.registry)) === candidate.sourceState + ) { + return; + } + candidate = await this.loadStableRegistry(specs); + await this.applyRefresh(candidate.registry, specs); + } + throw new Error("OpenAPI sources changed repeatedly while being generated"); + } + + private async applyRefresh( + nextRegistry: OpenApiRegistry, + nextSpecs: NormalizedOpenApiSpec[], + ): Promise { + const previousRegistry = this.options.getRegistry(); + const previousSpecs = this.options.getSpecs(); + const previousSections = this.options.getSections(); + const previousRoutes = this.options.getOpenApiRoutes(); + const candidateRoutes = new Map(); + let watcherSyncAttempted = false; + + try { + this.options.setRegistry(nextRegistry); + this.options.setSpecs(nextSpecs); + this.options.setSections(await this.options.resolveSections()); + await this.options.writeApiPages(undefined, { + writtenRoutes: candidateRoutes, + }); + await this.options.refreshSiteAggregates(); + watcherSyncAttempted = true; + await this.options.syncWatcher(); + } catch (error) { + this.options.setRegistry(previousRegistry); + this.options.setSpecs(previousSpecs); + this.options.setSections(previousSections); + const rollbackErrors: unknown[] = []; + + if (watcherSyncAttempted) { + try { + await this.options.syncWatcher(); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + const previousSlugs = new Set(previousRoutes.map((route) => route.slug)); + const occupiedMdxSlugs = new Set( + this.options.getSuccessfulMdxPages().map((page) => page.slug), + ); + for (const slug of new Set(candidateRoutes.values())) { + if (previousSlugs.has(slug) || occupiedMdxSlugs.has(slug)) continue; + try { + await this.options.removeOwnedRoute(slug); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + try { + await this.options.writeApiPages(undefined, { + additionalPreviousRoutes: [...candidateRoutes].map( + ([source, slug]) => ({ kind: "openapi", source, slug }), + ), + }); + await this.options.refreshSiteAggregates(); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + "Unable to apply or restore the OpenAPI reference", + ); + } + throw error; + } + } + + async handleOpenApiChange(): Promise { + console.log( + chalk.cyan("📘 OpenAPI spec changed - regenerating API reference"), + ); + try { + await this.applyStableRefresh(this.options.getSpecs()); + console.log(chalk.green("✅ API reference updated")); + } catch (error) { + console.error(chalk.red("❌ Error updating API reference:"), error); + } + } + + async handleConfigChange(): Promise { + const configPath = path.join(this.options.rootDir, this.options.configFile); + let config: DoccupineConfig; + try { + const { data } = await this.options.sourceFs.readProjectSourceFile( + configPath, + "Doccupine configuration source", + ); + config = validateConfig( + JSON.parse(data.toString("utf8")), + this.options.rootDir, + ); + } catch (error) { + console.warn( + chalk.yellow( + "⚠️ doccupine.json is missing or invalid - keeping the current configuration", + ), + error instanceof Error ? error.message : error, + ); + return; + } + + if ( + (config.watchDir && + path.resolve(this.options.rootDir, config.watchDir) !== + this.options.watchDir) || + (config.outputDir && + path.resolve(this.options.rootDir, config.outputDir) !== + this.options.outputDir) + ) { + console.log( + chalk.yellow( + "⚠️ watchDir/outputDir changes in doccupine.json need a restart to apply", + ), + ); + } + + const nextSpecs = normalizeOpenApiConfig(config.openapi); + if (JSON.stringify(nextSpecs) === JSON.stringify(this.options.getSpecs())) { + return; + } + + console.log( + chalk.cyan("📘 OpenAPI configuration changed - updating API reference"), + ); + try { + await this.applyStableRefresh(nextSpecs); + console.log(chalk.green("✅ API reference updated")); + } catch (error) { + console.error(chalk.red("❌ Error updating API reference:"), error); + } + } +} diff --git a/src/generator/section-index-generator.ts b/src/generator/section-index-generator.ts new file mode 100644 index 0000000..b161741 --- /dev/null +++ b/src/generator/section-index-generator.ts @@ -0,0 +1,101 @@ +import fs from "fs-extra"; + +import type { PageMeta, SectionConfig } from "../lib/types.js"; +import { + readOutputFileIfPresent, + resolveOutputPath, +} from "../lib/output-safety.js"; +import { writeFileAtomic } from "../lib/utils.js"; +import { GeneratedRouteManager } from "./generated-route-manager.js"; + +type WriteRedirect = (slug: string, target: string) => Promise; + +export class SectionIndexGenerator { + constructor( + private readonly outputDir: string, + private readonly routeManager: GeneratedRouteManager, + ) {} + + async generate( + pages: PageMeta[], + sections: SectionConfig[] | null, + declaredSlugs: Set | undefined, + writeRedirect: WriteRedirect, + ): Promise { + const occupiedSlugs = new Set(pages.map((page) => page.slug)); + const resolvedDeclaredSlugs = new Set(occupiedSlugs); + for (const slug of declaredSlugs ?? []) resolvedDeclaredSlugs.add(slug); + const redirects = new Map(); + + for (const section of sections ?? []) { + if (section.slug === "" || resolvedDeclaredSlugs.has(section.slug)) { + continue; + } + const sectionPages = pages + .filter((page) => page.section === section.slug) + .sort((a, b) => { + if (a.categoryOrder !== b.categoryOrder) { + return a.categoryOrder - b.categoryOrder; + } + return a.order - b.order; + }); + const firstPage = sectionPages[0]; + if (firstPage) redirects.set(section.slug, firstPage.slug); + } + + const previousSlugs = this.routeManager.sectionIndexSlugs(); + const touchedSlugs = new Set([...previousSlugs, ...redirects.keys()]); + const previousFiles = new Map(); + for (const slug of touchedSlugs) { + previousFiles.set( + slug, + await readOutputFileIfPresent( + this.outputDir, + "app", + "(site)", + slug, + "page.tsx", + ), + ); + } + + try { + for (const [slug, target] of redirects) { + await writeRedirect(slug, target); + } + await this.routeManager.cleanupStaleSectionIndexPages( + new Set(redirects.keys()), + occupiedSlugs, + (dir, stopDir) => this.routeManager.removeEmptyDirsUpTo(dir, stopDir), + ); + } catch (error) { + const rollbackErrors: unknown[] = []; + this.routeManager.replaceSectionIndexSlugs(previousSlugs); + for (const [slug, content] of previousFiles) { + try { + const target = resolveOutputPath( + this.outputDir, + "app", + "(site)", + slug, + "page.tsx", + ); + if (content === null) { + await fs.remove(target); + } else { + await writeFileAtomic(target, content); + } + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + "Unable to generate or restore section index redirects", + ); + } + throw error; + } + } +} diff --git a/src/mdx-to-nextjs-generator.assets.test.ts b/src/mdx-to-nextjs-generator.assets.test.ts new file mode 100644 index 0000000..2d90d17 --- /dev/null +++ b/src/mdx-to-nextjs-generator.assets.test.ts @@ -0,0 +1,466 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { MDXToNextJSGenerator } from "./mdx-to-nextjs-generator.js"; + +import { fixture, waitUntil } from "./test-utils/generator-fixture.js"; + +describe.sequential("MDXToNextJSGenerator assets and config", () => { + it("preserves project-owned public aggregate artifacts on every refresh", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const artifacts = [ + ["LLMS.TXT", "llms.txt", "USER_LLMS_INDEX\n"], + ["Llms-Full.TxT", "llms-full.txt", "USER_LLMS_FULL\n"], + ["SKILL.MD", "skill.md", "USER_SKILL\n"], + [ + path.join(".WELL-KNOWN", "MCP.JSON"), + path.join(".well-known", "mcp.json"), + '{"user":true}\n', + ], + ] as const; + for (const [sourceRelativePath, , content] of artifacts) { + await fs.outputFile( + path.join(root, "public", sourceRelativePath), + content, + ); + } + + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await generator.updateLlmsFiles(); + + for (const [, outputRelativePath, content] of artifacts) { + expect( + await fs.readFile( + path.join(outputDir, "public", outputRelativePath), + "utf8", + ), + ).toBe(content); + } + }); + + it("rejects public symlinks during the initial public copy", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sensitivePath = path.join(root, "sensitive.txt"); + const publicPath = path.join(root, "public", "leaked.txt"); + await fs.writeFile(sensitivePath, "SENSITIVE_PUBLIC_CONTENT\n"); + await fs.ensureDir(path.dirname(publicPath)); + await fs.symlink(sensitivePath, publicPath, "file"); + await fs.ensureDir(outputDir); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + + await expect(generator.copyPublicFiles()).rejects.toThrow( + /public source.*leaked\.txt.*symbolic link/i, + ); + expect( + await fs.pathExists(path.join(outputDir, "public", "leaked.txt")), + ).toBe(false); + }); + + it("rejects public symlinks during watch-style copies", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sensitivePath = path.join(root, "sensitive.txt"); + const publicPath = path.join(root, "public", "leaked.txt"); + await fs.writeFile(sensitivePath, "SENSITIVE_PUBLIC_CONTENT\n"); + await fs.ensureDir(path.dirname(publicPath)); + await fs.symlink(sensitivePath, publicPath, "file"); + await fs.ensureDir(outputDir); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(generator.handlePublicFileChange(publicPath)).rejects.toThrow( + /public source.*leaked\.txt.*symbolic link/i, + ); + expect( + await fs.pathExists(path.join(outputDir, "public", "leaked.txt")), + ).toBe(false); + }); + + it("atomically replaces a hard-linked public destination", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(root, "public", "asset.bin"); + const destPath = path.join(outputDir, "public", "asset.bin"); + const externalPeer = path.join(root, "external-peer.bin"); + await fs.outputFile(sourcePath, Buffer.from([0, 1, 2, 255])); + await fs.outputFile(externalPeer, "keep"); + await fs.ensureDir(path.dirname(destPath)); + await fs.link(externalPeer, destPath); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + + await generator.handlePublicFileChange(sourcePath); + + await expect(fs.readFile(destPath)).resolves.toEqual( + Buffer.from([0, 1, 2, 255]), + ); + await expect(fs.readFile(externalPeer, "utf8")).resolves.toBe("keep"); + }); + + it("prunes public files deleted while the generator was stopped", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(root, "public", "obsolete.txt"); + const outputPath = path.join(outputDir, "public", "obsolete.txt"); + await fs.outputFile(sourcePath, "obsolete\n"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + + const first = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await first.init(); + expect(await fs.readFile(outputPath, "utf8")).toBe("obsolete\n"); + + await fs.remove(sourcePath); + const second = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await second.init(); + + expect(await fs.pathExists(outputPath)).toBe(false); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.publicFiles).toEqual([]); + }); + + it("writes normalized analytics configuration to the generated runtime", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + await fs.writeJson(path.join(root, "analytics.json"), { + provider: "posthog", + posthog: { + key: "phc_test-key", + host: " https://posthog.example/ ", + }, + }); + + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + + expect(await fs.readJson(path.join(outputDir, "analytics.json"))).toEqual({ + provider: "posthog", + posthog: { + key: "phc_test-key", + host: "https://posthog.example", + }, + }); + expect( + await fs.readFile(path.join(outputDir, "next.config.ts"), "utf8"), + ).toContain('destination: "https://posthog.example/:path*"'); + }); + + it("recreates public watching after the source directory is replaced", async () => { + const { root, watchDir, outputDir } = await fixture(); + const publicDir = path.join(root, "public"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + await fs.outputFile(path.join(publicDir, "old.txt"), "old\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await generator.startWatching(); + + await fs.remove(publicDir); + await waitUntil(() => + fs + .pathExists(path.join(outputDir, "public", "old.txt")) + .then((exists) => !exists), + ); + await fs.outputFile(path.join(publicDir, "new.txt"), "new\n"); + await waitUntil(() => + fs.pathExists(path.join(outputDir, "public", "new.txt")), + ); + await fs.writeFile(path.join(publicDir, "new.txt"), "updated\n"); + await waitUntil(async () => { + try { + return ( + (await fs.readFile( + path.join(outputDir, "public", "new.txt"), + "utf8", + )) === "updated\n" + ); + } catch { + return false; + } + }); + + await generator.stop(); + }); + + it.skipIf(process.platform !== "darwin" && process.platform !== "win32")( + "preserves case-only public renames on case-insensitive filesystems", + async () => { + const { root, watchDir, outputDir } = await fixture(); + const lowerSource = path.join(root, "public", "asset.txt"); + const upperSource = path.join(root, "public", "ASSET.txt"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + await fs.outputFile(lowerSource, "asset\n"); + + const first = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await first.init(); + await fs.rename(lowerSource, upperSource); + + const second = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await second.init(); + + await expect( + fs.readFile(path.join(outputDir, "public", "ASSET.txt"), "utf8"), + ).resolves.toBe("asset\n"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.publicFiles).toContain("ASSET.txt"); + expect(manifest.publicFiles).not.toContain("asset.txt"); + }, + ); + + it.skipIf(process.platform === "win32")( + "atomically replaces a symlinked public destination", + async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(root, "public", "asset.txt"); + const destPath = path.join(outputDir, "public", "asset.txt"); + const externalTarget = path.join(root, "external-target.txt"); + await fs.outputFile(sourcePath, "new"); + await fs.outputFile(externalTarget, "keep"); + await fs.ensureDir(path.dirname(destPath)); + await fs.symlink(externalTarget, destPath, "file"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + + await generator.handlePublicFileChange(sourcePath); + + await expect(fs.readFile(destPath, "utf8")).resolves.toBe("new"); + await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("keep"); + expect((await fs.lstat(destPath)).isSymbolicLink()).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a source parent swapped before the source is opened", + async () => { + const { root, watchDir, outputDir } = await fixture(); + const publicDir = path.join(root, "public"); + const sourceParent = path.join(publicDir, "assets"); + const displacedParent = path.join(publicDir, "assets-original"); + const externalParent = path.join(root, "external-assets"); + const sourcePath = path.join(sourceParent, "asset.txt"); + await fs.outputFile(sourcePath, "safe"); + await fs.outputFile(path.join(externalParent, "asset.txt"), "secret"); + await fs.ensureDir(outputDir); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + const realpath = fs.realpath.bind(fs); + let sourceResolutions = 0; + vi.spyOn(fs, "realpath").mockImplementation(async (candidate: string) => { + const resolved = await realpath(candidate); + if ( + path.resolve(candidate) === sourcePath && + sourceResolutions++ === 0 + ) { + await fs.rename(sourceParent, displacedParent); + await fs.symlink(externalParent, sourceParent, "dir"); + } + return resolved; + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + generator.handlePublicFileChange(sourcePath), + ).rejects.toThrow(/real path.*outside/i); + await expect( + fs.pathExists(path.join(outputDir, "public", "assets", "asset.txt")), + ).resolves.toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "keeps reading the opened source if its parent is swapped afterward", + async () => { + const { root, watchDir, outputDir } = await fixture(); + const publicDir = path.join(root, "public"); + const sourceParent = path.join(publicDir, "assets"); + const displacedParent = path.join(publicDir, "assets-original"); + const externalParent = path.join(root, "external-assets"); + const sourcePath = path.join(sourceParent, "asset.txt"); + const destPath = path.join(outputDir, "public", "assets", "asset.txt"); + await fs.outputFile(sourcePath, "safe"); + await fs.outputFile(path.join(externalParent, "asset.txt"), "secret"); + await fs.ensureDir(outputDir); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + const lstat = fs.lstat.bind(fs); + let sourceStats = 0; + vi.spyOn(fs, "lstat").mockImplementation(async (candidate: string) => { + const stat = await lstat(candidate); + if (path.resolve(candidate) === sourcePath && ++sourceStats === 4) { + await fs.rename(sourceParent, displacedParent); + await fs.symlink(externalParent, sourceParent, "dir"); + } + return stat; + }); + + await generator.handlePublicFileChange(sourcePath); + + await expect(fs.readFile(destPath, "utf8")).resolves.toBe("safe"); + await expect( + fs.readFile(path.join(externalParent, "asset.txt"), "utf8"), + ).resolves.toBe("secret"); + }, + ); + + it("restores mixed-case managed public overrides after watch deletion", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "guide.mdx"), + "---\ntitle: Guide\n---\nGUIDE_BODY\n", + ); + await fs.writeJson(path.join(root, "config.json"), { + name: "Test Docs", + url: "https://docs.example.com", + }); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const artifacts = [ + ["LLMS.TXT", "llms.txt"], + ["LLMS-FULL.TXT", "llms-full.txt"], + ["SKILL.MD", "skill.md"], + ["GUIDE.MD", "guide.md"], + [ + path.join(".WELL-KNOWN", "MCP.JSON"), + path.join(".well-known", "mcp.json"), + ], + ] as const; + const generated = new Map(); + for (const [, outputRelativePath] of artifacts) { + generated.set( + outputRelativePath, + await fs.readFile( + path.join(outputDir, "public", outputRelativePath), + "utf8", + ), + ); + } + + for (const [sourceRelativePath, outputRelativePath] of artifacts) { + const sourcePath = path.join(root, "public", sourceRelativePath); + await fs.outputFile(sourcePath, `USER:${sourceRelativePath}\n`); + await generator.handlePublicFileChange(sourcePath); + expect( + await fs.readFile( + path.join(outputDir, "public", outputRelativePath), + "utf8", + ), + ).toBe(`USER:${sourceRelativePath}\n`); + } + let manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.llmsPageFiles).not.toContain("guide.md"); + + for (const [sourceRelativePath, outputRelativePath] of artifacts) { + const sourcePath = path.join(root, "public", sourceRelativePath); + await fs.remove(sourcePath); + await generator.handlePublicFileDelete(sourcePath); + expect( + await fs.readFile( + path.join(outputDir, "public", outputRelativePath), + "utf8", + ), + ).toBe(generated.get(outputRelativePath)); + } + manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.llmsPageFiles).toContain("guide.md"); + }); + + it("restores generated public artifacts after watch-style overrides are deleted", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "skill.mdx"), + "---\ntitle: Skill Page\n---\nPAGE_SKILL_BODY\n", + ); + await fs.outputFile( + path.join(watchDir, "guide.mdx"), + "---\ntitle: Guide\n---\nGUIDE_BODY\n", + ); + const configPath = path.join(root, "config.json"); + await fs.writeJson(configPath, { + name: "Test Docs", + url: "https://docs.example.com", + }); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + + const relativePaths = [ + "llms.txt", + "llms-full.txt", + "skill.md", + "guide.md", + path.join(".well-known", "mcp.json"), + ]; + const generated = new Map(); + for (const relativePath of relativePaths) { + generated.set( + relativePath, + await fs.readFile(path.join(outputDir, "public", relativePath), "utf8"), + ); + } + expect(generated.get("skill.md")).toContain("## Reading these docs"); + expect(generated.get("skill.md")).not.toContain("PAGE_SKILL_BODY"); + + for (const relativePath of relativePaths) { + const sourcePath = path.join(root, "public", relativePath); + await fs.outputFile(sourcePath, `USER_OVERRIDE:${relativePath}\n`); + await generator.handlePublicFileChange(sourcePath); + expect( + await fs.readFile(path.join(outputDir, "public", relativePath), "utf8"), + ).toBe(`USER_OVERRIDE:${relativePath}\n`); + } + let manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.llmsPageFiles).not.toContain("skill.md"); + expect(manifest.llmsPageFiles).not.toContain("guide.md"); + + for (const relativePath of relativePaths) { + const sourcePath = path.join(root, "public", relativePath); + await fs.remove(sourcePath); + await generator.handlePublicFileDelete(sourcePath); + expect( + await fs.readFile(path.join(outputDir, "public", relativePath), "utf8"), + ).toBe(generated.get(relativePath)); + } + + manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.llmsPageFiles).not.toContain("skill.md"); + expect(manifest.llmsPageFiles).toContain("guide.md"); + + await fs.writeJson(configPath, { name: "Test Docs" }); + await generator.handleConfigFileChange(configPath); + expect( + await fs.pathExists( + path.join(outputDir, "public", ".well-known", "mcp.json"), + ), + ).toBe(false); + }); + + it("does not overwrite or delete a colliding project public asset", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "guide.mdx"), + "---\ntitle: Guide\n---\nGenerated body\n", + ); + await fs.outputFile( + path.join(root, "public", "guide.md"), + "USER_OWNED_PUBLIC_ASSET\n", + ); + + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const publicAsset = path.join(outputDir, "public", "guide.md"); + expect(await fs.readFile(publicAsset, "utf8")).toBe( + "USER_OWNED_PUBLIC_ASSET\n", + ); + + await fs.remove(path.join(watchDir, "guide.mdx")); + await generator.handleFileDelete("guide.mdx"); + expect(await fs.readFile(publicAsset, "utf8")).toBe( + "USER_OWNED_PUBLIC_ASSET\n", + ); + }); +}); diff --git a/src/mdx-to-nextjs-generator.mdx-reconciliation.test.ts b/src/mdx-to-nextjs-generator.mdx-reconciliation.test.ts new file mode 100644 index 0000000..745caa2 --- /dev/null +++ b/src/mdx-to-nextjs-generator.mdx-reconciliation.test.ts @@ -0,0 +1,1237 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { SecureSourceFs } from "./generator/secure-source-fs.js"; +import { MDXToNextJSGenerator } from "./mdx-to-nextjs-generator.js"; + +import { fixture } from "./test-utils/generator-fixture.js"; + +describe.sequential("MDXToNextJSGenerator MDX reconciliation", () => { + it("restores sections.json and rebuilds routes after deletion", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "guides", "intro.mdx"), + "---\ntitle: Intro\n---\nIntro\n", + ); + const sectionsPath = path.join(root, "sections.json"); + await fs.writeJson(sectionsPath, [ + { label: "Guides", slug: "guides", directory: "guides" }, + ]); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + + await fs.remove(sectionsPath); + await generator.handleConfigFileDelete(sectionsPath); + + expect( + await fs.readFile(path.join(outputDir, "sections.json"), "utf8"), + ).toBe("[]\n"); + expect( + await fs.pathExists( + path.join(outputDir, "app", "(site)", "guides", "intro", "page.tsx"), + ), + ).toBe(true); + }); + + it("preserves a real section page containing the redirect function name", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "guides", "intro.mdx"), + "---\ntitle: Intro\n---\nIntro\n", + ); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Guides", slug: "guides", directory: "guides" }, + ]); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.outputFile( + path.join(watchDir, "guides", "index.mdx"), + "---\ntitle: Guides\n---\n```tsx\nfunction SectionIndex() {}\n```\n", + ); + + await generator.handleFileChange("added", path.join("guides", "index.mdx")); + + const page = await fs.readFile( + path.join(outputDir, "app", "(site)", "guides", "page.tsx"), + "utf8", + ); + expect(page).toContain("function SectionIndex() {}"); + }); + + it("does not preserve a section redirect for a page that failed to render", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "guides", "intro.mdx"), + "---\ntitle: Intro\n---\nIntro\n", + ); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Guides", slug: "guides", directory: "guides" }, + ]); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.outputFile( + path.join(watchDir, "guides", "index.mdx"), + "---\ntitle: Guides\nimage: &self [*self]\n---\nGuides\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("added", "guides/index.mdx"); + + expect( + await fs.pathExists( + path.join(outputDir, "app", "(site)", "guides", "page.tsx"), + ), + ).toBe(false); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ kind: "mdx", source: "guides/index.mdx" }), + ); + + await fs.writeFile( + path.join(watchDir, "guides", "intro.mdx"), + "---\ntitle: Updated Intro\n---\nUpdated intro\n", + ); + await generator.handleFileChange("changed", "guides/intro.mdx"); + + expect( + await fs.pathExists( + path.join(outputDir, "app", "(site)", "guides", "page.tsx"), + ), + ).toBe(false); + const updatedManifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(updatedManifest.routes).not.toContainEqual( + expect.objectContaining({ kind: "mdx", source: "guides/index.mdx" }), + ); + }); + + it("rolls back section redirects when a later redirect fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.writeFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.writeFile( + path.join(watchDir, "alpha.mdx"), + "---\ntitle: Alpha\nsection: Alpha\n---\nAlpha\n", + ); + await fs.writeFile( + path.join(watchDir, "beta.mdx"), + "---\ntitle: Beta\nsection: Beta\n---\nBeta\n", + ); + type GeneratorInternals = { + writeSectionIndexRedirect(slug: string, target: string): Promise; + generatedRouteManager: { sectionIndexSlugs(): Set }; + }; + const internals = generator as unknown as GeneratorInternals; + const writeRedirect = internals.writeSectionIndexRedirect.bind(generator); + let writes = 0; + vi.spyOn(internals, "writeSectionIndexRedirect").mockImplementation( + async (slug, target) => { + writes += 1; + if (writes === 2) throw new Error("Injected redirect failure"); + await writeRedirect(slug, target); + }, + ); + + await expect(generator.processAllMDXFiles()).rejects.toThrow( + "Injected redirect failure", + ); + + expect( + await fs.pathExists( + path.join(outputDir, "app", "(site)", "alpha", "page.tsx"), + ), + ).toBe(false); + expect( + await fs.pathExists( + path.join(outputDir, "app", "(site)", "beta", "page.tsx"), + ), + ).toBe(false); + expect(internals.generatedRouteManager.sectionIndexSlugs()).toEqual( + new Set(), + ); + }); + + it("restores section redirects when stale cleanup fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.writeFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const alphaSource = path.join(watchDir, "alpha.mdx"); + const betaSource = path.join(watchDir, "beta.mdx"); + await fs.writeFile( + alphaSource, + "---\ntitle: Alpha\nsection: Alpha\n---\nAlpha\n", + ); + await fs.writeFile( + betaSource, + "---\ntitle: Beta\nsection: Beta\n---\nBeta\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const alphaRedirect = path.join( + outputDir, + "app", + "(site)", + "alpha", + "page.tsx", + ); + const betaRedirect = path.join( + outputDir, + "app", + "(site)", + "beta", + "page.tsx", + ); + const previousAlpha = await fs.readFile(alphaRedirect, "utf8"); + const previousBeta = await fs.readFile(betaRedirect, "utf8"); + await Promise.all([fs.remove(alphaSource), fs.remove(betaSource)]); + type RouteManagerInternals = { + removeSectionIndexPage( + slug: string, + removeEmptyDirs: (dir: string, stopDir: string) => Promise, + ): Promise; + }; + type GeneratorInternals = { + generatedRouteManager: RouteManagerInternals; + }; + const routeManager = (generator as unknown as GeneratorInternals) + .generatedRouteManager; + const removeSectionIndexPage = + routeManager.removeSectionIndexPage.bind(routeManager); + let cleanupCalls = 0; + vi.spyOn(routeManager, "removeSectionIndexPage").mockImplementation( + async (slug, removeEmptyDirs) => { + cleanupCalls += 1; + if (cleanupCalls === 2) { + throw new Error("Injected stale redirect cleanup failure"); + } + await removeSectionIndexPage(slug, removeEmptyDirs); + }, + ); + + await expect(generator.processAllMDXFiles()).rejects.toThrow( + "Unable to remove stale section index redirects", + ); + + expect(cleanupCalls).toBeGreaterThanOrEqual(2); + expect(await fs.readFile(alphaRedirect, "utf8")).toBe(previousAlpha); + expect(await fs.readFile(betaRedirect, "utf8")).toBe(previousBeta); + }); + + it("retains the last successful page when bulk regeneration fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nOriginal\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const originalPage = await fs.readFile(pagePath, "utf8"); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nimage: &self [*self]\n---\nBroken\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.processAllMDXFiles(); + + expect(await fs.readFile(pagePath, "utf8")).toBe(originalPage); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "guide", + }), + ); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ + uri: "docs://guide", + content: "Original\n", + }), + ); + expect( + await fs.readFile(path.join(outputDir, "app", "sitemap.ts"), "utf8"), + ).toContain('slug: "guide"'); + const llmsFull = await fs.readFile( + path.join(outputDir, "public", "llms-full.txt"), + "utf8", + ); + expect(llmsFull).toContain("Original"); + expect(llmsFull).not.toContain("Broken"); + }); + + it("keeps the previous route when a moved replacement fails to render", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const previousPage = path.join( + outputDir, + "app", + "(site)", + "guides", + "guide", + "page.tsx", + ); + const nextPage = path.join( + outputDir, + "app", + "(site)", + "tutorials", + "guide", + "page.tsx", + ); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Guides\n---\nOriginal\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const originalPage = await fs.readFile(previousPage, "utf8"); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Tutorials\nimage: &self [*self]\n---\nBroken\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(await fs.readFile(previousPage, "utf8")).toBe(originalPage); + expect(await fs.pathExists(nextPage)).toBe(false); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "guides/guide", + }), + ); + }); + + it("restores inferred sections when a changed page fails to render", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Guides\n---\nOLD_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "guides", + "guide", + "page.tsx", + ); + const previousPage = await fs.readFile(pagePath, "utf8"); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Tutorials\nimage: &self [*self]\n---\nBROKEN_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toBe(previousPage); + const layout = await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ); + expect(layout).toContain('label: "Guides"'); + expect(layout).not.toContain('label: "Tutorials"'); + }); + + it("keeps stale routes owned when replacement ownership cannot persist", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const previousPage = path.join( + outputDir, + "app", + "(site)", + "guides", + "guide", + "page.tsx", + ); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Guides", slug: "guides" }, + { label: "Tutorials", slug: "tutorials" }, + ]); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Guides\n---\nOriginal\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + type GeneratorInternals = { + artifacts: { + replaceRoutesAndSave( + kind: string, + routes: Iterable<{ source: string; slug: string }>, + ): Promise; + }; + }; + const artifacts = (generator as unknown as GeneratorInternals).artifacts; + vi.spyOn(artifacts, "replaceRoutesAndSave").mockRejectedValueOnce( + new Error("Injected manifest failure"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", + ); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(await fs.pathExists(previousPage)).toBe(true); + expect( + await fs.pathExists( + path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), + ), + ).toBe(false); + let manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "guides/guide", + }), + ); + + await generator.handleFileChange("changed", "guide.mdx"); + expect(await fs.pathExists(previousPage)).toBe(false); + manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "tutorials/guide", + }), + ); + }); + + it("retries stale route cleanup after a transient removal failure", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const previousPage = path.join( + outputDir, + "app", + "(site)", + "guides", + "guide", + "page.tsx", + ); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Guides", slug: "guides" }, + { label: "Tutorials", slug: "tutorials" }, + ]); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Guides\n---\nOriginal\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + type GeneratorInternals = { + generatedRouteManager: { removeOwnedRoute(slug: string): Promise }; + }; + const routeManager = (generator as unknown as GeneratorInternals) + .generatedRouteManager; + vi.spyOn(routeManager, "removeOwnedRoute").mockRejectedValueOnce( + new Error("Injected cleanup failure"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", + ); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(await fs.pathExists(previousPage)).toBe(true); + let manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "tutorials/guide", + }), + ); + + await generator.handleFileChange("changed", "guide.mdx"); + expect(await fs.pathExists(previousPage)).toBe(false); + manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ slug: "guides/guide" }), + ); + }); + + it("restores the previous page when its RSS route cannot be updated", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const pageDir = path.join(outputDir, "app", "(site)", "guide"); + const pagePath = path.join(pageDir, "page.tsx"); + await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nOriginal\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const originalPage = await fs.readFile(pagePath, "utf8"); + const externalRss = path.join(root, "external-rss"); + await fs.ensureDir(externalRss); + await fs.writeFile(path.join(externalRss, "route.ts"), "KEEP\n"); + await fs.symlink( + externalRss, + path.join(pageDir, "rss.xml"), + process.platform === "win32" ? "junction" : "dir", + ); + await fs.writeFile( + sourcePath, + '---\ntitle: Guide\n---\nChanged\nFeed\n', + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toBe(originalPage); + expect(await fs.readFile(path.join(externalRss, "route.ts"), "utf8")).toBe( + "KEEP\n", + ); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ uri: "docs://guide", content: "Original\n" }), + ); + }); + + it("removes stale output when a same-route source handoff fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile( + path.join(watchDir, "guide.mdx"), + "---\ntitle: Guide\n---\nOriginal\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + + await fs.remove(path.join(watchDir, "guide.mdx")); + await fs.outputFile( + path.join(watchDir, "guide", "index.mdx"), + "---\ntitle: Broken\nimage: &self [*self]\n---\nBroken\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await generator.processAllMDXFiles(); + + expect(await fs.pathExists(pagePath)).toBe(false); + let manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ kind: "mdx", slug: "guide" }), + ); + + await fs.remove(path.join(watchDir, "guide", "index.mdx")); + await generator.handleFileDelete("guide/index.mdx"); + expect(await fs.pathExists(pagePath)).toBe(false); + manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ kind: "mdx", slug: "guide" }), + ); + }); + + it("does not retain historical ownership after startup clears page output", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nOriginal\n"); + await new MDXToNextJSGenerator(watchDir, outputDir, [], root).init(); + expect(await fs.pathExists(pagePath)).toBe(true); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nimage: &self [*self]\n---\nBroken\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await new MDXToNextJSGenerator(watchDir, outputDir, [], root).init(); + + expect(await fs.pathExists(pagePath)).toBe(false); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ kind: "mdx", source: "guide.mdx" }), + ); + }); + + it("retains the last successful homepage and aggregate content", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "index.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "page.tsx"); + await fs.writeFile(sourcePath, "---\ntitle: Home\n---\nORIGINAL_HOME\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const originalPage = await fs.readFile(pagePath, "utf8"); + await fs.writeFile( + sourcePath, + "---\ntitle: Broken Home\nimage: &self [*self]\n---\nBROKEN_HOME\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("changed", "index.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toBe(originalPage); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ + uri: "docs:///", + content: "ORIGINAL_HOME\n", + }), + ); + expect(JSON.stringify(docsContent)).not.toContain("BROKEN_HOME"); + }); + + it("rolls back MDX pages, ownership, sections, and aggregates together", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Guides\n---\nOLD_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const oldPagePath = path.join( + outputDir, + "app", + "(site)", + "guides", + "guide", + "page.tsx", + ); + const newPagePath = path.join( + outputDir, + "app", + "(site)", + "tutorials", + "guide", + "page.tsx", + ); + const previousPage = await fs.readFile(oldPagePath, "utf8"); + const previousLayout = await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ); + const previousSitemap = await fs.readFile( + path.join(outputDir, "app", "sitemap.ts"), + "utf8", + ); + const previousDocs = await fs.readFile( + path.join(outputDir, "services", "mcp", "docs-content.json"), + "utf8", + ); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Tutorials\n---\nNEW_BODY\n", + ); + vi.spyOn(generator, "updateLlmsFiles").mockRejectedValueOnce( + new Error("Injected aggregate failure"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(await fs.readFile(oldPagePath, "utf8")).toBe(previousPage); + expect(await fs.pathExists(newPagePath)).toBe(false); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ), + ).toBe(previousLayout); + expect( + await fs.readFile(path.join(outputDir, "app", "sitemap.ts"), "utf8"), + ).toBe(previousSitemap); + expect( + await fs.readFile( + path.join(outputDir, "services", "mcp", "docs-content.json"), + "utf8", + ), + ).toBe(previousDocs); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ source: "guide.mdx", slug: "guides/guide" }), + ); + }); + + it("keeps a rolled-back deletion in later aggregate refreshes", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "index.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "page.tsx"); + await fs.writeFile( + sourcePath, + "---\ntitle: Retained Home\n---\nRETAINED_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const previousPage = await fs.readFile(pagePath, "utf8"); + const sitemapPath = path.join(outputDir, "app", "sitemap.ts"); + const previousSitemap = await fs.readFile(sitemapPath, "utf8"); + await fs.remove(sourcePath); + vi.spyOn(generator, "updateLlmsFiles").mockRejectedValueOnce( + new Error("Injected aggregate failure"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileDelete("index.mdx"); + type GeneratorInternals = { + refreshSiteAggregates(): Promise; + }; + await (generator as unknown as GeneratorInternals).refreshSiteAggregates(); + + expect(await fs.readFile(pagePath, "utf8")).toBe(previousPage); + expect(await fs.readFile(sitemapPath, "utf8")).toBe(previousSitemap); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ), + ).toContain("Retained Home"); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ + uri: "docs:///", + content: "RETAINED_BODY\n", + }), + ); + }); + + it("deletes the recorded frontmatter route and preserves nested pages", async () => { + const { watchDir, outputDir } = await fixture(); + await fs.outputFile( + path.join(watchDir, "guide.mdx"), + "---\ntitle: Guide\nsection: Guides\n---\nParent\n", + ); + await fs.outputFile( + path.join(watchDir, "guides", "child.mdx"), + "---\ntitle: Child\nsection: Guides\n---\nChild\n", + ); + + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + await generator.init(); + const parentPage = path.join( + outputDir, + "app", + "(site)", + "guides", + "guide", + "page.tsx", + ); + const childPage = path.join( + outputDir, + "app", + "(site)", + "guides", + "child", + "page.tsx", + ); + expect(await fs.pathExists(parentPage)).toBe(true); + expect(await fs.pathExists(childPage)).toBe(true); + + await fs.writeFile( + path.join(watchDir, "guide.mdx"), + "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", + ); + await generator.handleFileChange("changed", "guide.mdx"); + const movedPage = path.join( + outputDir, + "app", + "(site)", + "tutorials", + "guide", + "page.tsx", + ); + expect(await fs.pathExists(parentPage)).toBe(false); + expect(await fs.pathExists(childPage)).toBe(true); + expect(await fs.pathExists(movedPage)).toBe(true); + + await fs.remove(path.join(watchDir, "guide.mdx")); + await generator.handleFileDelete("guide.mdx"); + + expect(await fs.pathExists(movedPage)).toBe(false); + expect(await fs.pathExists(childPage)).toBe(true); + }); + + it("generates the surviving source when deleting a colliding route owner", async () => { + const { root, watchDir, outputDir } = await fixture(); + const originalSource = path.join(watchDir, "guide.mdx"); + const survivingSource = path.join(watchDir, "guide", "index.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile( + originalSource, + "---\ntitle: Original\n---\nORIGINAL_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.outputFile( + survivingSource, + "---\ntitle: Survivor\n---\nSURVIVOR_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("added", "guide/index.mdx"); + await fs.remove(originalSource); + await generator.handleFileDelete("guide.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toContain("SURVIVOR_BODY"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide/index.mdx", + slug: "guide", + }), + ); + }); + + it("does not commit inferred sections from a colliding pass", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const collidingPath = path.join(watchDir, "tutorials", "guide.mdx"); + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Guides\n---\nGUIDE_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + type GeneratorInternals = { + sectionsConfig: Array<{ label: string; slug: string }> | null; + }; + const internals = generator as unknown as GeneratorInternals; + await fs.writeFile( + sourcePath, + "---\ntitle: Guide\nsection: Tutorials\n---\nMOVED_BODY\n", + ); + await fs.outputFile( + collidingPath, + "---\ntitle: Collision\nsection: Tutorials\n---\nCOLLISION_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(internals.sectionsConfig).toEqual([ + { label: "Guides", slug: "guides" }, + ]); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ), + ).toContain('label: "Guides"'); + + await fs.remove(collidingPath); + await generator.handleFileDelete("tutorials/guide.mdx"); + expect(internals.sectionsConfig).toEqual([ + { label: "Tutorials", slug: "tutorials" }, + ]); + }); + + it("generates a blocked collision source when its owner moves routes", async () => { + const { root, watchDir, outputDir } = await fixture(); + const movingSource = path.join(watchDir, "guide.mdx"); + const blockedSource = path.join(watchDir, "guide", "index.mdx"); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Docs", slug: "" }, + { label: "Tutorials", slug: "tutorials" }, + ]); + await fs.writeFile( + movingSource, + "---\ntitle: Original\n---\nORIGINAL_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.outputFile( + blockedSource, + "---\ntitle: Replacement\n---\nREPLACEMENT_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await generator.handleFileChange("added", "guide/index.mdx"); + await fs.writeFile( + movingSource, + "---\ntitle: Moved\nsection: Tutorials\n---\nMOVED_BODY\n", + ); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "guide", "page.tsx"), + "utf8", + ), + ).toContain("REPLACEMENT_BODY"); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), + "utf8", + ), + ).toContain("MOVED_BODY"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "mdx", + source: "guide/index.mdx", + slug: "guide", + }), + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "tutorials/guide", + }), + ]), + ); + }); + + it("replays unrelated MDX changes after a route collision clears", async () => { + const { root, watchDir, outputDir } = await fixture(); + const movingSource = path.join(watchDir, "guide.mdx"); + const collidingSource = path.join(watchDir, "guide", "index.mdx"); + const unrelatedSource = path.join(watchDir, "other.mdx"); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Docs", slug: "" }, + { label: "Tutorials", slug: "tutorials" }, + ]); + await fs.writeFile(movingSource, "---\ntitle: Guide\n---\nGuide\n"); + await fs.writeFile(unrelatedSource, "---\ntitle: Other\n---\nOLD_OTHER\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.outputFile( + collidingSource, + "---\ntitle: Replacement\n---\nReplacement\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await generator.handleFileChange("added", "guide/index.mdx"); + await fs.writeFile(unrelatedSource, "---\ntitle: Other\n---\nNEW_OTHER\n"); + await generator.handleFileChange("changed", "other.mdx"); + await fs.writeFile( + movingSource, + "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", + ); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "other", "page.tsx"), + "utf8", + ), + ).toContain("NEW_OTHER"); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ uri: "docs://other", content: "NEW_OTHER\n" }), + ); + }); + + it("retries changed content after deleting its colliding source", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const collidingPath = path.join(watchDir, "guide", "index.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nORIGINAL_BODY\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.outputFile( + collidingPath, + "---\ntitle: Collision\n---\nCOLLIDING_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await generator.handleFileChange("added", "guide/index.mdx"); + await fs.writeFile( + sourcePath, + "---\ntitle: Updated Guide\n---\nUPDATED_BODY\n", + ); + await generator.handleFileChange("changed", "guide.mdx"); + + await fs.remove(collidingPath); + await generator.handleFileDelete("guide/index.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toContain("UPDATED_BODY"); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ + uri: "docs://guide", + content: "UPDATED_BODY\n", + }), + ); + }); + + it("recovers changed content after a bulk collision clears", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + const collidingPath = path.join(watchDir, "guide", "index.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nORIGINAL_BODY\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.writeFile( + sourcePath, + "---\ntitle: Updated Guide\n---\nUPDATED_BODY\n", + ); + await fs.outputFile( + collidingPath, + "---\ntitle: Collision\n---\nCOLLIDING_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(generator.processAllMDXFiles()).rejects.toThrow( + /Route collision/, + ); + await fs.remove(collidingPath); + await generator.handleFileDelete("guide/index.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toContain("UPDATED_BODY"); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect(docsContent).toContainEqual( + expect.objectContaining({ + uri: "docs://guide", + content: "UPDATED_BODY\n", + }), + ); + }); + + it("lets a successful source replace a conflicting retained snapshot", async () => { + const { root, watchDir, outputDir } = await fixture(); + const originalSource = path.join(watchDir, "guide.mdx"); + const replacementSource = path.join(watchDir, "guide", "index.mdx"); + const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); + await fs.writeFile( + originalSource, + "---\ntitle: Original\n---\nORIGINAL_BODY\n", + ); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Docs", slug: "" }, + { label: "Tutorials", slug: "tutorials" }, + ]); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.writeFile( + originalSource, + "---\ntitle: Broken move\nsection: Tutorials\nimage: &self [*self]\n---\nBROKEN_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await generator.handleFileChange("changed", "guide.mdx"); + await fs.outputFile( + replacementSource, + "---\ntitle: Replacement\n---\nREPLACEMENT_BODY\n", + ); + + await generator.handleFileChange("added", "guide/index.mdx"); + + expect(await fs.readFile(pagePath, "utf8")).toContain("REPLACEMENT_BODY"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect( + manifest.routes.filter( + (route: { kind: string; slug: string }) => + route.kind === "mdx" && route.slug === "guide", + ), + ).toEqual([ + expect.objectContaining({ source: "guide/index.mdx", slug: "guide" }), + ]); + const docsContent = await fs.readJson( + path.join(outputDir, "services", "mcp", "docs-content.json"), + ); + expect( + docsContent.filter( + (document: { uri: string }) => document.uri === "docs://guide", + ), + ).toEqual([expect.objectContaining({ content: "REPLACEMENT_BODY\n" })]); + }); + + it("retries a surviving collision source whose cached route is stale", async () => { + const { root, watchDir, outputDir } = await fixture(); + const movingSource = path.join(watchDir, "guide.mdx"); + const blockingSource = path.join(watchDir, "tutorials", "guide.mdx"); + const previousPage = path.join( + outputDir, + "app", + "(site)", + "guide", + "page.tsx", + ); + const movedPage = path.join( + outputDir, + "app", + "(site)", + "tutorials", + "guide", + "page.tsx", + ); + await fs.writeFile(movingSource, "---\ntitle: Moving\n---\nMOVING_BODY\n"); + await fs.outputFile( + blockingSource, + "---\ntitle: Blocking\nsection: Tutorials\n---\nBLOCKING_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.writeFile( + movingSource, + "---\ntitle: Moving\nsection: Tutorials\n---\nMOVED_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await generator.handleFileChange("changed", "guide.mdx"); + + await fs.remove(blockingSource); + await generator.handleFileDelete("tutorials/guide.mdx"); + + expect(await fs.pathExists(previousPage)).toBe(false); + expect(await fs.readFile(movedPage, "utf8")).toContain("MOVED_BODY"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + source: "guide.mdx", + slug: "tutorials/guide", + }), + ); + }); + + it("uses one captured source version for metadata and rendering", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(watchDir, "guide.mdx"); + await fs.writeJson(path.join(root, "sections.json"), [ + { label: "Guides", slug: "guides" }, + { label: "Tutorials", slug: "tutorials" }, + ]); + await fs.writeFile( + sourcePath, + "---\ntitle: Initial\nsection: Guides\n---\nInitial\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + type GeneratorInternals = { sourceFs: SecureSourceFs }; + const sourceFs = (generator as unknown as GeneratorInternals).sourceFs; + const readSource = sourceFs.readMdxSourceFile.bind(sourceFs); + let guideReads = 0; + const readSpy = vi + .spyOn(sourceFs, "readMdxSourceFile") + .mockImplementation(async (filePath) => { + const captured = await readSource(filePath); + if (filePath.replace(/\\/g, "/").endsWith("guide.mdx")) { + guideReads += 1; + if (guideReads === 1) { + await fs.writeFile( + sourcePath, + "---\ntitle: Later\nsection: Tutorials\n---\nLATER_BODY\n", + ); + } + } + return captured; + }); + await fs.writeFile( + sourcePath, + "---\ntitle: Captured\nsection: Guides\n---\nCAPTURED_BODY\n", + ); + + await generator.handleFileChange("changed", "guide.mdx"); + + expect(guideReads).toBe(1); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "guides", "guide", "page.tsx"), + "utf8", + ), + ).toContain("CAPTURED_BODY"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ source: "guide.mdx", slug: "guides/guide" }), + ); + + readSpy.mockRestore(); + await generator.handleFileChange("changed", "guide.mdx"); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), + "utf8", + ), + ).toContain("LATER_BODY"); + }); + + it("refreshes inferred sections before validating changed routes", async () => { + const { root, watchDir, outputDir } = await fixture(); + const movingSource = path.join(watchDir, "nested.mdx"); + await fs.writeFile( + movingSource, + "---\ntitle: Moving\nsection: Guides\n---\nOLD_BODY\n", + ); + await fs.outputFile( + path.join(watchDir, "nested", "index.mdx"), + "---\ntitle: Nested\n---\nNESTED_BODY\n", + ); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.writeFile( + movingSource, + "---\ntitle: Moving\nsection: Tutorials\n---\nNEW_BODY\n", + ); + + await generator.handleFileChange("changed", "nested.mdx"); + + expect( + await fs.readFile( + path.join( + outputDir, + "app", + "(site)", + "tutorials", + "nested", + "page.tsx", + ), + "utf8", + ), + ).toContain("NEW_BODY"); + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "nested", "page.tsx"), + "utf8", + ), + ).toContain("NESTED_BODY"); + }); +}); diff --git a/src/mdx-to-nextjs-generator.openapi.test.ts b/src/mdx-to-nextjs-generator.openapi.test.ts new file mode 100644 index 0000000..745f75e --- /dev/null +++ b/src/mdx-to-nextjs-generator.openapi.test.ts @@ -0,0 +1,975 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { MDXToNextJSGenerator } from "./mdx-to-nextjs-generator.js"; + +import { fixture, waitUntil } from "./test-utils/generator-fixture.js"; + +describe.sequential("MDXToNextJSGenerator OpenAPI", () => { + it("never overwrites or cleans up a hand-written OpenAPI route", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const operation = { + operationId: "listUsers", + summary: "Generated operation", + tags: ["users"], + responses: { "200": { description: "OK" } }, + }; + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { "/users": { get: operation } }, + }); + await fs.outputFile( + path.join(watchDir, "api-reference", "users", "listusers.mdx"), + "---\ntitle: Hand Written\n---\nHAND_WRITTEN_SENTINEL\n", + ); + + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + expect(await fs.readFile(pagePath, "utf8")).toContain( + "HAND_WRITTEN_SENTINEL", + ); + + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + }); + await generator.handleOpenApiChange(); + + expect(await fs.readFile(pagePath, "utf8")).toContain( + "HAND_WRITTEN_SENTINEL", + ); + }); + + it("includes the API Reference section in the initial generated layout", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + + await new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ).init(); + + const layout = await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ); + expect(layout).toContain("doccupineSections"); + expect(layout).toContain('label: "API Reference"'); + expect(layout).toContain('slug: "api-reference"'); + }); + + it("hands a removed MDX route back to OpenAPI across a restart", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary: "Generated operation", + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const handwrittenSource = path.join( + watchDir, + "api-reference", + "users", + "listusers.mdx", + ); + await fs.outputFile( + handwrittenSource, + "---\ntitle: Hand Written\n---\nHAND_WRITTEN_SENTINEL\n", + ); + const specs = [{ name: "Test", file: specPath }]; + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + + await new MDXToNextJSGenerator(watchDir, outputDir, specs, root).init(); + await fs.remove(handwrittenSource); + await new MDXToNextJSGenerator(watchDir, outputDir, specs, root).init(); + + expect(await fs.readFile(pagePath, "utf8")).toContain( + "Generated operation", + ); + }); + + it("clears persisted OpenAPI ownership after restarting without specs", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary: "Generated operation", + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + await new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ).init(); + let manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ kind: "openapi" }), + ); + + await new MDXToNextJSGenerator(watchDir, outputDir, [], root).init(); + + manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ kind: "openapi" }), + ); + }); + + it("does not publish OpenAPI metadata for a route blocked by broken MDX", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary: "Generated operation", + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + await fs.outputFile( + path.join(watchDir, "api-reference", "users", "listusers.mdx"), + "---\ntitle: Broken\nimage: &self [*self]\n---\nBroken\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + + await generator.init(); + + expect( + await fs.pathExists( + path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ), + ), + ).toBe(false); + const layout = await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ); + expect(layout).not.toContain('slug: "api-reference/users/listusers"'); + }); + + it("removes an OpenAPI page claimed by a broken incremental MDX source", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary: "Generated operation", + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + expect(await fs.pathExists(pagePath)).toBe(true); + await fs.outputFile( + path.join(watchDir, "api-reference", "users", "listusers.mdx"), + "---\ntitle: Broken\nimage: &self [*self]\n---\nBroken\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange( + "added", + "api-reference/users/listusers.mdx", + ); + + expect(await fs.pathExists(pagePath)).toBe(false); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ slug: "api-reference/users/listusers" }), + ); + const layout = await fs.readFile( + path.join(outputDir, "app", "(site)", "layout.tsx"), + "utf8", + ); + expect(layout).not.toContain('slug: "api-reference/users/listusers"'); + }); + + it("does not overwrite retained MDX output with OpenAPI after a failed move", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary: "Generated operation", + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + const sourcePath = path.join( + watchDir, + "api-reference", + "users", + "listusers.mdx", + ); + await fs.outputFile( + sourcePath, + "---\ntitle: Handwritten\n---\nHANDWRITTEN_BODY\n", + ); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + await fs.writeFile( + sourcePath, + "---\ntitle: Broken move\nsection: Tutorials\nimage: &self [*self]\n---\nBROKEN_BODY\n", + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await generator.handleFileChange( + "changed", + "api-reference/users/listusers.mdx", + ); + + const page = await fs.readFile(pagePath, "utf8"); + expect(page).toContain("HANDWRITTEN_BODY"); + expect(page).not.toContain("Generated operation"); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "mdx", + slug: "api-reference/users/listusers", + }), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ + kind: "openapi", + slug: "api-reference/users/listusers", + }), + ); + }); + + it("keeps the active OpenAPI config and watcher target after an invalid replacement", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const writeSpec = (summary: string) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary, + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await writeSpec("Initial summary"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + await fs.writeJson(path.join(root, "doccupine.json"), { + watchDir: "docs", + outputDir: "site", + openapi: "missing.json", + }); + await generator.handleDoccupineConfigChange(); + + await writeSpec("Updated active summary"); + await generator.handleOpenApiChange(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + expect(await fs.readFile(pagePath, "utf8")).toContain( + "Updated active summary", + ); + }); + + it("rejects a symlinked doccupine.json during hot reload", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const externalConfig = path.join(root, "external-doccupine.json"); + await fs.writeJson(externalConfig, { + watchDir: "other-docs", + outputDir: "other-site", + }); + await fs.symlink(externalConfig, path.join(root, "doccupine.json")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await generator.handleDoccupineConfigChange(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("keeping the current configuration"), + expect.stringContaining("symbolic link"), + ); + }); + + it("rolls back a parsed OpenAPI config when regeneration fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + const oldSpecPath = path.join(root, "old-openapi.json"); + const candidateSpecPath = path.join(root, "candidate-openapi.json"); + const writeSpec = (specPath: string, resource: string, summary: string) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + [`/${resource}`]: { + get: { + operationId: `list${resource}`, + summary, + tags: [resource], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await writeSpec(oldSpecPath, "users", "Old users"); + await writeSpec(candidateSpecPath, "pets", "Candidate pets"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Old", file: oldSpecPath }], + root, + ); + await generator.init(); + const oldPagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + const candidatePagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "pets", + "listpets", + "page.tsx", + ); + const llmsPath = path.join(outputDir, "public", "llms.txt"); + const oldPage = await fs.readFile(oldPagePath, "utf8"); + const oldLlms = await fs.readFile(llmsPath, "utf8"); + + type GeneratorInternals = { + openApiSpecs: Array<{ name: string; file: string }>; + syncOpenApiSpecWatcher(): Promise; + }; + const internals = generator as unknown as GeneratorInternals; + const syncWatcher = internals.syncOpenApiSpecWatcher.bind(generator); + const watcherTargets: string[] = []; + vi.spyOn(internals, "syncOpenApiSpecWatcher").mockImplementation( + async () => { + watcherTargets.push( + internals.openApiSpecs.map((spec) => path.basename(spec.file)).join(), + ); + await syncWatcher(); + }, + ); + let candidatePageWasWritten = false; + vi.spyOn(generator, "updateLlmsFiles").mockImplementationOnce(async () => { + candidatePageWasWritten = await fs.pathExists(candidatePagePath); + throw new Error("Injected aggregate failure"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + await fs.writeJson(path.join(root, "doccupine.json"), { + watchDir: "docs", + outputDir: "site", + openapi: [{ name: "Candidate", file: "candidate-openapi.json" }], + }); + + await generator.handleDoccupineConfigChange(); + + expect(candidatePageWasWritten).toBe(true); + expect(internals.openApiSpecs).toEqual([ + { name: "Old", file: oldSpecPath }, + ]); + expect(watcherTargets).toEqual([]); + expect(await fs.pathExists(candidatePagePath)).toBe(false); + expect(await fs.readFile(oldPagePath, "utf8")).toBe(oldPage); + expect(await fs.readFile(llmsPath, "utf8")).toBe(oldLlms); + + await writeSpec(oldSpecPath, "users", "Updated old users"); + await generator.handleOpenApiChange(); + expect(await fs.readFile(oldPagePath, "utf8")).toContain( + "Updated old users", + ); + await generator.stop(); + }); + + it("replays a newly configured OpenAPI source changed before watcher readiness", async () => { + const { root, watchDir, outputDir } = await fixture(); + const oldSpecPath = path.join(root, "old-openapi.json"); + const candidateSpecPath = path.join(root, "candidate-openapi.json"); + const schemaPath = path.join(root, "schemas", "pet.json"); + const writeSchema = (property: string) => + fs.outputJson(schemaPath, { + type: "object", + properties: { [property]: { type: "string" } }, + }); + const writeSpec = ( + specPath: string, + resource: string, + schema: Record, + ) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + [`/${resource}`]: { + get: { + operationId: `list${resource}`, + tags: [resource], + responses: { + "200": { + description: "OK", + content: { "application/json": { schema } }, + }, + }, + }, + }, + }, + }); + await writeSpec(oldSpecPath, "users", { + type: "object", + properties: { OLD_USER: { type: "string" } }, + }); + await writeSpec(candidateSpecPath, "pets", { + type: "object", + properties: { INITIAL_PET: { type: "string" } }, + }); + await writeSchema("REFERENCED_PET"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Old", file: oldSpecPath }], + root, + ); + await generator.init(); + type GeneratorInternals = { + openApiSpecs: Array<{ name: string; file: string }>; + syncOpenApiSpecWatcher(): Promise; + }; + const internals = generator as unknown as GeneratorInternals; + const syncWatcher = internals.syncOpenApiSpecWatcher.bind(generator); + let changedDuringSync = false; + vi.spyOn(internals, "syncOpenApiSpecWatcher").mockImplementation( + async () => { + if ( + !changedDuringSync && + internals.openApiSpecs.some( + (spec) => path.resolve(root, spec.file) === candidateSpecPath, + ) + ) { + changedDuringSync = true; + await writeSpec(candidateSpecPath, "pets", { + $ref: "./schemas/pet.json", + }); + } + await syncWatcher(); + }, + ); + await fs.writeJson(path.join(root, "doccupine.json"), { + watchDir: "docs", + outputDir: "site", + openapi: [{ name: "Candidate", file: "candidate-openapi.json" }], + }); + + await generator.handleDoccupineConfigChange(); + + expect(changedDuringSync).toBe(true); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "pets", + "listpets", + "page.tsx", + ); + expect(await fs.readFile(pagePath, "utf8")).toContain("REFERENCED_PET"); + + await writeSchema("UPDATED_PET"); + await waitUntil(async () => + (await fs.readFile(pagePath, "utf8")).includes("UPDATED_PET"), + ); + await generator.stop(); + }); + + it("keeps the last successful OpenAPI page when one candidate page fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const writeSpec = (summary: string, includePets = false) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary, + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + ...(includePets + ? { + "/pets": { + get: { + operationId: "listPets", + summary: "Candidate pets", + tags: ["pets"], + responses: { "200": { description: "OK" } }, + }, + }, + } + : {}), + }, + }); + await writeSpec("Last good users"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + const previousPage = await fs.readFile(pagePath, "utf8"); + const petsPage = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "pets", + "listpets", + "page.tsx", + ); + const generatePage = generator.generatePageFromMDX.bind(generator); + vi.spyOn(generator, "generatePageFromMDX") + .mockImplementationOnce(generatePage) + .mockImplementationOnce(generatePage) + .mockImplementationOnce(async () => { + throw new Error("Injected endpoint render failure"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + await writeSpec("Candidate users", true); + + await generator.handleOpenApiChange(); + + expect(await fs.readFile(pagePath, "utf8")).toBe(previousPage); + expect(await fs.pathExists(petsPage)).toBe(false); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "openapi", + slug: "api-reference/users/listusers", + }), + ); + }); + + it("removes uncommitted OpenAPI candidates when allowlist writing fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const writeSpec = (resource: string, summary: string) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + [`/${resource}`]: { + get: { + operationId: `list${resource}`, + summary, + tags: [resource], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await writeSpec("users", "Last good users"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const usersPage = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + const petsPage = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "pets", + "listpets", + "page.tsx", + ); + const previousUsers = await fs.readFile(usersPage, "utf8"); + const previousAllowlist = await fs.readFile( + path.join(outputDir, "services", "openapi", "playground-allowlist.json"), + "utf8", + ); + type GeneratorInternals = { writeApiAllowlist(): Promise }; + vi.spyOn( + generator as unknown as GeneratorInternals, + "writeApiAllowlist", + ).mockRejectedValue(new Error("Injected allowlist failure")); + vi.spyOn(console, "error").mockImplementation(() => {}); + await writeSpec("pets", "Candidate pets"); + + await generator.handleOpenApiChange(); + + expect(await fs.pathExists(petsPage)).toBe(false); + expect(await fs.readFile(usersPage, "utf8")).toBe(previousUsers); + expect( + await fs.readFile( + path.join( + outputDir, + "services", + "openapi", + "playground-allowlist.json", + ), + "utf8", + ), + ).toBe(previousAllowlist); + const manifest = await fs.readJson( + path.join(outputDir, ".doccupine-artifacts.json"), + ); + expect(manifest.routes).toContainEqual( + expect.objectContaining({ + kind: "openapi", + slug: "api-reference/users/listusers", + }), + ); + expect(manifest.routes).not.toContainEqual( + expect.objectContaining({ slug: "api-reference/pets/listpets" }), + ); + }); + + it("rolls back direct OpenAPI watcher updates after aggregate failure", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const writeSpec = (resource: string) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + [`/${resource}`]: { + get: { + operationId: `list${resource}`, + summary: `${resource} summary`, + tags: [resource], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await writeSpec("users"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const usersPage = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + const petsPage = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "pets", + "listpets", + "page.tsx", + ); + const previousUsers = await fs.readFile(usersPage, "utf8"); + const previousLlms = await fs.readFile( + path.join(outputDir, "public", "llms.txt"), + "utf8", + ); + vi.spyOn(generator, "updateLlmsFiles").mockRejectedValueOnce( + new Error("Injected aggregate failure"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + await writeSpec("pets"); + + await generator.handleOpenApiChange(); + + expect(await fs.pathExists(petsPage)).toBe(false); + expect(await fs.readFile(usersPage, "utf8")).toBe(previousUsers); + expect( + await fs.readFile(path.join(outputDir, "public", "llms.txt"), "utf8"), + ).toBe(previousLlms); + }); + + it("rejects schema-invalid config reloads before changing generated output", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const writeSpec = (summary: string) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary, + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await writeSpec("Initial summary"); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + const llmsPath = path.join(outputDir, "public", "llms.txt"); + const initialPage = await fs.readFile(pagePath, "utf8"); + const initialLlms = await fs.readFile(llmsPath, "utf8"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const invalidConfigs: unknown[] = [ + {}, + { + watchDir: "docs", + outputDir: "site", + openapi: [{ name: "Missing file" }], + }, + { + watchDir: "docs", + outputDir: "docs/generated", + openapi: specPath, + }, + { watchDir: "", outputDir: "site", openapi: specPath }, + { + watchDir: "docs", + outputDir: "site", + port: "70000", + openapi: specPath, + }, + { + watchDir: "docs", + outputDir: "site", + packageManager: "yarn", + openapi: specPath, + }, + ]; + + for (const invalidConfig of invalidConfigs) { + await fs.writeJson(path.join(root, "doccupine.json"), invalidConfig); + await generator.handleDoccupineConfigChange(); + expect(await fs.readFile(pagePath, "utf8")).toBe(initialPage); + expect(await fs.readFile(llmsPath, "utf8")).toBe(initialLlms); + } + expect(warn).toHaveBeenCalledTimes(invalidConfigs.length); + + await writeSpec("Updated active summary"); + await generator.handleOpenApiChange(); + expect(await fs.readFile(pagePath, "utf8")).toContain( + "Updated active summary", + ); + }); + + it("keeps the restart hint for valid watch and output directory changes", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + await fs.writeJson(path.join(root, "doccupine.json"), { + watchDir: "other-docs", + outputDir: "other-site", + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await generator.handleDoccupineConfigChange(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining("watchDir/outputDir changes"), + ); + }); +}); diff --git a/src/mdx-to-nextjs-generator.source-safety.test.ts b/src/mdx-to-nextjs-generator.source-safety.test.ts new file mode 100644 index 0000000..c6db23a --- /dev/null +++ b/src/mdx-to-nextjs-generator.source-safety.test.ts @@ -0,0 +1,273 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { SecureSourceFs } from "./generator/secure-source-fs.js"; +import { MDXToNextJSGenerator } from "./mdx-to-nextjs-generator.js"; + +import { fixture } from "./test-utils/generator-fixture.js"; + +describe.sequential("MDXToNextJSGenerator source safety", () => { + it("does not seed starter docs over an existing MDX source", async () => { + const { watchDir, outputDir } = await fixture(); + const existingPath = path.join(watchDir, "components.mdx"); + await fs.writeFile(existingPath, "# Existing\n"); + + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + await generator.createStartingDocs(); + + expect(await fs.readFile(existingPath, "utf8")).toBe("# Existing\n"); + expect(await fs.pathExists(path.join(watchDir, "index.mdx"))).toBe(false); + }); + + it("rejects external MDX symlinks during scans and watch-style changes", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sensitivePath = path.join(root, "sensitive.mdx"); + const linkedPath = path.join(watchDir, "leaked.mdx"); + await fs.writeFile(sensitivePath, "SENSITIVE_SOURCE_CONTENT\n"); + await fs.symlink(sensitivePath, linkedPath, "file"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + + await expect(generator.getAllMDXFiles()).rejects.toThrow( + /documentation source.*leaked\.mdx.*symbolic link/i, + ); + await expect( + generator.handleFileChange("added", "leaked.mdx"), + ).rejects.toThrow(/documentation source.*leaked\.mdx.*symbolic link/i); + expect(await fs.pathExists(path.join(outputDir, "app"))).toBe(false); + }); + + it("rejects a symlinked config before mutating output or source files", async () => { + const { root } = await fixture(); + const projectRoot = path.join(root, "project"); + const watchDir = path.join(projectRoot, "docs"); + const outputDir = path.join(projectRoot, "site"); + const externalConfig = path.join(root, "external-config.json"); + const existingPage = path.join(outputDir, "app", "existing", "page.tsx"); + await fs.ensureDir(watchDir); + await fs.outputJson(path.join(outputDir, ".doccupine-generated.json"), { + generator: "doccupine", + schemaVersion: 1, + }); + await fs.outputFile(existingPage, "EXISTING_PAGE\n"); + await fs.writeJson(externalConfig, { + name: "EXTERNAL_SECRET_MARKER", + description: "LEAKED_DESCRIPTION", + }); + await fs.symlink(externalConfig, path.join(projectRoot, "config.json")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [], + projectRoot, + ); + + await expect(generator.init()).rejects.toThrow( + /config source.*config\.json.*symbolic link/i, + ); + + expect(await fs.readFile(existingPage, "utf8")).toBe("EXISTING_PAGE\n"); + expect(await fs.readdir(watchDir)).toEqual([]); + }); + + it("does not load sections through a symlinked source", async () => { + const { root } = await fixture(); + const projectRoot = path.join(root, "project"); + const watchDir = path.join(projectRoot, "docs"); + const externalSections = path.join(root, "external-sections.json"); + await fs.ensureDir(watchDir); + await fs.writeJson(externalSections, [ + { label: "External", slug: "external", directory: "external" }, + ]); + await fs.symlink(externalSections, path.join(projectRoot, "sections.json")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const generator = new MDXToNextJSGenerator( + watchDir, + path.join(projectRoot, "site"), + [], + projectRoot, + ); + + await expect(generator.loadSectionsConfig()).resolves.toBeNull(); + }); + + it("ignores non-MDX file symlinks during documentation scans", async () => { + const { root, watchDir, outputDir } = await fixture(); + const linkedTarget = path.join(root, "notes.txt"); + await fs.writeFile(linkedTarget, "not documentation\n"); + await fs.symlink(linkedTarget, path.join(watchDir, "notes.txt"), "file"); + await fs.writeFile(path.join(watchDir, "guide.mdx"), "# Guide\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + + await expect(generator.getAllMDXFiles()).resolves.toEqual(["guide.mdx"]); + }); + + it("does not write starter docs through a symlinked source directory", async () => { + const { root, watchDir, outputDir } = await fixture(); + const victimDir = path.join(root, "victim"); + await fs.ensureDir(victimDir); + await fs.writeFile(path.join(victimDir, "keep.txt"), "UNCHANGED\n"); + await fs.symlink(victimDir, path.join(watchDir, "platform"), "dir"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + + await expect(generator.createStartingDocs()).rejects.toThrow( + /documentation source.*platform.*symbolic link/i, + ); + expect(await fs.readdir(victimDir)).toEqual(["keep.txt"]); + expect(await fs.readFile(path.join(victimDir, "keep.txt"), "utf8")).toBe( + "UNCHANGED\n", + ); + expect(await fs.pathExists(path.join(watchDir, "index.mdx"))).toBe(false); + }); + + it("seeds starter docs through an unchanged symlinked source root", async () => { + const { root, outputDir } = await fixture(); + const realWatchDir = path.join(root, "real-docs"); + const linkedWatchDir = path.join(root, "linked-docs"); + await fs.ensureDir(realWatchDir); + await fs.symlink(realWatchDir, linkedWatchDir, "dir"); + const generator = new MDXToNextJSGenerator(linkedWatchDir, outputDir); + + await generator.createStartingDocs(); + + expect(await fs.pathExists(path.join(realWatchDir, "index.mdx"))).toBe( + true, + ); + }); + + it("pins the documentation root across every starter write", async () => { + const { root, watchDir } = await fixture(); + const originalDocs = path.join(root, "docs-original"); + const victimDir = path.join(root, "victim"); + await fs.ensureDir(victimDir); + const sourceFs = new SecureSourceFs(watchDir, root); + + async function* starterFiles() { + yield ["first.mdx", "FIRST\n"] as const; + await fs.rename(watchDir, originalDocs); + await fs.symlink(victimDir, watchDir, "dir"); + yield ["second.mdx", "SECOND\n"] as const; + } + + await expect( + sourceFs.writeStarterFilesIfEmpty(starterFiles()), + ).rejects.toThrow(/source root changed.*starter documents/i); + + expect( + await fs.readFile(path.join(originalDocs, "first.mdx"), "utf8"), + ).toBe("FIRST\n"); + expect(await fs.pathExists(path.join(victimDir, "second.mdx"))).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "does not overwrite an external starter file when the source root is swapped", + async () => { + const { root, watchDir, outputDir } = await fixture(); + const displacedDir = path.join(root, "docs-original"); + const victimDir = path.join(root, "victim"); + const victimIndex = path.join(victimDir, "index.mdx"); + const starterIndex = path.join(watchDir, "index.mdx"); + await fs.outputFile(victimIndex, "UNCHANGED\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + const lstat = fs.lstat.bind(fs); + let swapped = false; + vi.spyOn(fs, "lstat").mockImplementation(async (candidate: string) => { + try { + return await lstat(candidate); + } catch (error) { + if (!swapped && path.resolve(candidate) === starterIndex) { + await fs.rename(watchDir, displacedDir); + await fs.symlink(victimDir, watchDir, "dir"); + swapped = true; + } + throw error; + } + }); + + await expect(generator.createStartingDocs()).rejects.toThrow( + /starter path changed/i, + ); + + expect(swapped).toBe(true); + expect(await fs.readFile(victimIndex, "utf8")).toBe("UNCHANGED\n"); + }, + ); + + it.skipIf(process.platform === "win32")( + "removes an external starter file created during a source-root swap", + async () => { + const { root, watchDir, outputDir } = await fixture(); + const displacedDir = path.join(root, "docs-original"); + const victimDir = path.join(root, "victim"); + const victimIndex = path.join(victimDir, "index.mdx"); + const starterIndex = path.join(watchDir, "index.mdx"); + await fs.ensureDir(victimDir); + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + const lstat = fs.lstat.bind(fs); + let swapped = false; + vi.spyOn(fs, "lstat").mockImplementation(async (candidate: string) => { + try { + return await lstat(candidate); + } catch (error) { + if (!swapped && path.resolve(candidate) === starterIndex) { + await fs.rename(watchDir, displacedDir); + await fs.symlink(victimDir, watchDir, "dir"); + swapped = true; + } + throw error; + } + }); + + await expect(generator.createStartingDocs()).rejects.toThrow( + /starter path changed/i, + ); + + expect(swapped).toBe(true); + expect(await fs.pathExists(victimIndex)).toBe(false); + }, + ); + + it("restores required JSON modules when project config is deleted", async () => { + const { watchDir, outputDir } = await fixture(); + await Promise.all([ + fs.ensureDir(path.join(outputDir, "app")), + fs.ensureDir(path.join(outputDir, "services", "mcp")), + ]); + const generator = new MDXToNextJSGenerator(watchDir, outputDir); + + for (const [name, expected] of [ + ["config.json", "{}\n"], + ["theme.json", "{}\n"], + ["links.json", "[]\n"], + ["navigation.json", "[]\n"], + ] as const) { + await fs.writeFile(path.join(outputDir, name), '{"old":true}\n'); + await generator.handleConfigFileDelete(name); + expect(await fs.readFile(path.join(outputDir, name), "utf8")).toBe( + expected, + ); + } + }); + + it("atomically replaces a hard-linked config destination", async () => { + const { root, watchDir, outputDir } = await fixture(); + const sourcePath = path.join(root, "config.json"); + const destPath = path.join(outputDir, "config.json"); + const externalPeer = path.join(root, "external-config.json"); + await fs.writeFile(sourcePath, '{"name":"safe"}\n'); + await fs.writeFile(externalPeer, '{"name":"keep"}\n'); + await fs.ensureDir(outputDir); + await fs.link(externalPeer, destPath); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + + await generator.copyCustomConfigFiles(); + + await expect(fs.readFile(destPath, "utf8")).resolves.toBe( + '{"name":"safe"}\n', + ); + await expect(fs.readFile(externalPeer, "utf8")).resolves.toBe( + '{"name":"keep"}\n', + ); + }); +}); diff --git a/src/mdx-to-nextjs-generator.test.ts b/src/mdx-to-nextjs-generator.test.ts deleted file mode 100644 index 3c0e76d..0000000 --- a/src/mdx-to-nextjs-generator.test.ts +++ /dev/null @@ -1,3254 +0,0 @@ -import fs from "fs-extra"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { SecureSourceFs } from "./generator/secure-source-fs.js"; -import { MDXToNextJSGenerator } from "./mdx-to-nextjs-generator.js"; - -const temporaryDirectories: string[] = []; - -async function fixture(): Promise<{ - root: string; - watchDir: string; - outputDir: string; -}> { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "doccupine-generator-")); - temporaryDirectories.push(root); - const watchDir = path.join(root, "docs"); - const outputDir = path.join(root, "site"); - await fs.ensureDir(watchDir); - return { root, watchDir, outputDir }; -} - -async function waitUntil(check: () => Promise): Promise { - for (let attempt = 0; attempt < 100; attempt++) { - if (await check()) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error("Timed out waiting for watcher output"); -} - -afterEach(async () => { - vi.restoreAllMocks(); - await Promise.all( - temporaryDirectories.splice(0).map((dir) => fs.remove(dir)), - ); -}); - -describe.sequential("MDXToNextJSGenerator ownership", () => { - it("does not seed starter docs over an existing MDX source", async () => { - const { watchDir, outputDir } = await fixture(); - const existingPath = path.join(watchDir, "components.mdx"); - await fs.writeFile(existingPath, "# Existing\n"); - - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - await generator.createStartingDocs(); - - expect(await fs.readFile(existingPath, "utf8")).toBe("# Existing\n"); - expect(await fs.pathExists(path.join(watchDir, "index.mdx"))).toBe(false); - }); - - it("rejects external MDX symlinks during scans and watch-style changes", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sensitivePath = path.join(root, "sensitive.mdx"); - const linkedPath = path.join(watchDir, "leaked.mdx"); - await fs.writeFile(sensitivePath, "SENSITIVE_SOURCE_CONTENT\n"); - await fs.symlink(sensitivePath, linkedPath, "file"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - - await expect(generator.getAllMDXFiles()).rejects.toThrow( - /documentation source.*leaked\.mdx.*symbolic link/i, - ); - await expect( - generator.handleFileChange("added", "leaked.mdx"), - ).rejects.toThrow(/documentation source.*leaked\.mdx.*symbolic link/i); - expect(await fs.pathExists(path.join(outputDir, "app"))).toBe(false); - }); - - it("rejects a symlinked config before mutating output or source files", async () => { - const { root } = await fixture(); - const projectRoot = path.join(root, "project"); - const watchDir = path.join(projectRoot, "docs"); - const outputDir = path.join(projectRoot, "site"); - const externalConfig = path.join(root, "external-config.json"); - const existingPage = path.join(outputDir, "app", "existing", "page.tsx"); - await fs.ensureDir(watchDir); - await fs.outputJson(path.join(outputDir, ".doccupine-generated.json"), { - generator: "doccupine", - schemaVersion: 1, - }); - await fs.outputFile(existingPage, "EXISTING_PAGE\n"); - await fs.writeJson(externalConfig, { - name: "EXTERNAL_SECRET_MARKER", - description: "LEAKED_DESCRIPTION", - }); - await fs.symlink(externalConfig, path.join(projectRoot, "config.json")); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [], - projectRoot, - ); - - await expect(generator.init()).rejects.toThrow( - /config source.*config\.json.*symbolic link/i, - ); - - expect(await fs.readFile(existingPage, "utf8")).toBe("EXISTING_PAGE\n"); - expect(await fs.readdir(watchDir)).toEqual([]); - }); - - it("does not load sections through a symlinked source", async () => { - const { root } = await fixture(); - const projectRoot = path.join(root, "project"); - const watchDir = path.join(projectRoot, "docs"); - const externalSections = path.join(root, "external-sections.json"); - await fs.ensureDir(watchDir); - await fs.writeJson(externalSections, [ - { label: "External", slug: "external", directory: "external" }, - ]); - await fs.symlink(externalSections, path.join(projectRoot, "sections.json")); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const generator = new MDXToNextJSGenerator( - watchDir, - path.join(projectRoot, "site"), - [], - projectRoot, - ); - - await expect(generator.loadSectionsConfig()).resolves.toBeNull(); - }); - - it("ignores non-MDX file symlinks during documentation scans", async () => { - const { root, watchDir, outputDir } = await fixture(); - const linkedTarget = path.join(root, "notes.txt"); - await fs.writeFile(linkedTarget, "not documentation\n"); - await fs.symlink(linkedTarget, path.join(watchDir, "notes.txt"), "file"); - await fs.writeFile(path.join(watchDir, "guide.mdx"), "# Guide\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - - await expect(generator.getAllMDXFiles()).resolves.toEqual(["guide.mdx"]); - }); - - it("does not write starter docs through a symlinked source directory", async () => { - const { root, watchDir, outputDir } = await fixture(); - const victimDir = path.join(root, "victim"); - await fs.ensureDir(victimDir); - await fs.writeFile(path.join(victimDir, "keep.txt"), "UNCHANGED\n"); - await fs.symlink(victimDir, path.join(watchDir, "platform"), "dir"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - - await expect(generator.createStartingDocs()).rejects.toThrow( - /documentation source.*platform.*symbolic link/i, - ); - expect(await fs.readdir(victimDir)).toEqual(["keep.txt"]); - expect(await fs.readFile(path.join(victimDir, "keep.txt"), "utf8")).toBe( - "UNCHANGED\n", - ); - expect(await fs.pathExists(path.join(watchDir, "index.mdx"))).toBe(false); - }); - - it("seeds starter docs through an unchanged symlinked source root", async () => { - const { root, outputDir } = await fixture(); - const realWatchDir = path.join(root, "real-docs"); - const linkedWatchDir = path.join(root, "linked-docs"); - await fs.ensureDir(realWatchDir); - await fs.symlink(realWatchDir, linkedWatchDir, "dir"); - const generator = new MDXToNextJSGenerator(linkedWatchDir, outputDir); - - await generator.createStartingDocs(); - - expect(await fs.pathExists(path.join(realWatchDir, "index.mdx"))).toBe( - true, - ); - }); - - it("pins the documentation root across every starter write", async () => { - const { root, watchDir } = await fixture(); - const originalDocs = path.join(root, "docs-original"); - const victimDir = path.join(root, "victim"); - await fs.ensureDir(victimDir); - const sourceFs = new SecureSourceFs(watchDir, root); - - async function* starterFiles() { - yield ["first.mdx", "FIRST\n"] as const; - await fs.rename(watchDir, originalDocs); - await fs.symlink(victimDir, watchDir, "dir"); - yield ["second.mdx", "SECOND\n"] as const; - } - - await expect( - sourceFs.writeStarterFilesIfEmpty(starterFiles()), - ).rejects.toThrow(/source root changed.*starter documents/i); - - expect( - await fs.readFile(path.join(originalDocs, "first.mdx"), "utf8"), - ).toBe("FIRST\n"); - expect(await fs.pathExists(path.join(victimDir, "second.mdx"))).toBe(false); - }); - - it.skipIf(process.platform === "win32")( - "does not overwrite an external starter file when the source root is swapped", - async () => { - const { root, watchDir, outputDir } = await fixture(); - const displacedDir = path.join(root, "docs-original"); - const victimDir = path.join(root, "victim"); - const victimIndex = path.join(victimDir, "index.mdx"); - const starterIndex = path.join(watchDir, "index.mdx"); - await fs.outputFile(victimIndex, "UNCHANGED\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - const lstat = fs.lstat.bind(fs); - let swapped = false; - vi.spyOn(fs, "lstat").mockImplementation(async (candidate: string) => { - try { - return await lstat(candidate); - } catch (error) { - if (!swapped && path.resolve(candidate) === starterIndex) { - await fs.rename(watchDir, displacedDir); - await fs.symlink(victimDir, watchDir, "dir"); - swapped = true; - } - throw error; - } - }); - - await expect(generator.createStartingDocs()).rejects.toThrow( - /starter path changed/i, - ); - - expect(swapped).toBe(true); - expect(await fs.readFile(victimIndex, "utf8")).toBe("UNCHANGED\n"); - }, - ); - - it.skipIf(process.platform === "win32")( - "removes an external starter file created during a source-root swap", - async () => { - const { root, watchDir, outputDir } = await fixture(); - const displacedDir = path.join(root, "docs-original"); - const victimDir = path.join(root, "victim"); - const victimIndex = path.join(victimDir, "index.mdx"); - const starterIndex = path.join(watchDir, "index.mdx"); - await fs.ensureDir(victimDir); - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - const lstat = fs.lstat.bind(fs); - let swapped = false; - vi.spyOn(fs, "lstat").mockImplementation(async (candidate: string) => { - try { - return await lstat(candidate); - } catch (error) { - if (!swapped && path.resolve(candidate) === starterIndex) { - await fs.rename(watchDir, displacedDir); - await fs.symlink(victimDir, watchDir, "dir"); - swapped = true; - } - throw error; - } - }); - - await expect(generator.createStartingDocs()).rejects.toThrow( - /starter path changed/i, - ); - - expect(swapped).toBe(true); - expect(await fs.pathExists(victimIndex)).toBe(false); - }, - ); - - it("restores required JSON modules when project config is deleted", async () => { - const { watchDir, outputDir } = await fixture(); - await Promise.all([ - fs.ensureDir(path.join(outputDir, "app")), - fs.ensureDir(path.join(outputDir, "services", "mcp")), - ]); - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - - for (const [name, expected] of [ - ["config.json", "{}\n"], - ["theme.json", "{}\n"], - ["links.json", "[]\n"], - ["navigation.json", "[]\n"], - ] as const) { - await fs.writeFile(path.join(outputDir, name), '{"old":true}\n'); - await generator.handleConfigFileDelete(name); - expect(await fs.readFile(path.join(outputDir, name), "utf8")).toBe( - expected, - ); - } - }); - - it("atomically replaces a hard-linked config destination", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(root, "config.json"); - const destPath = path.join(outputDir, "config.json"); - const externalPeer = path.join(root, "external-config.json"); - await fs.writeFile(sourcePath, '{"name":"safe"}\n'); - await fs.writeFile(externalPeer, '{"name":"keep"}\n'); - await fs.ensureDir(outputDir); - await fs.link(externalPeer, destPath); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - - await generator.copyCustomConfigFiles(); - - await expect(fs.readFile(destPath, "utf8")).resolves.toBe( - '{"name":"safe"}\n', - ); - await expect(fs.readFile(externalPeer, "utf8")).resolves.toBe( - '{"name":"keep"}\n', - ); - }); - - it("restores sections.json and rebuilds routes after deletion", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "guides", "intro.mdx"), - "---\ntitle: Intro\n---\nIntro\n", - ); - const sectionsPath = path.join(root, "sections.json"); - await fs.writeJson(sectionsPath, [ - { label: "Guides", slug: "guides", directory: "guides" }, - ]); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - - await fs.remove(sectionsPath); - await generator.handleConfigFileDelete(sectionsPath); - - expect( - await fs.readFile(path.join(outputDir, "sections.json"), "utf8"), - ).toBe("[]\n"); - expect( - await fs.pathExists( - path.join(outputDir, "app", "(site)", "guides", "intro", "page.tsx"), - ), - ).toBe(true); - }); - - it("preserves a real section page containing the redirect function name", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "guides", "intro.mdx"), - "---\ntitle: Intro\n---\nIntro\n", - ); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Guides", slug: "guides", directory: "guides" }, - ]); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.outputFile( - path.join(watchDir, "guides", "index.mdx"), - "---\ntitle: Guides\n---\n```tsx\nfunction SectionIndex() {}\n```\n", - ); - - await generator.handleFileChange("added", path.join("guides", "index.mdx")); - - const page = await fs.readFile( - path.join(outputDir, "app", "(site)", "guides", "page.tsx"), - "utf8", - ); - expect(page).toContain("function SectionIndex() {}"); - }); - - it("does not preserve a section redirect for a page that failed to render", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "guides", "intro.mdx"), - "---\ntitle: Intro\n---\nIntro\n", - ); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Guides", slug: "guides", directory: "guides" }, - ]); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.outputFile( - path.join(watchDir, "guides", "index.mdx"), - "---\ntitle: Guides\nimage: &self [*self]\n---\nGuides\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("added", "guides/index.mdx"); - - expect( - await fs.pathExists( - path.join(outputDir, "app", "(site)", "guides", "page.tsx"), - ), - ).toBe(false); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ kind: "mdx", source: "guides/index.mdx" }), - ); - - await fs.writeFile( - path.join(watchDir, "guides", "intro.mdx"), - "---\ntitle: Updated Intro\n---\nUpdated intro\n", - ); - await generator.handleFileChange("changed", "guides/intro.mdx"); - - expect( - await fs.pathExists( - path.join(outputDir, "app", "(site)", "guides", "page.tsx"), - ), - ).toBe(false); - const updatedManifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(updatedManifest.routes).not.toContainEqual( - expect.objectContaining({ kind: "mdx", source: "guides/index.mdx" }), - ); - }); - - it("rolls back section redirects when a later redirect fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.writeFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.writeFile( - path.join(watchDir, "alpha.mdx"), - "---\ntitle: Alpha\nsection: Alpha\n---\nAlpha\n", - ); - await fs.writeFile( - path.join(watchDir, "beta.mdx"), - "---\ntitle: Beta\nsection: Beta\n---\nBeta\n", - ); - type GeneratorInternals = { - writeSectionIndexRedirect(slug: string, target: string): Promise; - generatedRouteManager: { sectionIndexSlugs(): Set }; - }; - const internals = generator as unknown as GeneratorInternals; - const writeRedirect = internals.writeSectionIndexRedirect.bind(generator); - let writes = 0; - vi.spyOn(internals, "writeSectionIndexRedirect").mockImplementation( - async (slug, target) => { - writes += 1; - if (writes === 2) throw new Error("Injected redirect failure"); - await writeRedirect(slug, target); - }, - ); - - await expect(generator.processAllMDXFiles()).rejects.toThrow( - "Injected redirect failure", - ); - - expect( - await fs.pathExists( - path.join(outputDir, "app", "(site)", "alpha", "page.tsx"), - ), - ).toBe(false); - expect( - await fs.pathExists( - path.join(outputDir, "app", "(site)", "beta", "page.tsx"), - ), - ).toBe(false); - expect(internals.generatedRouteManager.sectionIndexSlugs()).toEqual( - new Set(), - ); - }); - - it("restores section redirects when stale cleanup fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.writeFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const alphaSource = path.join(watchDir, "alpha.mdx"); - const betaSource = path.join(watchDir, "beta.mdx"); - await fs.writeFile( - alphaSource, - "---\ntitle: Alpha\nsection: Alpha\n---\nAlpha\n", - ); - await fs.writeFile( - betaSource, - "---\ntitle: Beta\nsection: Beta\n---\nBeta\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const alphaRedirect = path.join( - outputDir, - "app", - "(site)", - "alpha", - "page.tsx", - ); - const betaRedirect = path.join( - outputDir, - "app", - "(site)", - "beta", - "page.tsx", - ); - const previousAlpha = await fs.readFile(alphaRedirect, "utf8"); - const previousBeta = await fs.readFile(betaRedirect, "utf8"); - await Promise.all([fs.remove(alphaSource), fs.remove(betaSource)]); - type RouteManagerInternals = { - removeSectionIndexPage( - slug: string, - removeEmptyDirs: (dir: string, stopDir: string) => Promise, - ): Promise; - }; - type GeneratorInternals = { - generatedRouteManager: RouteManagerInternals; - }; - const routeManager = (generator as unknown as GeneratorInternals) - .generatedRouteManager; - const removeSectionIndexPage = - routeManager.removeSectionIndexPage.bind(routeManager); - let cleanupCalls = 0; - vi.spyOn(routeManager, "removeSectionIndexPage").mockImplementation( - async (slug, removeEmptyDirs) => { - cleanupCalls += 1; - if (cleanupCalls === 2) { - throw new Error("Injected stale redirect cleanup failure"); - } - await removeSectionIndexPage(slug, removeEmptyDirs); - }, - ); - - await expect(generator.processAllMDXFiles()).rejects.toThrow( - "Unable to remove stale section index redirects", - ); - - expect(cleanupCalls).toBeGreaterThanOrEqual(2); - expect(await fs.readFile(alphaRedirect, "utf8")).toBe(previousAlpha); - expect(await fs.readFile(betaRedirect, "utf8")).toBe(previousBeta); - }); - - it("retains the last successful page when bulk regeneration fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nOriginal\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const originalPage = await fs.readFile(pagePath, "utf8"); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nimage: &self [*self]\n---\nBroken\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.processAllMDXFiles(); - - expect(await fs.readFile(pagePath, "utf8")).toBe(originalPage); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "guide", - }), - ); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ - uri: "docs://guide", - content: "Original\n", - }), - ); - expect( - await fs.readFile(path.join(outputDir, "app", "sitemap.ts"), "utf8"), - ).toContain('slug: "guide"'); - const llmsFull = await fs.readFile( - path.join(outputDir, "public", "llms-full.txt"), - "utf8", - ); - expect(llmsFull).toContain("Original"); - expect(llmsFull).not.toContain("Broken"); - }); - - it("keeps the previous route when a moved replacement fails to render", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const previousPage = path.join( - outputDir, - "app", - "(site)", - "guides", - "guide", - "page.tsx", - ); - const nextPage = path.join( - outputDir, - "app", - "(site)", - "tutorials", - "guide", - "page.tsx", - ); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Guides\n---\nOriginal\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const originalPage = await fs.readFile(previousPage, "utf8"); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Tutorials\nimage: &self [*self]\n---\nBroken\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(await fs.readFile(previousPage, "utf8")).toBe(originalPage); - expect(await fs.pathExists(nextPage)).toBe(false); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "guides/guide", - }), - ); - }); - - it("restores inferred sections when a changed page fails to render", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Guides\n---\nOLD_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "guides", - "guide", - "page.tsx", - ); - const previousPage = await fs.readFile(pagePath, "utf8"); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Tutorials\nimage: &self [*self]\n---\nBROKEN_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toBe(previousPage); - const layout = await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ); - expect(layout).toContain('label: "Guides"'); - expect(layout).not.toContain('label: "Tutorials"'); - }); - - it("keeps stale routes owned when replacement ownership cannot persist", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const previousPage = path.join( - outputDir, - "app", - "(site)", - "guides", - "guide", - "page.tsx", - ); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Guides", slug: "guides" }, - { label: "Tutorials", slug: "tutorials" }, - ]); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Guides\n---\nOriginal\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - type GeneratorInternals = { - artifacts: { - replaceRoutesAndSave( - kind: string, - routes: Iterable<{ source: string; slug: string }>, - ): Promise; - }; - }; - const artifacts = (generator as unknown as GeneratorInternals).artifacts; - vi.spyOn(artifacts, "replaceRoutesAndSave").mockRejectedValueOnce( - new Error("Injected manifest failure"), - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", - ); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(await fs.pathExists(previousPage)).toBe(true); - expect( - await fs.pathExists( - path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), - ), - ).toBe(false); - let manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "guides/guide", - }), - ); - - await generator.handleFileChange("changed", "guide.mdx"); - expect(await fs.pathExists(previousPage)).toBe(false); - manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "tutorials/guide", - }), - ); - }); - - it("retries stale route cleanup after a transient removal failure", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const previousPage = path.join( - outputDir, - "app", - "(site)", - "guides", - "guide", - "page.tsx", - ); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Guides", slug: "guides" }, - { label: "Tutorials", slug: "tutorials" }, - ]); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Guides\n---\nOriginal\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - type GeneratorInternals = { - generatedRouteManager: { removeOwnedRoute(slug: string): Promise }; - }; - const routeManager = (generator as unknown as GeneratorInternals) - .generatedRouteManager; - vi.spyOn(routeManager, "removeOwnedRoute").mockRejectedValueOnce( - new Error("Injected cleanup failure"), - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", - ); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(await fs.pathExists(previousPage)).toBe(true); - let manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "tutorials/guide", - }), - ); - - await generator.handleFileChange("changed", "guide.mdx"); - expect(await fs.pathExists(previousPage)).toBe(false); - manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ slug: "guides/guide" }), - ); - }); - - it("restores the previous page when its RSS route cannot be updated", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const pageDir = path.join(outputDir, "app", "(site)", "guide"); - const pagePath = path.join(pageDir, "page.tsx"); - await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nOriginal\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const originalPage = await fs.readFile(pagePath, "utf8"); - const externalRss = path.join(root, "external-rss"); - await fs.ensureDir(externalRss); - await fs.writeFile(path.join(externalRss, "route.ts"), "KEEP\n"); - await fs.symlink( - externalRss, - path.join(pageDir, "rss.xml"), - process.platform === "win32" ? "junction" : "dir", - ); - await fs.writeFile( - sourcePath, - '---\ntitle: Guide\n---\nChanged\nFeed\n', - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toBe(originalPage); - expect(await fs.readFile(path.join(externalRss, "route.ts"), "utf8")).toBe( - "KEEP\n", - ); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ uri: "docs://guide", content: "Original\n" }), - ); - }); - - it("removes stale output when a same-route source handoff fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile( - path.join(watchDir, "guide.mdx"), - "---\ntitle: Guide\n---\nOriginal\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - - await fs.remove(path.join(watchDir, "guide.mdx")); - await fs.outputFile( - path.join(watchDir, "guide", "index.mdx"), - "---\ntitle: Broken\nimage: &self [*self]\n---\nBroken\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await generator.processAllMDXFiles(); - - expect(await fs.pathExists(pagePath)).toBe(false); - let manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ kind: "mdx", slug: "guide" }), - ); - - await fs.remove(path.join(watchDir, "guide", "index.mdx")); - await generator.handleFileDelete("guide/index.mdx"); - expect(await fs.pathExists(pagePath)).toBe(false); - manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ kind: "mdx", slug: "guide" }), - ); - }); - - it("does not retain historical ownership after startup clears page output", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nOriginal\n"); - await new MDXToNextJSGenerator(watchDir, outputDir, [], root).init(); - expect(await fs.pathExists(pagePath)).toBe(true); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nimage: &self [*self]\n---\nBroken\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await new MDXToNextJSGenerator(watchDir, outputDir, [], root).init(); - - expect(await fs.pathExists(pagePath)).toBe(false); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ kind: "mdx", source: "guide.mdx" }), - ); - }); - - it("retains the last successful homepage and aggregate content", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "index.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "page.tsx"); - await fs.writeFile(sourcePath, "---\ntitle: Home\n---\nORIGINAL_HOME\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const originalPage = await fs.readFile(pagePath, "utf8"); - await fs.writeFile( - sourcePath, - "---\ntitle: Broken Home\nimage: &self [*self]\n---\nBROKEN_HOME\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("changed", "index.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toBe(originalPage); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ - uri: "docs:///", - content: "ORIGINAL_HOME\n", - }), - ); - expect(JSON.stringify(docsContent)).not.toContain("BROKEN_HOME"); - }); - - it("rolls back MDX pages, ownership, sections, and aggregates together", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Guides\n---\nOLD_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const oldPagePath = path.join( - outputDir, - "app", - "(site)", - "guides", - "guide", - "page.tsx", - ); - const newPagePath = path.join( - outputDir, - "app", - "(site)", - "tutorials", - "guide", - "page.tsx", - ); - const previousPage = await fs.readFile(oldPagePath, "utf8"); - const previousLayout = await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ); - const previousSitemap = await fs.readFile( - path.join(outputDir, "app", "sitemap.ts"), - "utf8", - ); - const previousDocs = await fs.readFile( - path.join(outputDir, "services", "mcp", "docs-content.json"), - "utf8", - ); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Tutorials\n---\nNEW_BODY\n", - ); - vi.spyOn(generator, "updateLlmsFiles").mockRejectedValueOnce( - new Error("Injected aggregate failure"), - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(await fs.readFile(oldPagePath, "utf8")).toBe(previousPage); - expect(await fs.pathExists(newPagePath)).toBe(false); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ), - ).toBe(previousLayout); - expect( - await fs.readFile(path.join(outputDir, "app", "sitemap.ts"), "utf8"), - ).toBe(previousSitemap); - expect( - await fs.readFile( - path.join(outputDir, "services", "mcp", "docs-content.json"), - "utf8", - ), - ).toBe(previousDocs); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ source: "guide.mdx", slug: "guides/guide" }), - ); - }); - - it("keeps a rolled-back deletion in later aggregate refreshes", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "index.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "page.tsx"); - await fs.writeFile( - sourcePath, - "---\ntitle: Retained Home\n---\nRETAINED_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const previousPage = await fs.readFile(pagePath, "utf8"); - const sitemapPath = path.join(outputDir, "app", "sitemap.ts"); - const previousSitemap = await fs.readFile(sitemapPath, "utf8"); - await fs.remove(sourcePath); - vi.spyOn(generator, "updateLlmsFiles").mockRejectedValueOnce( - new Error("Injected aggregate failure"), - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileDelete("index.mdx"); - type GeneratorInternals = { - refreshSiteAggregates(): Promise; - }; - await (generator as unknown as GeneratorInternals).refreshSiteAggregates(); - - expect(await fs.readFile(pagePath, "utf8")).toBe(previousPage); - expect(await fs.readFile(sitemapPath, "utf8")).toBe(previousSitemap); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ), - ).toContain("Retained Home"); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ - uri: "docs:///", - content: "RETAINED_BODY\n", - }), - ); - }); - - it("deletes the recorded frontmatter route and preserves nested pages", async () => { - const { watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "guide.mdx"), - "---\ntitle: Guide\nsection: Guides\n---\nParent\n", - ); - await fs.outputFile( - path.join(watchDir, "guides", "child.mdx"), - "---\ntitle: Child\nsection: Guides\n---\nChild\n", - ); - - const generator = new MDXToNextJSGenerator(watchDir, outputDir); - await generator.init(); - const parentPage = path.join( - outputDir, - "app", - "(site)", - "guides", - "guide", - "page.tsx", - ); - const childPage = path.join( - outputDir, - "app", - "(site)", - "guides", - "child", - "page.tsx", - ); - expect(await fs.pathExists(parentPage)).toBe(true); - expect(await fs.pathExists(childPage)).toBe(true); - - await fs.writeFile( - path.join(watchDir, "guide.mdx"), - "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", - ); - await generator.handleFileChange("changed", "guide.mdx"); - const movedPage = path.join( - outputDir, - "app", - "(site)", - "tutorials", - "guide", - "page.tsx", - ); - expect(await fs.pathExists(parentPage)).toBe(false); - expect(await fs.pathExists(childPage)).toBe(true); - expect(await fs.pathExists(movedPage)).toBe(true); - - await fs.remove(path.join(watchDir, "guide.mdx")); - await generator.handleFileDelete("guide.mdx"); - - expect(await fs.pathExists(movedPage)).toBe(false); - expect(await fs.pathExists(childPage)).toBe(true); - }); - - it("generates the surviving source when deleting a colliding route owner", async () => { - const { root, watchDir, outputDir } = await fixture(); - const originalSource = path.join(watchDir, "guide.mdx"); - const survivingSource = path.join(watchDir, "guide", "index.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile( - originalSource, - "---\ntitle: Original\n---\nORIGINAL_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.outputFile( - survivingSource, - "---\ntitle: Survivor\n---\nSURVIVOR_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("added", "guide/index.mdx"); - await fs.remove(originalSource); - await generator.handleFileDelete("guide.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toContain("SURVIVOR_BODY"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide/index.mdx", - slug: "guide", - }), - ); - }); - - it("does not commit inferred sections from a colliding pass", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const collidingPath = path.join(watchDir, "tutorials", "guide.mdx"); - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Guides\n---\nGUIDE_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - type GeneratorInternals = { - sectionsConfig: Array<{ label: string; slug: string }> | null; - }; - const internals = generator as unknown as GeneratorInternals; - await fs.writeFile( - sourcePath, - "---\ntitle: Guide\nsection: Tutorials\n---\nMOVED_BODY\n", - ); - await fs.outputFile( - collidingPath, - "---\ntitle: Collision\nsection: Tutorials\n---\nCOLLISION_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(internals.sectionsConfig).toEqual([ - { label: "Guides", slug: "guides" }, - ]); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ), - ).toContain('label: "Guides"'); - - await fs.remove(collidingPath); - await generator.handleFileDelete("tutorials/guide.mdx"); - expect(internals.sectionsConfig).toEqual([ - { label: "Tutorials", slug: "tutorials" }, - ]); - }); - - it("generates a blocked collision source when its owner moves routes", async () => { - const { root, watchDir, outputDir } = await fixture(); - const movingSource = path.join(watchDir, "guide.mdx"); - const blockedSource = path.join(watchDir, "guide", "index.mdx"); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Docs", slug: "" }, - { label: "Tutorials", slug: "tutorials" }, - ]); - await fs.writeFile( - movingSource, - "---\ntitle: Original\n---\nORIGINAL_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.outputFile( - blockedSource, - "---\ntitle: Replacement\n---\nREPLACEMENT_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await generator.handleFileChange("added", "guide/index.mdx"); - await fs.writeFile( - movingSource, - "---\ntitle: Moved\nsection: Tutorials\n---\nMOVED_BODY\n", - ); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "guide", "page.tsx"), - "utf8", - ), - ).toContain("REPLACEMENT_BODY"); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), - "utf8", - ), - ).toContain("MOVED_BODY"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "mdx", - source: "guide/index.mdx", - slug: "guide", - }), - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "tutorials/guide", - }), - ]), - ); - }); - - it("replays unrelated MDX changes after a route collision clears", async () => { - const { root, watchDir, outputDir } = await fixture(); - const movingSource = path.join(watchDir, "guide.mdx"); - const collidingSource = path.join(watchDir, "guide", "index.mdx"); - const unrelatedSource = path.join(watchDir, "other.mdx"); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Docs", slug: "" }, - { label: "Tutorials", slug: "tutorials" }, - ]); - await fs.writeFile(movingSource, "---\ntitle: Guide\n---\nGuide\n"); - await fs.writeFile(unrelatedSource, "---\ntitle: Other\n---\nOLD_OTHER\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.outputFile( - collidingSource, - "---\ntitle: Replacement\n---\nReplacement\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await generator.handleFileChange("added", "guide/index.mdx"); - await fs.writeFile(unrelatedSource, "---\ntitle: Other\n---\nNEW_OTHER\n"); - await generator.handleFileChange("changed", "other.mdx"); - await fs.writeFile( - movingSource, - "---\ntitle: Guide\nsection: Tutorials\n---\nMoved\n", - ); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "other", "page.tsx"), - "utf8", - ), - ).toContain("NEW_OTHER"); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ uri: "docs://other", content: "NEW_OTHER\n" }), - ); - }); - - it("retries changed content after deleting its colliding source", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const collidingPath = path.join(watchDir, "guide", "index.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nORIGINAL_BODY\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.outputFile( - collidingPath, - "---\ntitle: Collision\n---\nCOLLIDING_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await generator.handleFileChange("added", "guide/index.mdx"); - await fs.writeFile( - sourcePath, - "---\ntitle: Updated Guide\n---\nUPDATED_BODY\n", - ); - await generator.handleFileChange("changed", "guide.mdx"); - - await fs.remove(collidingPath); - await generator.handleFileDelete("guide/index.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toContain("UPDATED_BODY"); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ - uri: "docs://guide", - content: "UPDATED_BODY\n", - }), - ); - }); - - it("recovers changed content after a bulk collision clears", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - const collidingPath = path.join(watchDir, "guide", "index.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile(sourcePath, "---\ntitle: Guide\n---\nORIGINAL_BODY\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.writeFile( - sourcePath, - "---\ntitle: Updated Guide\n---\nUPDATED_BODY\n", - ); - await fs.outputFile( - collidingPath, - "---\ntitle: Collision\n---\nCOLLIDING_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await expect(generator.processAllMDXFiles()).rejects.toThrow( - /Route collision/, - ); - await fs.remove(collidingPath); - await generator.handleFileDelete("guide/index.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toContain("UPDATED_BODY"); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect(docsContent).toContainEqual( - expect.objectContaining({ - uri: "docs://guide", - content: "UPDATED_BODY\n", - }), - ); - }); - - it("lets a successful source replace a conflicting retained snapshot", async () => { - const { root, watchDir, outputDir } = await fixture(); - const originalSource = path.join(watchDir, "guide.mdx"); - const replacementSource = path.join(watchDir, "guide", "index.mdx"); - const pagePath = path.join(outputDir, "app", "(site)", "guide", "page.tsx"); - await fs.writeFile( - originalSource, - "---\ntitle: Original\n---\nORIGINAL_BODY\n", - ); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Docs", slug: "" }, - { label: "Tutorials", slug: "tutorials" }, - ]); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.writeFile( - originalSource, - "---\ntitle: Broken move\nsection: Tutorials\nimage: &self [*self]\n---\nBROKEN_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await generator.handleFileChange("changed", "guide.mdx"); - await fs.outputFile( - replacementSource, - "---\ntitle: Replacement\n---\nREPLACEMENT_BODY\n", - ); - - await generator.handleFileChange("added", "guide/index.mdx"); - - expect(await fs.readFile(pagePath, "utf8")).toContain("REPLACEMENT_BODY"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect( - manifest.routes.filter( - (route: { kind: string; slug: string }) => - route.kind === "mdx" && route.slug === "guide", - ), - ).toEqual([ - expect.objectContaining({ source: "guide/index.mdx", slug: "guide" }), - ]); - const docsContent = await fs.readJson( - path.join(outputDir, "services", "mcp", "docs-content.json"), - ); - expect( - docsContent.filter( - (document: { uri: string }) => document.uri === "docs://guide", - ), - ).toEqual([expect.objectContaining({ content: "REPLACEMENT_BODY\n" })]); - }); - - it("retries a surviving collision source whose cached route is stale", async () => { - const { root, watchDir, outputDir } = await fixture(); - const movingSource = path.join(watchDir, "guide.mdx"); - const blockingSource = path.join(watchDir, "tutorials", "guide.mdx"); - const previousPage = path.join( - outputDir, - "app", - "(site)", - "guide", - "page.tsx", - ); - const movedPage = path.join( - outputDir, - "app", - "(site)", - "tutorials", - "guide", - "page.tsx", - ); - await fs.writeFile(movingSource, "---\ntitle: Moving\n---\nMOVING_BODY\n"); - await fs.outputFile( - blockingSource, - "---\ntitle: Blocking\nsection: Tutorials\n---\nBLOCKING_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.writeFile( - movingSource, - "---\ntitle: Moving\nsection: Tutorials\n---\nMOVED_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await generator.handleFileChange("changed", "guide.mdx"); - - await fs.remove(blockingSource); - await generator.handleFileDelete("tutorials/guide.mdx"); - - expect(await fs.pathExists(previousPage)).toBe(false); - expect(await fs.readFile(movedPage, "utf8")).toContain("MOVED_BODY"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - source: "guide.mdx", - slug: "tutorials/guide", - }), - ); - }); - - it("never overwrites or cleans up a hand-written OpenAPI route", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const operation = { - operationId: "listUsers", - summary: "Generated operation", - tags: ["users"], - responses: { "200": { description: "OK" } }, - }; - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { "/users": { get: operation } }, - }); - await fs.outputFile( - path.join(watchDir, "api-reference", "users", "listusers.mdx"), - "---\ntitle: Hand Written\n---\nHAND_WRITTEN_SENTINEL\n", - ); - - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - expect(await fs.readFile(pagePath, "utf8")).toContain( - "HAND_WRITTEN_SENTINEL", - ); - - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: {}, - }); - await generator.handleOpenApiChange(); - - expect(await fs.readFile(pagePath, "utf8")).toContain( - "HAND_WRITTEN_SENTINEL", - ); - }); - - it("includes the API Reference section in the initial generated layout", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - - await new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ).init(); - - const layout = await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ); - expect(layout).toContain("doccupineSections"); - expect(layout).toContain('label: "API Reference"'); - expect(layout).toContain('slug: "api-reference"'); - }); - - it("hands a removed MDX route back to OpenAPI across a restart", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary: "Generated operation", - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const handwrittenSource = path.join( - watchDir, - "api-reference", - "users", - "listusers.mdx", - ); - await fs.outputFile( - handwrittenSource, - "---\ntitle: Hand Written\n---\nHAND_WRITTEN_SENTINEL\n", - ); - const specs = [{ name: "Test", file: specPath }]; - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - - await new MDXToNextJSGenerator(watchDir, outputDir, specs, root).init(); - await fs.remove(handwrittenSource); - await new MDXToNextJSGenerator(watchDir, outputDir, specs, root).init(); - - expect(await fs.readFile(pagePath, "utf8")).toContain( - "Generated operation", - ); - }); - - it("clears persisted OpenAPI ownership after restarting without specs", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary: "Generated operation", - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - await new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ).init(); - let manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ kind: "openapi" }), - ); - - await new MDXToNextJSGenerator(watchDir, outputDir, [], root).init(); - - manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ kind: "openapi" }), - ); - }); - - it("does not publish OpenAPI metadata for a route blocked by broken MDX", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary: "Generated operation", - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - await fs.outputFile( - path.join(watchDir, "api-reference", "users", "listusers.mdx"), - "---\ntitle: Broken\nimage: &self [*self]\n---\nBroken\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - - await generator.init(); - - expect( - await fs.pathExists( - path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ), - ), - ).toBe(false); - const layout = await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ); - expect(layout).not.toContain('slug: "api-reference/users/listusers"'); - }); - - it("removes an OpenAPI page claimed by a broken incremental MDX source", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary: "Generated operation", - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - expect(await fs.pathExists(pagePath)).toBe(true); - await fs.outputFile( - path.join(watchDir, "api-reference", "users", "listusers.mdx"), - "---\ntitle: Broken\nimage: &self [*self]\n---\nBroken\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange( - "added", - "api-reference/users/listusers.mdx", - ); - - expect(await fs.pathExists(pagePath)).toBe(false); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ slug: "api-reference/users/listusers" }), - ); - const layout = await fs.readFile( - path.join(outputDir, "app", "(site)", "layout.tsx"), - "utf8", - ); - expect(layout).not.toContain('slug: "api-reference/users/listusers"'); - }); - - it("does not overwrite retained MDX output with OpenAPI after a failed move", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary: "Generated operation", - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - const sourcePath = path.join( - watchDir, - "api-reference", - "users", - "listusers.mdx", - ); - await fs.outputFile( - sourcePath, - "---\ntitle: Handwritten\n---\nHANDWRITTEN_BODY\n", - ); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - await fs.writeFile( - sourcePath, - "---\ntitle: Broken move\nsection: Tutorials\nimage: &self [*self]\n---\nBROKEN_BODY\n", - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await generator.handleFileChange( - "changed", - "api-reference/users/listusers.mdx", - ); - - const page = await fs.readFile(pagePath, "utf8"); - expect(page).toContain("HANDWRITTEN_BODY"); - expect(page).not.toContain("Generated operation"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "mdx", - slug: "api-reference/users/listusers", - }), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ - kind: "openapi", - slug: "api-reference/users/listusers", - }), - ); - }); - - it("keeps the active OpenAPI config and watcher target after an invalid replacement", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const writeSpec = (summary: string) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary, - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await writeSpec("Initial summary"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - await fs.writeJson(path.join(root, "doccupine.json"), { - watchDir: "docs", - outputDir: "site", - openapi: "missing.json", - }); - await generator.handleDoccupineConfigChange(); - - await writeSpec("Updated active summary"); - await generator.handleOpenApiChange(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - expect(await fs.readFile(pagePath, "utf8")).toContain( - "Updated active summary", - ); - }); - - it("rejects a symlinked doccupine.json during hot reload", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const externalConfig = path.join(root, "external-doccupine.json"); - await fs.writeJson(externalConfig, { - watchDir: "other-docs", - outputDir: "other-site", - }); - await fs.symlink(externalConfig, path.join(root, "doccupine.json")); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - - await generator.handleDoccupineConfigChange(); - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("keeping the current configuration"), - expect.stringContaining("symbolic link"), - ); - }); - - it("rolls back a parsed OpenAPI config when regeneration fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - const oldSpecPath = path.join(root, "old-openapi.json"); - const candidateSpecPath = path.join(root, "candidate-openapi.json"); - const writeSpec = (specPath: string, resource: string, summary: string) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - [`/${resource}`]: { - get: { - operationId: `list${resource}`, - summary, - tags: [resource], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await writeSpec(oldSpecPath, "users", "Old users"); - await writeSpec(candidateSpecPath, "pets", "Candidate pets"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Old", file: oldSpecPath }], - root, - ); - await generator.init(); - const oldPagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - const candidatePagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "pets", - "listpets", - "page.tsx", - ); - const llmsPath = path.join(outputDir, "public", "llms.txt"); - const oldPage = await fs.readFile(oldPagePath, "utf8"); - const oldLlms = await fs.readFile(llmsPath, "utf8"); - - type GeneratorInternals = { - openApiSpecs: Array<{ name: string; file: string }>; - syncOpenApiSpecWatcher(): Promise; - }; - const internals = generator as unknown as GeneratorInternals; - const syncWatcher = internals.syncOpenApiSpecWatcher.bind(generator); - const watcherTargets: string[] = []; - vi.spyOn(internals, "syncOpenApiSpecWatcher").mockImplementation( - async () => { - watcherTargets.push( - internals.openApiSpecs.map((spec) => path.basename(spec.file)).join(), - ); - await syncWatcher(); - }, - ); - let candidatePageWasWritten = false; - vi.spyOn(generator, "updateLlmsFiles").mockImplementationOnce(async () => { - candidatePageWasWritten = await fs.pathExists(candidatePagePath); - throw new Error("Injected aggregate failure"); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - await fs.writeJson(path.join(root, "doccupine.json"), { - watchDir: "docs", - outputDir: "site", - openapi: [{ name: "Candidate", file: "candidate-openapi.json" }], - }); - - await generator.handleDoccupineConfigChange(); - - expect(candidatePageWasWritten).toBe(true); - expect(internals.openApiSpecs).toEqual([ - { name: "Old", file: oldSpecPath }, - ]); - expect(watcherTargets).toEqual([]); - expect(await fs.pathExists(candidatePagePath)).toBe(false); - expect(await fs.readFile(oldPagePath, "utf8")).toBe(oldPage); - expect(await fs.readFile(llmsPath, "utf8")).toBe(oldLlms); - - await writeSpec(oldSpecPath, "users", "Updated old users"); - await generator.handleOpenApiChange(); - expect(await fs.readFile(oldPagePath, "utf8")).toContain( - "Updated old users", - ); - await generator.stop(); - }); - - it("replays a newly configured OpenAPI source changed before watcher readiness", async () => { - const { root, watchDir, outputDir } = await fixture(); - const oldSpecPath = path.join(root, "old-openapi.json"); - const candidateSpecPath = path.join(root, "candidate-openapi.json"); - const schemaPath = path.join(root, "schemas", "pet.json"); - const writeSchema = (property: string) => - fs.outputJson(schemaPath, { - type: "object", - properties: { [property]: { type: "string" } }, - }); - const writeSpec = ( - specPath: string, - resource: string, - schema: Record, - ) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - [`/${resource}`]: { - get: { - operationId: `list${resource}`, - tags: [resource], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema } }, - }, - }, - }, - }, - }, - }); - await writeSpec(oldSpecPath, "users", { - type: "object", - properties: { OLD_USER: { type: "string" } }, - }); - await writeSpec(candidateSpecPath, "pets", { - type: "object", - properties: { INITIAL_PET: { type: "string" } }, - }); - await writeSchema("REFERENCED_PET"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Old", file: oldSpecPath }], - root, - ); - await generator.init(); - type GeneratorInternals = { - openApiSpecs: Array<{ name: string; file: string }>; - syncOpenApiSpecWatcher(): Promise; - }; - const internals = generator as unknown as GeneratorInternals; - const syncWatcher = internals.syncOpenApiSpecWatcher.bind(generator); - let changedDuringSync = false; - vi.spyOn(internals, "syncOpenApiSpecWatcher").mockImplementation( - async () => { - if ( - !changedDuringSync && - internals.openApiSpecs.some( - (spec) => path.resolve(root, spec.file) === candidateSpecPath, - ) - ) { - changedDuringSync = true; - await writeSpec(candidateSpecPath, "pets", { - $ref: "./schemas/pet.json", - }); - } - await syncWatcher(); - }, - ); - await fs.writeJson(path.join(root, "doccupine.json"), { - watchDir: "docs", - outputDir: "site", - openapi: [{ name: "Candidate", file: "candidate-openapi.json" }], - }); - - await generator.handleDoccupineConfigChange(); - - expect(changedDuringSync).toBe(true); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "pets", - "listpets", - "page.tsx", - ); - expect(await fs.readFile(pagePath, "utf8")).toContain("REFERENCED_PET"); - - await writeSchema("UPDATED_PET"); - await waitUntil(async () => - (await fs.readFile(pagePath, "utf8")).includes("UPDATED_PET"), - ); - await generator.stop(); - }); - - it("keeps the last successful OpenAPI page when one candidate page fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const writeSpec = (summary: string, includePets = false) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary, - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - ...(includePets - ? { - "/pets": { - get: { - operationId: "listPets", - summary: "Candidate pets", - tags: ["pets"], - responses: { "200": { description: "OK" } }, - }, - }, - } - : {}), - }, - }); - await writeSpec("Last good users"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - const previousPage = await fs.readFile(pagePath, "utf8"); - const petsPage = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "pets", - "listpets", - "page.tsx", - ); - const generatePage = generator.generatePageFromMDX.bind(generator); - vi.spyOn(generator, "generatePageFromMDX") - .mockImplementationOnce(generatePage) - .mockImplementationOnce(generatePage) - .mockImplementationOnce(async () => { - throw new Error("Injected endpoint render failure"); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - await writeSpec("Candidate users", true); - - await generator.handleOpenApiChange(); - - expect(await fs.readFile(pagePath, "utf8")).toBe(previousPage); - expect(await fs.pathExists(petsPage)).toBe(false); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "openapi", - slug: "api-reference/users/listusers", - }), - ); - }); - - it("removes uncommitted OpenAPI candidates when allowlist writing fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const writeSpec = (resource: string, summary: string) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - [`/${resource}`]: { - get: { - operationId: `list${resource}`, - summary, - tags: [resource], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await writeSpec("users", "Last good users"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const usersPage = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - const petsPage = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "pets", - "listpets", - "page.tsx", - ); - const previousUsers = await fs.readFile(usersPage, "utf8"); - const previousAllowlist = await fs.readFile( - path.join(outputDir, "services", "openapi", "playground-allowlist.json"), - "utf8", - ); - type GeneratorInternals = { writeApiAllowlist(): Promise }; - vi.spyOn( - generator as unknown as GeneratorInternals, - "writeApiAllowlist", - ).mockRejectedValue(new Error("Injected allowlist failure")); - vi.spyOn(console, "error").mockImplementation(() => {}); - await writeSpec("pets", "Candidate pets"); - - await generator.handleOpenApiChange(); - - expect(await fs.pathExists(petsPage)).toBe(false); - expect(await fs.readFile(usersPage, "utf8")).toBe(previousUsers); - expect( - await fs.readFile( - path.join( - outputDir, - "services", - "openapi", - "playground-allowlist.json", - ), - "utf8", - ), - ).toBe(previousAllowlist); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ - kind: "openapi", - slug: "api-reference/users/listusers", - }), - ); - expect(manifest.routes).not.toContainEqual( - expect.objectContaining({ slug: "api-reference/pets/listpets" }), - ); - }); - - it("rolls back direct OpenAPI watcher updates after aggregate failure", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const writeSpec = (resource: string) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - [`/${resource}`]: { - get: { - operationId: `list${resource}`, - summary: `${resource} summary`, - tags: [resource], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await writeSpec("users"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const usersPage = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - const petsPage = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "pets", - "listpets", - "page.tsx", - ); - const previousUsers = await fs.readFile(usersPage, "utf8"); - const previousLlms = await fs.readFile( - path.join(outputDir, "public", "llms.txt"), - "utf8", - ); - vi.spyOn(generator, "updateLlmsFiles").mockRejectedValueOnce( - new Error("Injected aggregate failure"), - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - await writeSpec("pets"); - - await generator.handleOpenApiChange(); - - expect(await fs.pathExists(petsPage)).toBe(false); - expect(await fs.readFile(usersPage, "utf8")).toBe(previousUsers); - expect( - await fs.readFile(path.join(outputDir, "public", "llms.txt"), "utf8"), - ).toBe(previousLlms); - }); - - it("rejects schema-invalid config reloads before changing generated output", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const writeSpec = (summary: string) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary, - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await writeSpec("Initial summary"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - const llmsPath = path.join(outputDir, "public", "llms.txt"); - const initialPage = await fs.readFile(pagePath, "utf8"); - const initialLlms = await fs.readFile(llmsPath, "utf8"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const invalidConfigs: unknown[] = [ - {}, - { - watchDir: "docs", - outputDir: "site", - openapi: [{ name: "Missing file" }], - }, - { - watchDir: "docs", - outputDir: "docs/generated", - openapi: specPath, - }, - { watchDir: "", outputDir: "site", openapi: specPath }, - { - watchDir: "docs", - outputDir: "site", - port: "70000", - openapi: specPath, - }, - { - watchDir: "docs", - outputDir: "site", - packageManager: "yarn", - openapi: specPath, - }, - ]; - - for (const invalidConfig of invalidConfigs) { - await fs.writeJson(path.join(root, "doccupine.json"), invalidConfig); - await generator.handleDoccupineConfigChange(); - expect(await fs.readFile(pagePath, "utf8")).toBe(initialPage); - expect(await fs.readFile(llmsPath, "utf8")).toBe(initialLlms); - } - expect(warn).toHaveBeenCalledTimes(invalidConfigs.length); - - await writeSpec("Updated active summary"); - await generator.handleOpenApiChange(); - expect(await fs.readFile(pagePath, "utf8")).toContain( - "Updated active summary", - ); - }); - - it("keeps the restart hint for valid watch and output directory changes", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.writeJson(path.join(root, "doccupine.json"), { - watchDir: "other-docs", - outputDir: "other-site", - }); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - - await generator.handleDoccupineConfigChange(); - - expect(log).toHaveBeenCalledWith( - expect.stringContaining("watchDir/outputDir changes"), - ); - }); - - it("preserves project-owned public aggregate artifacts on every refresh", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const artifacts = [ - ["LLMS.TXT", "llms.txt", "USER_LLMS_INDEX\n"], - ["Llms-Full.TxT", "llms-full.txt", "USER_LLMS_FULL\n"], - ["SKILL.MD", "skill.md", "USER_SKILL\n"], - [ - path.join(".WELL-KNOWN", "MCP.JSON"), - path.join(".well-known", "mcp.json"), - '{"user":true}\n', - ], - ] as const; - for (const [sourceRelativePath, , content] of artifacts) { - await fs.outputFile( - path.join(root, "public", sourceRelativePath), - content, - ); - } - - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await generator.updateLlmsFiles(); - - for (const [, outputRelativePath, content] of artifacts) { - expect( - await fs.readFile( - path.join(outputDir, "public", outputRelativePath), - "utf8", - ), - ).toBe(content); - } - }); - - it("rejects public symlinks during the initial public copy", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sensitivePath = path.join(root, "sensitive.txt"); - const publicPath = path.join(root, "public", "leaked.txt"); - await fs.writeFile(sensitivePath, "SENSITIVE_PUBLIC_CONTENT\n"); - await fs.ensureDir(path.dirname(publicPath)); - await fs.symlink(sensitivePath, publicPath, "file"); - await fs.ensureDir(outputDir); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - - await expect(generator.copyPublicFiles()).rejects.toThrow( - /public source.*leaked\.txt.*symbolic link/i, - ); - expect( - await fs.pathExists(path.join(outputDir, "public", "leaked.txt")), - ).toBe(false); - }); - - it("rejects public symlinks during watch-style copies", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sensitivePath = path.join(root, "sensitive.txt"); - const publicPath = path.join(root, "public", "leaked.txt"); - await fs.writeFile(sensitivePath, "SENSITIVE_PUBLIC_CONTENT\n"); - await fs.ensureDir(path.dirname(publicPath)); - await fs.symlink(sensitivePath, publicPath, "file"); - await fs.ensureDir(outputDir); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await expect(generator.handlePublicFileChange(publicPath)).rejects.toThrow( - /public source.*leaked\.txt.*symbolic link/i, - ); - expect( - await fs.pathExists(path.join(outputDir, "public", "leaked.txt")), - ).toBe(false); - }); - - it("atomically replaces a hard-linked public destination", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(root, "public", "asset.bin"); - const destPath = path.join(outputDir, "public", "asset.bin"); - const externalPeer = path.join(root, "external-peer.bin"); - await fs.outputFile(sourcePath, Buffer.from([0, 1, 2, 255])); - await fs.outputFile(externalPeer, "keep"); - await fs.ensureDir(path.dirname(destPath)); - await fs.link(externalPeer, destPath); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - - await generator.handlePublicFileChange(sourcePath); - - await expect(fs.readFile(destPath)).resolves.toEqual( - Buffer.from([0, 1, 2, 255]), - ); - await expect(fs.readFile(externalPeer, "utf8")).resolves.toBe("keep"); - }); - - it("prunes public files deleted while the generator was stopped", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(root, "public", "obsolete.txt"); - const outputPath = path.join(outputDir, "public", "obsolete.txt"); - await fs.outputFile(sourcePath, "obsolete\n"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - - const first = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await first.init(); - expect(await fs.readFile(outputPath, "utf8")).toBe("obsolete\n"); - - await fs.remove(sourcePath); - const second = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await second.init(); - - expect(await fs.pathExists(outputPath)).toBe(false); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.publicFiles).toEqual([]); - }); - - it("writes normalized analytics configuration to the generated runtime", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - await fs.writeJson(path.join(root, "analytics.json"), { - provider: "posthog", - posthog: { - key: "phc_test-key", - host: " https://posthog.example/ ", - }, - }); - - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - - expect(await fs.readJson(path.join(outputDir, "analytics.json"))).toEqual({ - provider: "posthog", - posthog: { - key: "phc_test-key", - host: "https://posthog.example", - }, - }); - expect( - await fs.readFile(path.join(outputDir, "next.config.ts"), "utf8"), - ).toContain('destination: "https://posthog.example/:path*"'); - }); - - it("uses one captured source version for metadata and rendering", async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(watchDir, "guide.mdx"); - await fs.writeJson(path.join(root, "sections.json"), [ - { label: "Guides", slug: "guides" }, - { label: "Tutorials", slug: "tutorials" }, - ]); - await fs.writeFile( - sourcePath, - "---\ntitle: Initial\nsection: Guides\n---\nInitial\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - type GeneratorInternals = { sourceFs: SecureSourceFs }; - const sourceFs = (generator as unknown as GeneratorInternals).sourceFs; - const readSource = sourceFs.readMdxSourceFile.bind(sourceFs); - let guideReads = 0; - const readSpy = vi - .spyOn(sourceFs, "readMdxSourceFile") - .mockImplementation(async (filePath) => { - const captured = await readSource(filePath); - if (filePath.replace(/\\/g, "/").endsWith("guide.mdx")) { - guideReads += 1; - if (guideReads === 1) { - await fs.writeFile( - sourcePath, - "---\ntitle: Later\nsection: Tutorials\n---\nLATER_BODY\n", - ); - } - } - return captured; - }); - await fs.writeFile( - sourcePath, - "---\ntitle: Captured\nsection: Guides\n---\nCAPTURED_BODY\n", - ); - - await generator.handleFileChange("changed", "guide.mdx"); - - expect(guideReads).toBe(1); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "guides", "guide", "page.tsx"), - "utf8", - ), - ).toContain("CAPTURED_BODY"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.routes).toContainEqual( - expect.objectContaining({ source: "guide.mdx", slug: "guides/guide" }), - ); - - readSpy.mockRestore(); - await generator.handleFileChange("changed", "guide.mdx"); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), - "utf8", - ), - ).toContain("LATER_BODY"); - }); - - it("refreshes inferred sections before validating changed routes", async () => { - const { root, watchDir, outputDir } = await fixture(); - const movingSource = path.join(watchDir, "nested.mdx"); - await fs.writeFile( - movingSource, - "---\ntitle: Moving\nsection: Guides\n---\nOLD_BODY\n", - ); - await fs.outputFile( - path.join(watchDir, "nested", "index.mdx"), - "---\ntitle: Nested\n---\nNESTED_BODY\n", - ); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await fs.writeFile( - movingSource, - "---\ntitle: Moving\nsection: Tutorials\n---\nNEW_BODY\n", - ); - - await generator.handleFileChange("changed", "nested.mdx"); - - expect( - await fs.readFile( - path.join( - outputDir, - "app", - "(site)", - "tutorials", - "nested", - "page.tsx", - ), - "utf8", - ), - ).toContain("NEW_BODY"); - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "nested", "page.tsx"), - "utf8", - ), - ).toContain("NESTED_BODY"); - }); - - it("reconciles MDX, public, and OpenAPI changes made during init", async () => { - const { root, watchDir, outputDir } = await fixture(); - const mdxPath = path.join(watchDir, "guide.mdx"); - const publicPath = path.join(root, "public", "asset.txt"); - const specPath = path.join(root, "openapi.json"); - const writeSpec = (summary: string) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - summary, - tags: ["users"], - responses: { "200": { description: "OK" } }, - }, - }, - }, - }); - await fs.outputFile( - mdxPath, - "---\ntitle: Old\nsection: Guides\n---\nOld body\n", - ); - await fs.outputFile(publicPath, "old asset\n"); - await writeSpec("Old API summary"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: "openapi.json" }], - root, - ); - const processAll = generator.processAllMDXFiles.bind(generator); - vi.spyOn(generator, "processAllMDXFiles").mockImplementationOnce( - async () => { - await processAll(); - await fs.writeFile( - mdxPath, - "---\ntitle: New\nsection: Tutorials\n---\nNew body\n", - ); - await fs.writeFile(publicPath, "new asset\n"); - await writeSpec("New API summary"); - await fs.writeJson(path.join(root, "doccupine.json"), { - watchDir: "docs", - outputDir: "site", - port: "4000", - openapi: [{ name: "Test", file: "openapi.json" }], - }); - }, - ); - - await generator.init(); - await generator.startWatching(); - - expect( - await fs.readFile( - path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), - "utf8", - ), - ).toContain("New body"); - expect( - await fs.readFile(path.join(outputDir, "public", "asset.txt"), "utf8"), - ).toBe("new asset\n"); - expect( - await fs.readFile( - path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ), - "utf8", - ), - ).toContain("New API summary"); - await generator.stop(); - }); - - it("closes every watcher when watcher startup fails", async () => { - const { root, watchDir, outputDir } = await fixture(); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - type Watcher = { close(): Promise }; - type WatchCoordinatorInternals = { - waitForWatcherReady(watcher: Watcher): Promise; - watcher: Watcher | null; - configWatcher: Watcher | null; - fontWatcher: Watcher | null; - analyticsWatcher: Watcher | null; - openApiWatcher: Watcher | null; - doccupineConfigWatcher: Watcher | null; - publicWatcher: Watcher | null; - rootDirWatcher: Watcher | null; - }; - type GeneratorInternals = { - watchCoordinator: WatchCoordinatorInternals; - }; - const coordinator = (generator as unknown as GeneratorInternals) - .watchCoordinator; - const closeSpies: Array> = []; - let readinessCalls = 0; - vi.spyOn(coordinator, "waitForWatcherReady").mockImplementation( - async (watcher) => { - readinessCalls += 1; - const close = vi.spyOn(watcher, "close"); - if (readinessCalls === 1) { - close.mockRejectedValueOnce(new Error("Injected close failure")); - } - closeSpies.push(close); - if (readinessCalls === 6) { - throw new Error("Injected watcher readiness failure"); - } - }, - ); - - await expect(generator.startWatching()).rejects.toThrow( - "Injected watcher readiness failure", - ); - - expect(readinessCalls).toBe(6); - expect(closeSpies[0]).toHaveBeenCalledTimes(2); - for (const close of closeSpies.slice(1)) { - expect(close).toHaveBeenCalledOnce(); - } - expect(coordinator.watcher).toBeNull(); - expect(coordinator.configWatcher).toBeNull(); - expect(coordinator.fontWatcher).toBeNull(); - expect(coordinator.analyticsWatcher).toBeNull(); - expect(coordinator.openApiWatcher).toBeNull(); - expect(coordinator.doccupineConfigWatcher).toBeNull(); - expect(coordinator.publicWatcher).toBeNull(); - expect(coordinator.rootDirWatcher).toBeNull(); - }); - - it("retries an OpenAPI watcher close that fails during retargeting", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - await fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: {}, - }); - await fs.writeFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const specs = [{ name: "Test", file: specPath }]; - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - specs, - root, - ); - await generator.init(); - await generator.startWatching(); - type Watcher = { close(): Promise }; - type WatchCoordinatorInternals = { - openApiWatcher: Watcher | null; - syncOpenApiSpecWatcher( - specs: Array<{ name: string; file: string }>, - sourceFiles: string[], - ): Promise; - }; - type GeneratorInternals = { - watchCoordinator: WatchCoordinatorInternals; - }; - const coordinator = (generator as unknown as GeneratorInternals) - .watchCoordinator; - const watcher = coordinator.openApiWatcher; - if (!watcher) throw new Error("Expected an OpenAPI watcher"); - const close = vi - .spyOn(watcher, "close") - .mockRejectedValueOnce(new Error("Injected retarget close failure")); - - await expect( - coordinator.syncOpenApiSpecWatcher(specs, [specPath]), - ).rejects.toThrow("Injected retarget close failure"); - await generator.stop(); - - expect(close).toHaveBeenCalledTimes(2); - expect(coordinator.openApiWatcher).toBeNull(); - }); - - it("reconciles source changes that predate watcher readiness", async () => { - const { root, watchDir, outputDir } = await fixture(); - const mdxPath = path.join(watchDir, "guide.mdx"); - const publicPath = path.join(root, "public", "asset.txt"); - await fs.outputFile(mdxPath, "---\ntitle: Old\n---\nOld body\n"); - await fs.outputFile(publicPath, "old asset\n"); - - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - - await fs.writeFile(mdxPath, "---\ntitle: New\n---\nNew body\n"); - await fs.remove(publicPath); - await generator.startWatching(); - - const page = await fs.readFile( - path.join(outputDir, "app", "(site)", "guide", "page.tsx"), - "utf8", - ); - expect(page).toContain("New body"); - expect( - await fs.pathExists(path.join(outputDir, "public", "asset.txt")), - ).toBe(false); - await generator.stop(); - }); - - it("watches local OpenAPI reference files as generation sources", async () => { - const { root, watchDir, outputDir } = await fixture(); - const specPath = path.join(root, "openapi.json"); - const schemaPath = path.join(root, "schemas", "user.json"); - const writeSchema = (property: string) => - fs.outputJson(schemaPath, { - type: "object", - properties: { [property]: { type: "string" } }, - }); - const writeSpec = (schema: Record) => - fs.writeJson(specPath, { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/users": { - get: { - operationId: "listUsers", - tags: ["users"], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema } }, - }, - }, - }, - }, - }, - }); - await writeSchema("OLD_FIELD"); - await writeSpec({ - type: "object", - properties: { INITIAL_FIELD: { type: "string" } }, - }); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - const generator = new MDXToNextJSGenerator( - watchDir, - outputDir, - [{ name: "Test", file: specPath }], - root, - ); - await generator.init(); - await generator.startWatching(); - const pagePath = path.join( - outputDir, - "app", - "(site)", - "api-reference", - "users", - "listusers", - "page.tsx", - ); - expect(await fs.readFile(pagePath, "utf8")).toContain("INITIAL_FIELD"); - - await writeSpec({ $ref: "./schemas/user.json" }); - - await waitUntil(async () => { - try { - return (await fs.readFile(pagePath, "utf8")).includes("OLD_FIELD"); - } catch { - return false; - } - }); - - await writeSchema("NEW_FIELD"); - - await waitUntil(async () => { - try { - return (await fs.readFile(pagePath, "utf8")).includes("NEW_FIELD"); - } catch { - return false; - } - }); - await generator.stop(); - }); - - it("does not replay unchanged sources when watchers become ready", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - await fs.writeJson(path.join(root, "config.json"), { name: "Docs" }); - await fs.outputFile(path.join(root, "public", "asset.txt"), "asset\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const processAll = vi.spyOn(generator, "processAllMDXFiles"); - const configChange = vi.spyOn(generator, "handleConfigFileChange"); - const configDelete = vi.spyOn(generator, "handleConfigFileDelete"); - const publicCopy = vi.spyOn(generator, "copyPublicFiles"); - - await generator.startWatching(); - - expect(processAll).not.toHaveBeenCalled(); - expect(configChange).not.toHaveBeenCalled(); - expect(configDelete).not.toHaveBeenCalled(); - expect(publicCopy).not.toHaveBeenCalled(); - await generator.stop(); - }); - - it("recreates public watching after the source directory is replaced", async () => { - const { root, watchDir, outputDir } = await fixture(); - const publicDir = path.join(root, "public"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - await fs.outputFile(path.join(publicDir, "old.txt"), "old\n"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - await generator.startWatching(); - - await fs.remove(publicDir); - await waitUntil(() => - fs - .pathExists(path.join(outputDir, "public", "old.txt")) - .then((exists) => !exists), - ); - await fs.outputFile(path.join(publicDir, "new.txt"), "new\n"); - await waitUntil(() => - fs.pathExists(path.join(outputDir, "public", "new.txt")), - ); - await fs.writeFile(path.join(publicDir, "new.txt"), "updated\n"); - await waitUntil(async () => { - try { - return ( - (await fs.readFile( - path.join(outputDir, "public", "new.txt"), - "utf8", - )) === "updated\n" - ); - } catch { - return false; - } - }); - - await generator.stop(); - }); - - it.skipIf(process.platform !== "darwin" && process.platform !== "win32")( - "preserves case-only public renames on case-insensitive filesystems", - async () => { - const { root, watchDir, outputDir } = await fixture(); - const lowerSource = path.join(root, "public", "asset.txt"); - const upperSource = path.join(root, "public", "ASSET.txt"); - await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); - await fs.outputFile(lowerSource, "asset\n"); - - const first = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await first.init(); - await fs.rename(lowerSource, upperSource); - - const second = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await second.init(); - - await expect( - fs.readFile(path.join(outputDir, "public", "ASSET.txt"), "utf8"), - ).resolves.toBe("asset\n"); - const manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.publicFiles).toContain("ASSET.txt"); - expect(manifest.publicFiles).not.toContain("asset.txt"); - }, - ); - - it.skipIf(process.platform === "win32")( - "atomically replaces a symlinked public destination", - async () => { - const { root, watchDir, outputDir } = await fixture(); - const sourcePath = path.join(root, "public", "asset.txt"); - const destPath = path.join(outputDir, "public", "asset.txt"); - const externalTarget = path.join(root, "external-target.txt"); - await fs.outputFile(sourcePath, "new"); - await fs.outputFile(externalTarget, "keep"); - await fs.ensureDir(path.dirname(destPath)); - await fs.symlink(externalTarget, destPath, "file"); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - - await generator.handlePublicFileChange(sourcePath); - - await expect(fs.readFile(destPath, "utf8")).resolves.toBe("new"); - await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("keep"); - expect((await fs.lstat(destPath)).isSymbolicLink()).toBe(false); - }, - ); - - it.skipIf(process.platform === "win32")( - "rejects a source parent swapped before the source is opened", - async () => { - const { root, watchDir, outputDir } = await fixture(); - const publicDir = path.join(root, "public"); - const sourceParent = path.join(publicDir, "assets"); - const displacedParent = path.join(publicDir, "assets-original"); - const externalParent = path.join(root, "external-assets"); - const sourcePath = path.join(sourceParent, "asset.txt"); - await fs.outputFile(sourcePath, "safe"); - await fs.outputFile(path.join(externalParent, "asset.txt"), "secret"); - await fs.ensureDir(outputDir); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - const realpath = fs.realpath.bind(fs); - let sourceResolutions = 0; - vi.spyOn(fs, "realpath").mockImplementation(async (candidate: string) => { - const resolved = await realpath(candidate); - if ( - path.resolve(candidate) === sourcePath && - sourceResolutions++ === 0 - ) { - await fs.rename(sourceParent, displacedParent); - await fs.symlink(externalParent, sourceParent, "dir"); - } - return resolved; - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - - await expect( - generator.handlePublicFileChange(sourcePath), - ).rejects.toThrow(/real path.*outside/i); - await expect( - fs.pathExists(path.join(outputDir, "public", "assets", "asset.txt")), - ).resolves.toBe(false); - }, - ); - - it.skipIf(process.platform === "win32")( - "keeps reading the opened source if its parent is swapped afterward", - async () => { - const { root, watchDir, outputDir } = await fixture(); - const publicDir = path.join(root, "public"); - const sourceParent = path.join(publicDir, "assets"); - const displacedParent = path.join(publicDir, "assets-original"); - const externalParent = path.join(root, "external-assets"); - const sourcePath = path.join(sourceParent, "asset.txt"); - const destPath = path.join(outputDir, "public", "assets", "asset.txt"); - await fs.outputFile(sourcePath, "safe"); - await fs.outputFile(path.join(externalParent, "asset.txt"), "secret"); - await fs.ensureDir(outputDir); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - const lstat = fs.lstat.bind(fs); - let sourceStats = 0; - vi.spyOn(fs, "lstat").mockImplementation(async (candidate: string) => { - const stat = await lstat(candidate); - if (path.resolve(candidate) === sourcePath && ++sourceStats === 4) { - await fs.rename(sourceParent, displacedParent); - await fs.symlink(externalParent, sourceParent, "dir"); - } - return stat; - }); - - await generator.handlePublicFileChange(sourcePath); - - await expect(fs.readFile(destPath, "utf8")).resolves.toBe("safe"); - await expect( - fs.readFile(path.join(externalParent, "asset.txt"), "utf8"), - ).resolves.toBe("secret"); - }, - ); - - it("restores mixed-case managed public overrides after watch deletion", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "guide.mdx"), - "---\ntitle: Guide\n---\nGUIDE_BODY\n", - ); - await fs.writeJson(path.join(root, "config.json"), { - name: "Test Docs", - url: "https://docs.example.com", - }); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const artifacts = [ - ["LLMS.TXT", "llms.txt"], - ["LLMS-FULL.TXT", "llms-full.txt"], - ["SKILL.MD", "skill.md"], - ["GUIDE.MD", "guide.md"], - [ - path.join(".WELL-KNOWN", "MCP.JSON"), - path.join(".well-known", "mcp.json"), - ], - ] as const; - const generated = new Map(); - for (const [, outputRelativePath] of artifacts) { - generated.set( - outputRelativePath, - await fs.readFile( - path.join(outputDir, "public", outputRelativePath), - "utf8", - ), - ); - } - - for (const [sourceRelativePath, outputRelativePath] of artifacts) { - const sourcePath = path.join(root, "public", sourceRelativePath); - await fs.outputFile(sourcePath, `USER:${sourceRelativePath}\n`); - await generator.handlePublicFileChange(sourcePath); - expect( - await fs.readFile( - path.join(outputDir, "public", outputRelativePath), - "utf8", - ), - ).toBe(`USER:${sourceRelativePath}\n`); - } - let manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.llmsPageFiles).not.toContain("guide.md"); - - for (const [sourceRelativePath, outputRelativePath] of artifacts) { - const sourcePath = path.join(root, "public", sourceRelativePath); - await fs.remove(sourcePath); - await generator.handlePublicFileDelete(sourcePath); - expect( - await fs.readFile( - path.join(outputDir, "public", outputRelativePath), - "utf8", - ), - ).toBe(generated.get(outputRelativePath)); - } - manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.llmsPageFiles).toContain("guide.md"); - }); - - it("restores generated public artifacts after watch-style overrides are deleted", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "skill.mdx"), - "---\ntitle: Skill Page\n---\nPAGE_SKILL_BODY\n", - ); - await fs.outputFile( - path.join(watchDir, "guide.mdx"), - "---\ntitle: Guide\n---\nGUIDE_BODY\n", - ); - const configPath = path.join(root, "config.json"); - await fs.writeJson(configPath, { - name: "Test Docs", - url: "https://docs.example.com", - }); - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - - const relativePaths = [ - "llms.txt", - "llms-full.txt", - "skill.md", - "guide.md", - path.join(".well-known", "mcp.json"), - ]; - const generated = new Map(); - for (const relativePath of relativePaths) { - generated.set( - relativePath, - await fs.readFile(path.join(outputDir, "public", relativePath), "utf8"), - ); - } - expect(generated.get("skill.md")).toContain("## Reading these docs"); - expect(generated.get("skill.md")).not.toContain("PAGE_SKILL_BODY"); - - for (const relativePath of relativePaths) { - const sourcePath = path.join(root, "public", relativePath); - await fs.outputFile(sourcePath, `USER_OVERRIDE:${relativePath}\n`); - await generator.handlePublicFileChange(sourcePath); - expect( - await fs.readFile(path.join(outputDir, "public", relativePath), "utf8"), - ).toBe(`USER_OVERRIDE:${relativePath}\n`); - } - let manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.llmsPageFiles).not.toContain("skill.md"); - expect(manifest.llmsPageFiles).not.toContain("guide.md"); - - for (const relativePath of relativePaths) { - const sourcePath = path.join(root, "public", relativePath); - await fs.remove(sourcePath); - await generator.handlePublicFileDelete(sourcePath); - expect( - await fs.readFile(path.join(outputDir, "public", relativePath), "utf8"), - ).toBe(generated.get(relativePath)); - } - - manifest = await fs.readJson( - path.join(outputDir, ".doccupine-artifacts.json"), - ); - expect(manifest.llmsPageFiles).not.toContain("skill.md"); - expect(manifest.llmsPageFiles).toContain("guide.md"); - - await fs.writeJson(configPath, { name: "Test Docs" }); - await generator.handleConfigFileChange(configPath); - expect( - await fs.pathExists( - path.join(outputDir, "public", ".well-known", "mcp.json"), - ), - ).toBe(false); - }); - - it("does not overwrite or delete a colliding project public asset", async () => { - const { root, watchDir, outputDir } = await fixture(); - await fs.outputFile( - path.join(watchDir, "guide.mdx"), - "---\ntitle: Guide\n---\nGenerated body\n", - ); - await fs.outputFile( - path.join(root, "public", "guide.md"), - "USER_OWNED_PUBLIC_ASSET\n", - ); - - const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); - await generator.init(); - const publicAsset = path.join(outputDir, "public", "guide.md"); - expect(await fs.readFile(publicAsset, "utf8")).toBe( - "USER_OWNED_PUBLIC_ASSET\n", - ); - - await fs.remove(path.join(watchDir, "guide.mdx")); - await generator.handleFileDelete("guide.mdx"); - expect(await fs.readFile(publicAsset, "utf8")).toBe( - "USER_OWNED_PUBLIC_ASSET\n", - ); - }); -}); diff --git a/src/mdx-to-nextjs-generator.ts b/src/mdx-to-nextjs-generator.ts index 0f9306d..0c5c61a 100644 --- a/src/mdx-to-nextjs-generator.ts +++ b/src/mdx-to-nextjs-generator.ts @@ -15,7 +15,6 @@ import { } from "./lib/generated-artifacts.js"; import { claimOutputDirectory, - readOutputFileIfPresent, resolveOutputPath, } from "./lib/output-safety.js"; import { OpenApiRegistry, DEFAULT_API_BASE_SLUG } from "./lib/openapi.js"; @@ -23,7 +22,6 @@ import { getFullSlug, safeMatter, writeFileAtomic } from "./lib/utils.js"; import { nextConfigTemplate } from "./templates/next.config.js"; import { proxyTemplate } from "./templates/proxy.js"; import type { - DoccupineConfig, MDXFile, PageMeta, SectionConfig, @@ -35,10 +33,21 @@ import type { OperationDescriptor } from "./lib/openapi-types.js"; import { SecureSourceFs } from "./generator/secure-source-fs.js"; import { AppScaffolder } from "./generator/app-scaffolder.js"; import { ApiReferenceGenerator } from "./generator/api-reference-generator.js"; +import { GeneratedPagePublisher } from "./generator/generated-page-publisher.js"; import { GeneratedRouteManager } from "./generator/generated-route-manager.js"; +import { + MdxPassBuilder, + type MdxPassSnapshot, + type MdxSourceSnapshot, +} from "./generator/mdx-pass-builder.js"; import { ProjectConfigRepository } from "./generator/project-config-repository.js"; import { PublicAssetManager } from "./generator/public-asset-manager.js"; import { WatchCoordinator } from "./generator/watch-coordinator.js"; +import { SectionIndexGenerator } from "./generator/section-index-generator.js"; +import { + OpenApiRefreshCoordinator, + type ApiPageWriteOptions, +} from "./generator/openapi-refresh-coordinator.js"; import { addApiReferenceSection, determineSectionRoute, @@ -46,15 +55,10 @@ import { } from "./generator/section-resolver.js"; import { renderHomepage, - renderMdxPage, - renderSectionPage, type HomepageSource, - type RenderedPage, } from "./generator/page-renderer.js"; import { - buildRealPagesMeta as buildRealPageCatalog, mergePages as mergePageCatalog, - parseMdxPageMeta, RouteCollisionError, } from "./generator/page-catalog.js"; import { @@ -65,18 +69,6 @@ import { writeSitemap, } from "./generator/site-artifacts.js"; -interface MdxSourceSnapshot { - content: string; - stat: fs.Stats; -} - -interface MdxPassSnapshot { - files: string[]; - pages: PageMeta[]; - sources: ReadonlyMap; - sections: SectionConfig[] | null; -} - interface SuccessfulMdxState { pages: Map; content: Map; @@ -91,11 +83,6 @@ interface GeneratedPageCommit { rollback(): Promise; } -interface ApiPageWriteOptions { - writtenRoutes?: Map; - additionalPreviousRoutes?: Iterable; -} - export class MDXToNextJSGenerator { private watchDir: string; private outputDir: string; @@ -122,7 +109,11 @@ export class MDXToNextJSGenerator { private sourceFs: SecureSourceFs; private appScaffolder: AppScaffolder; private apiReferenceGenerator: ApiReferenceGenerator; + private generatedPagePublisher: GeneratedPagePublisher; private generatedRouteManager: GeneratedRouteManager; + private mdxPassBuilder: MdxPassBuilder; + private openApiRefreshCoordinator: OpenApiRefreshCoordinator; + private sectionIndexGenerator: SectionIndexGenerator; private projectConfigRepository: ProjectConfigRepository; private publicAssetManager: PublicAssetManager; private watchCoordinator: WatchCoordinator; @@ -154,6 +145,26 @@ export class MDXToNextJSGenerator { this.outputDir, this.artifacts, ); + this.generatedPagePublisher = new GeneratedPagePublisher( + this.outputDir, + this.generatedRouteManager, + ); + this.sectionIndexGenerator = new SectionIndexGenerator( + this.outputDir, + this.generatedRouteManager, + ); + this.mdxPassBuilder = new MdxPassBuilder({ + sourceFs: this.sourceFs, + getAllMdxFiles: () => this.getAllMDXFiles(), + getSections: () => this.sectionsConfig, + loadSectionsConfig: () => this.loadSectionsConfig(), + withApiReferenceSection: (sections) => + this.withApiReferenceSection(sections), + determineSectionForFile: (filePath, frontmatter, sections) => + this.determineSectionForFile(filePath, frontmatter, sections), + resolveHttpMethod: (reference) => + this.apiRegistry.lookup(reference)?.method, + }); this.projectConfigRepository = new ProjectConfigRepository( this.rootDir, this.outputDir, @@ -201,6 +212,34 @@ export class MDXToNextJSGenerator { processAllMDXFiles: () => this.reconcileMdxSources(), }, }); + this.openApiRefreshCoordinator = new OpenApiRefreshCoordinator({ + rootDir: this.rootDir, + watchDir: this.watchDir, + outputDir: this.outputDir, + configFile: this.doccupineConfigFile, + apiBaseSlug: this.apiBaseSlug, + sourceFs: this.sourceFs, + getRegistry: () => this.apiRegistry, + setRegistry: (registry) => { + this.apiRegistry = registry; + }, + getSpecs: () => this.openApiSpecs, + setSpecs: (specs) => { + this.openApiSpecs = specs; + }, + getSections: () => this.sectionsConfig, + setSections: (sections) => { + this.sectionsConfig = sections; + }, + getOpenApiRoutes: () => this.artifacts.routesFor("openapi"), + getSuccessfulMdxPages: () => [...this.successfulMdxPages.values()], + resolveSections: () => this.resolveSections(), + writeApiPages: (realPages, options) => + this.writeApiPages(realPages, options), + refreshSiteAggregates: () => this.refreshSiteAggregates(), + syncWatcher: () => this.syncOpenApiSpecWatcher(), + removeOwnedRoute: (slug) => this.removeOwnedRoute(slug), + }); } private outputPath(...segments: string[]): string { @@ -592,60 +631,12 @@ export class MDXToNextJSGenerator { return this.watchCoordinator.startWatching(); } - private async parseMDXFile( - file: string, - source?: MdxSourceSnapshot, - sections: SectionConfig[] | null = this.sectionsConfig, - ): Promise { - return parseMdxPageMeta( - file, - source - ? async () => source - : (filePath) => this.readMdxSourceFile(filePath), - (filePath, frontmatter) => - this.determineSectionForFile(filePath, frontmatter, sections), - (reference) => this.apiRegistry.lookup(reference)?.method, - ); - } - private async captureMdxPass( files?: string[], seededSources: ReadonlyMap = new Map(), refreshSections = false, ): Promise { - const resolvedFiles = files ?? (await this.getAllMDXFiles()); - const sources = new Map(seededSources); - await Promise.all( - resolvedFiles.map(async (file) => { - const source = file.replace(/\\/g, "/"); - if (!sources.has(source)) { - sources.set(source, await this.readMdxSourceFile(file)); - } - }), - ); - let sections = this.sectionsConfig; - if (refreshSections) { - const configuredSections = await this.loadSectionsConfig(); - if (configuredSections !== null) { - sections = this.withApiReferenceSection(configuredSections); - } else { - const documents = resolvedFiles.map((filePath) => { - const source = sources.get(filePath.replace(/\\/g, "/")); - if (!source) throw new Error(`Unable to snapshot ${filePath}`); - return { - filePath, - frontmatter: safeMatter(source.content, filePath).data, - }; - }); - sections = this.withApiReferenceSection(discoverSections(documents)); - } - } - const pages = await buildRealPageCatalog(resolvedFiles, (file) => { - const source = sources.get(file.replace(/\\/g, "/")); - if (!source) throw new Error(`Unable to snapshot ${file}`); - return this.parseMDXFile(file, source, sections); - }); - return { files: resolvedFiles, pages, sources, sections }; + return this.mdxPassBuilder.capture(files, seededSources, refreshSections); } private async buildRealPagesMeta(): Promise { @@ -1260,76 +1251,12 @@ export class MDXToNextJSGenerator { declaredSlugs?: Set, ) { const resolvedPages = pages ?? (await this.buildAllPagesMeta()); - const occupiedSlugs = new Set(resolvedPages.map((page) => page.slug)); - const resolvedDeclaredSlugs = new Set(occupiedSlugs); - for (const slug of declaredSlugs ?? []) resolvedDeclaredSlugs.add(slug); - const redirects = new Map(); - - if (this.sectionsConfig && this.sectionsConfig.length > 0) { - for (const section of this.sectionsConfig) { - if (section.slug === "") continue; - - // Check if a page already exists at the section root - const hasIndex = resolvedDeclaredSlugs.has(section.slug); - if (hasIndex) continue; - - // Find the first page in this section - const sectionPages = resolvedPages - .filter((p) => p.section === section.slug) - .sort((a, b) => { - if (a.categoryOrder !== b.categoryOrder) - return a.categoryOrder - b.categoryOrder; - return a.order - b.order; - }); - - if (sectionPages.length === 0) continue; - - const firstPage = sectionPages[0]; - redirects.set(section.slug, firstPage.slug); - } - } - - const previousSlugs = this.generatedRouteManager.sectionIndexSlugs(); - const touchedSlugs = new Set([...previousSlugs, ...redirects.keys()]); - const previousFiles = new Map(); - for (const slug of touchedSlugs) { - previousFiles.set( - slug, - await this.readGeneratedFile( - this.outputPath("app", "(site)", slug, "page.tsx"), - ), - ); - } - - try { - for (const [slug, target] of redirects) { - await this.writeSectionIndexRedirect(slug, target); - } - await this.cleanupStaleSectionIndexPages( - new Set(redirects.keys()), - occupiedSlugs, - ); - } catch (error) { - const rollbackErrors: unknown[] = []; - this.generatedRouteManager.replaceSectionIndexSlugs(previousSlugs); - for (const [slug, content] of previousFiles) { - try { - await this.restoreGeneratedFile( - this.outputPath("app", "(site)", slug, "page.tsx"), - content, - ); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - } - if (rollbackErrors.length > 0) { - throw new AggregateError( - [error, ...rollbackErrors], - "Unable to generate or restore section index redirects", - ); - } - throw error; - } + return this.sectionIndexGenerator.generate( + resolvedPages, + this.sectionsConfig, + declaredSlugs, + (slug, target) => this.writeSectionIndexRedirect(slug, target), + ); } private async writeSectionIndexRedirect( @@ -1351,173 +1278,16 @@ export default function SectionIndex() { ); } - /** - * Removes section index redirects written earlier in this session whose - * section has since disappeared (e.g. the API Reference section after the - * `openapi` config is removed mid-watch). Slugs now occupied by real pages - * are preserved. Fresh processes start clean anyway - init() wipes app/ - - * so in-session tracking is enough. - */ - private async cleanupStaleSectionIndexPages( - nextSlugs: Set, - occupiedSlugs: Set, - ): Promise { - return this.generatedRouteManager.cleanupStaleSectionIndexPages( - nextSlugs, - occupiedSlugs, - (dir, stopDir) => this.removeEmptyDirsUpTo(dir, stopDir), - ); - } - - /** Best-effort removal of now-empty directories up to (not incl.) stopDir. */ - private async removeEmptyDirsUpTo( - dir: string, - stopDir: string, - ): Promise { - return this.generatedRouteManager.removeEmptyDirsUpTo(dir, stopDir); - } - - private generatedFileSegments(filePath: string): string[] { - return path - .relative(fs.realpathSync(this.outputDir), filePath) - .split(path.sep) - .filter(Boolean); - } - - private async readGeneratedFile(filePath: string): Promise { - return readOutputFileIfPresent( - this.outputDir, - ...this.generatedFileSegments(filePath), - ); - } - - private async restoreGeneratedFile( - filePath: string, - content: string | null, - ): Promise { - const target = this.outputPath(...this.generatedFileSegments(filePath)); - if (content === null) { - await fs.remove(target); - } else { - await writeFileAtomic(target, content); - } - } - - private async commitRenderedPage( - pagePath: string, - rendered: RenderedPage, - ): Promise { - if (rendered.rssRoute.action === "preserve") { - const previousPage = await this.readGeneratedFile(pagePath); - await writeFileAtomic(pagePath, rendered.pageContent); - return { - rollback: () => this.restoreGeneratedFile(pagePath, previousPage), - }; - } - - const rssDir = resolveOutputPath(path.dirname(pagePath), "rss.xml"); - const rssPath = resolveOutputPath( - path.dirname(pagePath), - "rss.xml", - "route.ts", - ); - const [previousPage, previousRss] = await Promise.all([ - this.readGeneratedFile(pagePath), - this.readGeneratedFile(rssPath), - ]); - let pageChanged = false; - let rssChanged = false; - - try { - if (rendered.rssRoute.action === "write") { - await writeFileAtomic(rssPath, rendered.rssRoute.content); - rssChanged = true; - await writeFileAtomic(pagePath, rendered.pageContent); - pageChanged = true; - } else { - await writeFileAtomic(pagePath, rendered.pageContent); - pageChanged = true; - await fs.remove(rssPath); - rssChanged = true; - await this.removeEmptyDirsUpTo(rssDir, path.dirname(pagePath)); - } - } catch (error) { - const rollbackErrors: unknown[] = []; - if (rssChanged) { - try { - await this.restoreGeneratedFile(rssPath, previousRss); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - } - if (pageChanged) { - try { - await this.restoreGeneratedFile(pagePath, previousPage); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - } - if (rollbackErrors.length > 0) { - throw new AggregateError( - [error, ...rollbackErrors], - `Unable to publish or restore generated page ${pagePath}`, - ); - } - throw error; - } - - return { - rollback: async () => { - const rollbackErrors: unknown[] = []; - try { - await this.restoreGeneratedFile(rssPath, previousRss); - } catch (error) { - rollbackErrors.push(error); - } - try { - await this.restoreGeneratedFile(pagePath, previousPage); - } catch (error) { - rollbackErrors.push(error); - } - if (rollbackErrors.length > 0) { - throw new AggregateError( - rollbackErrors, - `Unable to restore generated page ${pagePath}`, - ); - } - }, - }; - } - async generatePageFromMDX( mdxFile: MDXFile, options?: { apiOperation?: OperationDescriptor }, - ) { - const rendered = renderMdxPage(mdxFile, options); - const pagePath = resolveOutputPath( - this.outputDir, - "app", - "(site)", - mdxFile.slug, - "page.tsx", - ); - await fs.ensureDir(path.dirname(pagePath)); - return this.commitRenderedPage(pagePath, rendered); + ): Promise { + return this.generatedPagePublisher.generatePageFromMdx(mdxFile, options); } /** Parses the configured OpenAPI spec(s) into the shared registry. */ private async loadOpenApiRegistry(): Promise { - if (this.openApiSpecs.length === 0) return; - this.apiRegistry = ( - await this.loadStableOpenApiRegistry(this.openApiSpecs) - ).registry; - if (!this.apiRegistry.isEmpty) { - console.log( - chalk.blue( - `📘 Loaded ${this.apiRegistry.all.length} API endpoint(s) from ${this.openApiSpecs.length} spec(s)`, - ), - ); - } + return this.openApiRefreshCoordinator.loadInitialRegistry(); } /** @@ -1591,133 +1361,9 @@ export default function SectionIndex() { ); } - private async openApiSourceState(registry: OpenApiRegistry): Promise { - return ( - await Promise.all( - registry.sourceFiles.map(async (sourcePath) => { - return `${sourcePath}:${await this.sourceFs.pathState(sourcePath, true)}`; - }), - ) - ).join("\n"); - } - - private async loadStableOpenApiRegistry( - specs: NormalizedOpenApiSpec[], - ): Promise<{ registry: OpenApiRegistry; sourceState: string }> { - for (let attempt = 0; attempt < 3; attempt += 1) { - const registry = new OpenApiRegistry(); - await registry.load(specs, this.rootDir, this.apiBaseSlug); - const current = await this.openApiSourceState(registry); - if (registry.sourceFingerprint === current) { - return { registry, sourceState: current }; - } - } - throw new Error("OpenAPI sources changed repeatedly while being loaded"); - } - - private async applyStableOpenApiRefresh( - specs: NormalizedOpenApiSpec[], - ): Promise { - let candidate = await this.loadStableOpenApiRegistry(specs); - await this.applyOpenApiRefresh(candidate.registry, specs); - - // Once the new watcher is ready, compare its source against the exact - // version rendered above. A change in the retargeting window is replayed - // explicitly because ignoreInitial watchers cannot report it. - for (let attempt = 0; attempt < 3; attempt += 1) { - if ( - (await this.openApiSourceState(candidate.registry)) === - candidate.sourceState - ) { - return; - } - candidate = await this.loadStableOpenApiRegistry(specs); - await this.applyOpenApiRefresh(candidate.registry, specs); - } - throw new Error("OpenAPI sources changed repeatedly while being generated"); - } - - /** - * Reparses the spec(s) and rewrites everything derived from them: the - * endpoint pages and allowlist, the sections (the "API Reference" section - * appears and disappears with the registry), and the site aggregates. - */ - private async applyOpenApiRefresh( - nextRegistry: OpenApiRegistry, - nextSpecs: NormalizedOpenApiSpec[], - ): Promise { - const previousRegistry = this.apiRegistry; - const previousSpecs = this.openApiSpecs; - const previousSections = this.sectionsConfig; - const previousRoutes = this.artifacts.routesFor("openapi"); - const candidateRoutes = new Map(); - let watcherSyncAttempted = false; - - try { - this.apiRegistry = nextRegistry; - this.openApiSpecs = nextSpecs; - this.sectionsConfig = await this.resolveSections(); - await this.writeApiPages(undefined, { writtenRoutes: candidateRoutes }); - await this.refreshSiteAggregates(); - watcherSyncAttempted = true; - await this.syncOpenApiSpecWatcher(); - } catch (error) { - this.apiRegistry = previousRegistry; - this.openApiSpecs = previousSpecs; - this.sectionsConfig = previousSections; - const rollbackErrors: unknown[] = []; - - if (watcherSyncAttempted) { - try { - await this.syncOpenApiSpecWatcher(); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - } - const previousSlugs = new Set(previousRoutes.map((route) => route.slug)); - const occupiedMdxSlugs = new Set( - [...this.successfulMdxPages.values()].map((page) => page.slug), - ); - for (const slug of new Set(candidateRoutes.values())) { - if (previousSlugs.has(slug) || occupiedMdxSlugs.has(slug)) continue; - try { - await this.removeOwnedRoute(slug); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - } - try { - await this.writeApiPages(undefined, { - additionalPreviousRoutes: [...candidateRoutes].map( - ([source, slug]) => ({ kind: "openapi", source, slug }), - ), - }); - await this.refreshSiteAggregates(); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - - if (rollbackErrors.length > 0) { - throw new AggregateError( - [error, ...rollbackErrors], - "Unable to apply or restore the OpenAPI reference", - ); - } - throw error; - } - } - /** Reparses the spec(s) and regenerates the API reference on a watch event. */ async handleOpenApiChange() { - console.log( - chalk.cyan("📘 OpenAPI spec changed - regenerating API reference"), - ); - try { - await this.applyStableOpenApiRefresh(this.openApiSpecs); - console.log(chalk.green("✅ API reference updated")); - } catch (error) { - console.error(chalk.red("❌ Error updating API reference:"), error); - } + return this.openApiRefreshCoordinator.handleOpenApiChange(); } /** @@ -1729,54 +1375,7 @@ export default function SectionIndex() { * half-written editor save) keeps the current configuration. */ async handleDoccupineConfigChange() { - const configPath = path.join(this.rootDir, this.doccupineConfigFile); - let config: DoccupineConfig; - try { - const { data } = await this.sourceFs.readProjectSourceFile( - configPath, - "Doccupine configuration source", - ); - config = validateConfig(JSON.parse(data.toString("utf8")), this.rootDir); - } catch (error) { - console.warn( - chalk.yellow( - "⚠️ doccupine.json is missing or invalid - keeping the current configuration", - ), - error instanceof Error ? error.message : error, - ); - return; - } - - if ( - (config.watchDir && - path.resolve(this.rootDir, config.watchDir) !== this.watchDir) || - (config.outputDir && - path.resolve(this.rootDir, config.outputDir) !== this.outputDir) - ) { - console.log( - chalk.yellow( - "⚠️ watchDir/outputDir changes in doccupine.json need a restart to apply", - ), - ); - } - - const nextSpecs = normalizeOpenApiConfig(config.openapi); - if (JSON.stringify(nextSpecs) === JSON.stringify(this.openApiSpecs)) { - return; - } - - console.log( - chalk.cyan("📘 OpenAPI configuration changed - updating API reference"), - ); - try { - // Validate the complete candidate before changing the active spec list or - // its watcher. A half-written/invalid replacement keeps both the current - // generated reference and its live watcher intact. - await this.applyStableOpenApiRefresh(nextSpecs); - console.log(chalk.green("✅ API reference updated")); - } catch (error) { - console.error(chalk.red("❌ Error updating API reference:"), error); - } + return this.openApiRefreshCoordinator.handleConfigChange(); } async updatePagesIndex(pages?: readonly PageMeta[]) { @@ -1815,11 +1414,7 @@ export default function SectionIndex() { ); } } - const rendered = renderHomepage(indexMDX, apiOperation); - - const homePath = this.outputPath("app", "(site)", "page.tsx"); - await fs.ensureDir(path.dirname(homePath)); - await this.commitRenderedPage(homePath, rendered); + await this.generatedPagePublisher.updateHomepage(indexMDX, apiOperation); } async updateSectionIndex( @@ -1827,23 +1422,13 @@ export default function SectionIndex() { frontmatter: Record, mdxContent: string, sourcePath?: string, - ) { - const rendered = renderSectionPage( + ): Promise { + return this.generatedPagePublisher.updateSectionIndex( sectionSlug, frontmatter, mdxContent, sourcePath, ); - - const pagePath = resolveOutputPath( - this.outputDir, - "app", - "(site)", - sectionSlug, - "page.tsx", - ); - await fs.ensureDir(path.dirname(pagePath)); - return this.commitRenderedPage(pagePath, rendered); } async updateRootLayout(pages?: PageMeta[]) { diff --git a/src/mdx-to-nextjs-generator.watching.test.ts b/src/mdx-to-nextjs-generator.watching.test.ts new file mode 100644 index 0000000..2df9dc6 --- /dev/null +++ b/src/mdx-to-nextjs-generator.watching.test.ts @@ -0,0 +1,311 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { MDXToNextJSGenerator } from "./mdx-to-nextjs-generator.js"; + +import { fixture, waitUntil } from "./test-utils/generator-fixture.js"; + +describe.sequential("MDXToNextJSGenerator watching", () => { + it("reconciles MDX, public, and OpenAPI changes made during init", async () => { + const { root, watchDir, outputDir } = await fixture(); + const mdxPath = path.join(watchDir, "guide.mdx"); + const publicPath = path.join(root, "public", "asset.txt"); + const specPath = path.join(root, "openapi.json"); + const writeSpec = (summary: string) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + summary, + tags: ["users"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + await fs.outputFile( + mdxPath, + "---\ntitle: Old\nsection: Guides\n---\nOld body\n", + ); + await fs.outputFile(publicPath, "old asset\n"); + await writeSpec("Old API summary"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: "openapi.json" }], + root, + ); + const processAll = generator.processAllMDXFiles.bind(generator); + vi.spyOn(generator, "processAllMDXFiles").mockImplementationOnce( + async () => { + await processAll(); + await fs.writeFile( + mdxPath, + "---\ntitle: New\nsection: Tutorials\n---\nNew body\n", + ); + await fs.writeFile(publicPath, "new asset\n"); + await writeSpec("New API summary"); + await fs.writeJson(path.join(root, "doccupine.json"), { + watchDir: "docs", + outputDir: "site", + port: "4000", + openapi: [{ name: "Test", file: "openapi.json" }], + }); + }, + ); + + await generator.init(); + await generator.startWatching(); + + expect( + await fs.readFile( + path.join(outputDir, "app", "(site)", "tutorials", "guide", "page.tsx"), + "utf8", + ), + ).toContain("New body"); + expect( + await fs.readFile(path.join(outputDir, "public", "asset.txt"), "utf8"), + ).toBe("new asset\n"); + expect( + await fs.readFile( + path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ), + "utf8", + ), + ).toContain("New API summary"); + await generator.stop(); + }); + + it("closes every watcher when watcher startup fails", async () => { + const { root, watchDir, outputDir } = await fixture(); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + type Watcher = { close(): Promise }; + type WatchCoordinatorInternals = { + waitForWatcherReady(watcher: Watcher): Promise; + watcher: Watcher | null; + configWatcher: Watcher | null; + fontWatcher: Watcher | null; + analyticsWatcher: Watcher | null; + openApiWatcher: Watcher | null; + doccupineConfigWatcher: Watcher | null; + publicWatcher: Watcher | null; + rootDirWatcher: Watcher | null; + }; + type GeneratorInternals = { + watchCoordinator: WatchCoordinatorInternals; + }; + const coordinator = (generator as unknown as GeneratorInternals) + .watchCoordinator; + const closeSpies: Array> = []; + let readinessCalls = 0; + vi.spyOn(coordinator, "waitForWatcherReady").mockImplementation( + async (watcher) => { + readinessCalls += 1; + const close = vi.spyOn(watcher, "close"); + if (readinessCalls === 1) { + close.mockRejectedValueOnce(new Error("Injected close failure")); + } + closeSpies.push(close); + if (readinessCalls === 6) { + throw new Error("Injected watcher readiness failure"); + } + }, + ); + + await expect(generator.startWatching()).rejects.toThrow( + "Injected watcher readiness failure", + ); + + expect(readinessCalls).toBe(6); + expect(closeSpies[0]).toHaveBeenCalledTimes(2); + for (const close of closeSpies.slice(1)) { + expect(close).toHaveBeenCalledOnce(); + } + expect(coordinator.watcher).toBeNull(); + expect(coordinator.configWatcher).toBeNull(); + expect(coordinator.fontWatcher).toBeNull(); + expect(coordinator.analyticsWatcher).toBeNull(); + expect(coordinator.openApiWatcher).toBeNull(); + expect(coordinator.doccupineConfigWatcher).toBeNull(); + expect(coordinator.publicWatcher).toBeNull(); + expect(coordinator.rootDirWatcher).toBeNull(); + }); + + it("retries an OpenAPI watcher close that fails during retargeting", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + await fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + }); + await fs.writeFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const specs = [{ name: "Test", file: specPath }]; + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + specs, + root, + ); + await generator.init(); + await generator.startWatching(); + type Watcher = { close(): Promise }; + type WatchCoordinatorInternals = { + openApiWatcher: Watcher | null; + syncOpenApiSpecWatcher( + specs: Array<{ name: string; file: string }>, + sourceFiles: string[], + ): Promise; + }; + type GeneratorInternals = { + watchCoordinator: WatchCoordinatorInternals; + }; + const coordinator = (generator as unknown as GeneratorInternals) + .watchCoordinator; + const watcher = coordinator.openApiWatcher; + if (!watcher) throw new Error("Expected an OpenAPI watcher"); + const close = vi + .spyOn(watcher, "close") + .mockRejectedValueOnce(new Error("Injected retarget close failure")); + + await expect( + coordinator.syncOpenApiSpecWatcher(specs, [specPath]), + ).rejects.toThrow("Injected retarget close failure"); + await generator.stop(); + + expect(close).toHaveBeenCalledTimes(2); + expect(coordinator.openApiWatcher).toBeNull(); + }); + + it("reconciles source changes that predate watcher readiness", async () => { + const { root, watchDir, outputDir } = await fixture(); + const mdxPath = path.join(watchDir, "guide.mdx"); + const publicPath = path.join(root, "public", "asset.txt"); + await fs.outputFile(mdxPath, "---\ntitle: Old\n---\nOld body\n"); + await fs.outputFile(publicPath, "old asset\n"); + + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + + await fs.writeFile(mdxPath, "---\ntitle: New\n---\nNew body\n"); + await fs.remove(publicPath); + await generator.startWatching(); + + const page = await fs.readFile( + path.join(outputDir, "app", "(site)", "guide", "page.tsx"), + "utf8", + ); + expect(page).toContain("New body"); + expect( + await fs.pathExists(path.join(outputDir, "public", "asset.txt")), + ).toBe(false); + await generator.stop(); + }); + + it("watches local OpenAPI reference files as generation sources", async () => { + const { root, watchDir, outputDir } = await fixture(); + const specPath = path.join(root, "openapi.json"); + const schemaPath = path.join(root, "schemas", "user.json"); + const writeSchema = (property: string) => + fs.outputJson(schemaPath, { + type: "object", + properties: { [property]: { type: "string" } }, + }); + const writeSpec = (schema: Record) => + fs.writeJson(specPath, { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "listUsers", + tags: ["users"], + responses: { + "200": { + description: "OK", + content: { "application/json": { schema } }, + }, + }, + }, + }, + }, + }); + await writeSchema("OLD_FIELD"); + await writeSpec({ + type: "object", + properties: { INITIAL_FIELD: { type: "string" } }, + }); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + const generator = new MDXToNextJSGenerator( + watchDir, + outputDir, + [{ name: "Test", file: specPath }], + root, + ); + await generator.init(); + await generator.startWatching(); + const pagePath = path.join( + outputDir, + "app", + "(site)", + "api-reference", + "users", + "listusers", + "page.tsx", + ); + expect(await fs.readFile(pagePath, "utf8")).toContain("INITIAL_FIELD"); + + await writeSpec({ $ref: "./schemas/user.json" }); + + await waitUntil(async () => { + try { + return (await fs.readFile(pagePath, "utf8")).includes("OLD_FIELD"); + } catch { + return false; + } + }); + + await writeSchema("NEW_FIELD"); + + await waitUntil(async () => { + try { + return (await fs.readFile(pagePath, "utf8")).includes("NEW_FIELD"); + } catch { + return false; + } + }); + await generator.stop(); + }); + + it("does not replay unchanged sources when watchers become ready", async () => { + const { root, watchDir, outputDir } = await fixture(); + await fs.outputFile(path.join(watchDir, "index.mdx"), "# Home\n"); + await fs.writeJson(path.join(root, "config.json"), { name: "Docs" }); + await fs.outputFile(path.join(root, "public", "asset.txt"), "asset\n"); + const generator = new MDXToNextJSGenerator(watchDir, outputDir, [], root); + await generator.init(); + const processAll = vi.spyOn(generator, "processAllMDXFiles"); + const configChange = vi.spyOn(generator, "handleConfigFileChange"); + const configDelete = vi.spyOn(generator, "handleConfigFileDelete"); + const publicCopy = vi.spyOn(generator, "copyPublicFiles"); + + await generator.startWatching(); + + expect(processAll).not.toHaveBeenCalled(); + expect(configChange).not.toHaveBeenCalled(); + expect(configDelete).not.toHaveBeenCalled(); + expect(publicCopy).not.toHaveBeenCalled(); + await generator.stop(); + }); +}); diff --git a/src/test-utils/generator-fixture.ts b/src/test-utils/generator-fixture.ts new file mode 100644 index 0000000..9a443a3 --- /dev/null +++ b/src/test-utils/generator-fixture.ts @@ -0,0 +1,34 @@ +import fs from "fs-extra"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, vi } from "vitest"; + +const temporaryDirectories: string[] = []; + +export async function fixture(): Promise<{ + root: string; + watchDir: string; + outputDir: string; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "doccupine-generator-")); + temporaryDirectories.push(root); + const watchDir = path.join(root, "docs"); + const outputDir = path.join(root, "site"); + await fs.ensureDir(watchDir); + return { root, watchDir, outputDir }; +} + +export async function waitUntil(check: () => Promise): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (await check()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for watcher output"); +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + temporaryDirectories.splice(0).map((dir) => fs.remove(dir)), + ); +}); diff --git a/tsconfig.json b/tsconfig.json index d4ac266..d7ef16b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,10 @@ "rootDir": "./src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/test-utils/**/*.ts" + ] }