import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import {
buildIndex,
extractDescription,
parseFrontmatter,
resolveIncludes,
splitIntoSections,
} from "./build-index.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const QUICKSTART_MDX = `---
title: Quickstart
description: Get started building a simple front-end app with Jazz in 10 minutes.
---
Build your first Jazz app. The jazz-tools package includes everything you need. Here is a third sentence.
## Install Jazz
Install jazz-tools to get started. Run npm install jazz-tools to install the package.
## Set Up Your Schema
Define a CoMap schema to describe your app's data structure using co.map().
`;
const COVALUES_MDX = `---
title: CoValues
---
CoValues are Jazz's collaborative values. A CoValue can be a CoMap, CoList, or CoFeed. CoValues are reactive and sync automatically.
## CoMap
Use CoMap to define collaborative key-value objects.
../../examples/snippets.ts#comap-example
## CoList
Use CoList for ordered collections. CoLists work like JavaScript arrays.
`;
const SUBSCRIPTIONS_MDX = `---
title: Subscriptions & Deep Loading
description: Learn how to subscribe to CoValues and handle loading states.
---
Set up subscriptions in your Jazz application.
## Subscription Hooks
Use useCoState to subscribe to a CoValue in React.
`;
const SNIPPETS_TS = `import { co } from "jazz-tools";
// #region comap-example
const TodoItem = co.map({
title: co.string,
done: co.boolean,
});
// #endregion comap-example
// #region another-region
const other = true;
// #endregion another-region
`;
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "build-index-test-"));
// content/docs/
await mkdir(join(tmpDir, "content", "docs", "core-concepts"), {
recursive: true,
});
// examples/ (for resolution)
await mkdir(join(tmpDir, "examples"), { recursive: true });
await writeFile(
join(tmpDir, "content", "docs", "quickstart.mdx"),
QUICKSTART_MDX,
);
await writeFile(
join(tmpDir, "content", "docs", "core-concepts", "covalues.mdx"),
COVALUES_MDX,
);
await writeFile(
join(
tmpDir,
"content",
"docs",
"core-concepts",
"subscription-and-loading.mdx",
),
SUBSCRIPTIONS_MDX,
);
await writeFile(join(tmpDir, "examples", "snippets.ts"), SNIPPETS_TS);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Unit tests — pure helpers
// ---------------------------------------------------------------------------
describe("parseFrontmatter", () => {
it("extracts title and description from frontmatter", () => {
const result = parseFrontmatter(
"---\ntitle: My Title\ndescription: My description.\n---\n\nBody text.",
);
expect(result.title).toBe("My Title");
expect(result.description).toBe("My description.");
expect(result.body).toBe("Body text.");
});
it("returns undefined description when not in frontmatter", () => {
const result = parseFrontmatter("---\ntitle: Just Title\n---\n\nBody.");
expect(result.title).toBe("Just Title");
expect(result.description).toBeUndefined();
expect(result.body).toBe("Body.");
});
it("handles missing frontmatter block", () => {
const result = parseFrontmatter("# No frontmatter here\n\nBody text.");
expect(result.title).toBeUndefined();
expect(result.description).toBeUndefined();
expect(result.body).toBe("# No frontmatter here\n\nBody text.");
});
});
describe("extractDescription", () => {
it("returns the first three sentences of plain text", () => {
const body = "First sentence. Second sentence. Third sentence. Fourth.";
expect(extractDescription(body)).toBe(
"First sentence. Second sentence. Third sentence.",
);
});
it("handles fewer than three sentences gracefully", () => {
expect(extractDescription("Only one sentence.")).toBe("Only one sentence.");
expect(extractDescription("One. Two.")).toBe("One. Two.");
});
it("skips leading fenced code blocks when extracting sentences", () => {
const body = "```ts\nconst x = 1;\n```\n\nFirst sentence. Second. Third.";
expect(extractDescription(body)).toBe("First sentence. Second. Third.");
});
it("returns empty string for empty body", () => {
expect(extractDescription("")).toBe("");
});
});
describe("resolveIncludes", () => {
it("replaces an include directive with a fenced code block", async () => {
const mdxFilePath = join(
tmpDir,
"content",
"docs",
"core-concepts",
"covalues.mdx",
);
// fileCwd mirrors what buildIndex passes: the content root, not the file's own dir
const fileCwd = join(tmpDir, "content", "docs");
const content = `
../../examples/snippets.ts#comap-example
`;
const result = await resolveIncludes(content, mdxFilePath, fileCwd);
expect(result).not.toContain(" {
const mdxFilePath = join(tmpDir, "content", "docs", "quickstart.mdx");
const fileCwd = join(tmpDir, "content", "docs");
const content = `
../../examples/snippets.ts
`;
const result = await resolveIncludes(content, mdxFilePath, fileCwd);
expect(result).not.toContain(" {
const mdxFilePath = join(
tmpDir,
"content",
"docs",
"core-concepts",
"covalues.mdx",
);
const fileCwd = join(tmpDir, "content", "docs");
const content = `
../../examples/snippets.ts#comap-example
`;
const result = await resolveIncludes(content, mdxFilePath, fileCwd);
expect(result).not.toContain(" {
const mdxFilePath = join(tmpDir, "content", "docs", "quickstart.mdx");
const content = "## Section\n\nJust plain text.";
const result = await resolveIncludes(content, mdxFilePath);
expect(result).toBe(content);
});
});
describe("splitIntoSections", () => {
it("returns one entry per ## heading", () => {
const body =
"Intro text.\n\n## Section One\n\nContent one.\n\n## Section Two\n\nContent two.";
const sections = splitIntoSections(body);
expect(sections).toHaveLength(3); // preamble + 2 sections
expect(sections[1]!.heading).toBe("Section One");
expect(sections[2]!.heading).toBe("Section Two");
});
it("treats content before first ## as a section with empty heading", () => {
const body = "Preamble text.\n\n## First Section\n\nContent.";
const sections = splitIntoSections(body);
expect(sections[0]!.heading).toBe("");
expect(sections[0]!.body).toContain("Preamble text.");
});
it("returns a single section with empty heading for pages with no ## headings", () => {
const body = "Just content, no sections.";
const sections = splitIntoSections(body);
expect(sections).toHaveLength(1);
expect(sections[0]!.heading).toBe("");
expect(sections[0]!.body).toBe("Just content, no sections.");
});
});
// ---------------------------------------------------------------------------
// Integration tests — buildIndex end-to-end
// ---------------------------------------------------------------------------
describe("buildIndex", () => {
const outputDir = () => join(tmpDir, "output");
beforeEach(() => mkdir(join(tmpDir, "output"), { recursive: true }));
it("produces docs-index.db and docs-index.txt", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const { existsSync } = await import("node:fs");
expect(existsSync(join(outputDir(), "docs-index.db"))).toBe(true);
expect(existsSync(join(outputDir(), "docs-index.txt"))).toBe(true);
});
it("pages table has correct schema", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row = db
.prepare("SELECT title, slug, description, body FROM pages LIMIT 1")
.get();
expect(row).toBeDefined();
db.close();
});
it("every MDX file produces a page row", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const rows = db.prepare("SELECT slug FROM pages ORDER BY slug").all();
const slugs = rows.map((r: any) => r.slug);
expect(slugs).toEqual(
expect.arrayContaining([
"quickstart",
"core-concepts/covalues",
"core-concepts/subscription-and-loading",
]),
);
expect(slugs).toHaveLength(3);
db.close();
});
it("slug is path relative to contentDir without .mdx extension", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row: any = db
.prepare("SELECT slug FROM pages WHERE slug = 'core-concepts/covalues'")
.get();
expect(row).toBeDefined();
db.close();
});
it("title comes from MDX frontmatter", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row: any = db
.prepare("SELECT title FROM pages WHERE slug = 'quickstart'")
.get();
expect(row.title).toBe("Quickstart");
db.close();
});
it("description comes from frontmatter when present", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row: any = db
.prepare("SELECT description FROM pages WHERE slug = 'quickstart'")
.get();
expect(row.description).toBe(
"Get started building a simple front-end app with Jazz in 10 minutes.",
);
db.close();
});
it("description falls back to first three sentences when no frontmatter description", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row: any = db
.prepare(
"SELECT description FROM pages WHERE slug = 'core-concepts/covalues'",
)
.get();
// First three sentences from the body
expect(row.description).toBe(
"CoValues are Jazz's collaborative values. A CoValue can be a CoMap, CoList, or CoFeed. CoValues are reactive and sync automatically.",
);
db.close();
});
it("resolves directives: body contains code, not include tag", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row: any = db
.prepare("SELECT body FROM pages WHERE slug = 'core-concepts/covalues'")
.get();
expect(row.body).not.toContain(" {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const row: any = db
.prepare("SELECT body FROM pages WHERE slug = 'core-concepts/covalues'")
.get();
expect(row.body).not.toContain(" {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const rows = db
.prepare(
"SELECT section_heading FROM sections_fts WHERE slug = 'quickstart' ORDER BY section_heading",
)
.all();
const headings = rows.map((r: any) => r.section_heading);
expect(headings).toContain("Install Jazz");
expect(headings).toContain("Set Up Your Schema");
db.close();
});
it("sections_fts is queryable via FTS5 MATCH", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const db = new DatabaseSync(join(outputDir(), "docs-index.db"));
const rows = db
.prepare(
"SELECT slug, section_heading FROM sections_fts WHERE sections_fts MATCH 'useCoState' ORDER BY bm25(sections_fts)",
)
.all();
expect(rows.length).toBeGreaterThan(0);
const match: any = rows[0];
expect(match.slug).toBe("core-concepts/subscription-and-loading");
db.close();
});
it("docs-index.txt contains ===PAGE:slug=== markers for all pages", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const txt = await readFile(join(outputDir(), "docs-index.txt"), "utf8");
expect(txt).toContain("===PAGE:quickstart===");
expect(txt).toContain("===PAGE:core-concepts/covalues===");
expect(txt).toContain("===PAGE:core-concepts/subscription-and-loading===");
});
it("docs-index.txt includes TITLE and DESCRIPTION lines per page", async () => {
await buildIndex({
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
});
const txt = await readFile(join(outputDir(), "docs-index.txt"), "utf8");
expect(txt).toContain("TITLE:Quickstart");
expect(txt).toContain(
"DESCRIPTION:Get started building a simple front-end app with Jazz in 10 minutes.",
);
});
it("is deterministic: running twice produces identical output", async () => {
const opts = {
contentDir: join(tmpDir, "content", "docs"),
outputDir: outputDir(),
};
await buildIndex(opts);
const txt1 = await readFile(join(outputDir(), "docs-index.txt"), "utf8");
// Remove db and txt, rebuild
await rm(join(outputDir(), "docs-index.db"));
await rm(join(outputDir(), "docs-index.txt"));
await buildIndex(opts);
const txt2 = await readFile(join(outputDir(), "docs-index.txt"), "utf8");
expect(txt1).toBe(txt2);
});
});