From 954a5eeb9cc6a25b34e4f79b641d6fac731cbc6c Mon Sep 17 00:00:00 2001 From: Luan Gjokaj Date: Fri, 31 Jul 2026 04:24:50 +0200 Subject: [PATCH] refactor: split generator facade into focused services Extract page publication, section redirects, MDX pass construction, and OpenAPI refresh coordination while preserving the public facade. Split the generator regression suite by behavior and share fixture cleanup without emitting test utilities in production builds. --- src/generator/generated-page-publisher.ts | 201 + src/generator/mdx-pass-builder.ts | 92 + src/generator/openapi-refresh-coordinator.ts | 244 ++ src/generator/section-index-generator.ts | 101 + src/mdx-to-nextjs-generator.assets.test.ts | 466 +++ ...extjs-generator.mdx-reconciliation.test.ts | 1237 +++++++ src/mdx-to-nextjs-generator.openapi.test.ts | 975 +++++ ...-to-nextjs-generator.source-safety.test.ts | 273 ++ src/mdx-to-nextjs-generator.test.ts | 3254 ----------------- src/mdx-to-nextjs-generator.ts | 571 +-- src/mdx-to-nextjs-generator.watching.test.ts | 311 ++ src/test-utils/generator-fixture.ts | 34 + tsconfig.json | 7 +- 13 files changed, 4018 insertions(+), 3748 deletions(-) create mode 100644 src/generator/generated-page-publisher.ts create mode 100644 src/generator/mdx-pass-builder.ts create mode 100644 src/generator/openapi-refresh-coordinator.ts create mode 100644 src/generator/section-index-generator.ts create mode 100644 src/mdx-to-nextjs-generator.assets.test.ts create mode 100644 src/mdx-to-nextjs-generator.mdx-reconciliation.test.ts create mode 100644 src/mdx-to-nextjs-generator.openapi.test.ts create mode 100644 src/mdx-to-nextjs-generator.source-safety.test.ts delete mode 100644 src/mdx-to-nextjs-generator.test.ts create mode 100644 src/mdx-to-nextjs-generator.watching.test.ts create mode 100644 src/test-utils/generator-fixture.ts 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" + ] }