// Every doc must compile as MDX (a stray `{` in prose compiles but breaks the // page at runtime), every embedded must point at a real recipe, // and every link to another doc must resolve. Docs live in chapter folders. import { describe, it, expect } from "vitest"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { evaluate } from "@mdx-js/mdx"; import * as runtime from "react/jsx-runtime"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import remarkFrontmatter from "remark-frontmatter"; import fm from "front-matter"; import rehypeShellPlugin from "./rehypeShellPlugin"; import { Recipe } from "../features/CommandBuilder/commandBuilderTypes"; import { tokensInUse } from "../features/CommandBuilder/commandAssembly"; const DATA_DIR = join(__dirname, "../../content"); const DOCS_DIR = join(DATA_DIR, "docs"); const chapters = readdirSync(DOCS_DIR).filter((entry) => statSync(join(DOCS_DIR, entry)).isDirectory(), ); const docs = chapters.flatMap((chapter) => readdirSync(join(DOCS_DIR, chapter)) .filter((f) => f.endsWith(".mdx")) .map((f) => ({ file: `${chapter}/${f}`, text: readFileSync(join(DOCS_DIR, chapter, f), "utf8"), })), ); describe("docs layout", () => { it("keeps every doc in a chapter folder (the app ignores the rest)", () => { const loose = readdirSync(DOCS_DIR).filter((f) => f.endsWith(".mdx")); expect(loose, "move these into a chapter folder").toEqual([]); }); it.each(chapters)("titles chapter %s in its chapter.json", (chapter) => { const path = join(DOCS_DIR, chapter, "chapter.json"); expect(existsSync(path), `${chapter}/chapter.json not found`).toBe(true); const { title } = JSON.parse(readFileSync(path, "utf8")); expect(typeof title === "string" && title !== "", "needs a title").toBe( true, ); }); }); /** Props of every tag in a doc. */ function recipeBuilders(text: string): Record[] { return [...text.matchAll(/]*?)\/>/gs)].map((m) => Object.fromEntries( [...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]]), ), ); } // Stand-ins for the app's MDX components, which need the terminal and store. const Stub = ({ children }: { children?: React.ReactNode }) => createElement("div", null, children); const components = { CodeBlock: Stub, InstallBlock: Stub, PromptDisplay: Stub, RecipeBuilder: Stub, Link: Stub, }; /** Paths (relative to content/docs) of every /docs/ link in a doc. */ function docLinks(text: string): string[] { return [...text.matchAll(/\]\(\/docs\/([^)#\s]+)/g)].map((m) => m[1]); } // A doc's platform, from its filename: "x.windows.mdx" -> "windows". // "unix" docs serve macOS and Linux, so they may link to "mac" docs. function platformOf(path: string): string | undefined { return path.match(/\.(windows|unix|mac)\.mdx$/)?.[1]; } const COMPATIBLE: Record = { windows: ["windows"], unix: ["unix", "mac"], mac: ["unix", "mac"], }; // The basics are a sequence: each tutorial should end by pointing somewhere. const SEQUENCE_CHAPTER = "1-cli-basics/"; describe.each(docs)("doc $file", ({ file, text }) => { it("renders as MDX", async () => { // Rendering, not just compiling: prose like `{{duration}}` compiles fine // (it's an object literal) and only fails when evaluated. const { body } = fm(text); const { default: Content } = await evaluate(body, { ...runtime, remarkPlugins: [[remarkFrontmatter, ["yaml", "toml"]]], rehypePlugins: [rehypeShellPlugin], useMDXComponents: () => components, }); expect(renderToStaticMarkup(createElement(Content))).not.toBe(""); }); it("links only to docs that exist", () => { for (const link of docLinks(text)) { expect(existsSync(join(DOCS_DIR, link)), `link to /docs/${link}`).toBe( true, ); } }); it("links only to docs for the same platform", () => { const own = platformOf(file); if (!own) return; // platform-neutral docs may link anywhere for (const link of docLinks(text)) { const target = platformOf(link); if (!target) continue; expect( COMPATIBLE[own], `${file} (${own}) links to /docs/${link} (${target})`, ).toContain(target); } }); it.skipIf(!file.startsWith(SEQUENCE_CHAPTER))( "ends by linking to a next step", () => { // The last paragraph should contain a link to another doc. const lastParagraph = text.trimEnd().split(/\n\s*\n/).pop() ?? ""; const next = docLinks(lastParagraph).filter((l) => !file.endsWith(l)); expect(next, "add a 'Next up' link at the end").not.toEqual([]); }, ); it("embeds only recipes that exist, capturing into tokens they use", () => { const builders = recipeBuilders(text); const pageTokens = new Set(); for (const { src } of builders) { const path = join(DATA_DIR, "recipes", src); expect(existsSync(path), `recipe ${src} not found`).toBe(true); const recipe = JSON.parse(readFileSync(path, "utf8")) as Recipe; tokensInUse(recipe).forEach((t) => pageTokens.add(t)); } for (const { captureAs } of builders) { if (captureAs) { expect( [...pageTokens], `captureAs="${captureAs}" isn't used by any recipe on the page`, ).toContain(captureAs); } } }); });