Skip to content

Commit a21efbd

Browse files
committed
refactor(showcase/shell-docs): reference pages share loadItems helper + recursive static params + guarded reads
- Extract duplicated loadItems/getAllItems into @/lib/reference-items so the /reference index page and the /reference/[...slug] page read the same tree the same way (same subdirs, same walker, same gray-matter path, same caching). - Walker is now recursive: subfolder files like components/inputs/textarea.mdx are indexed and statically generated. Previously only top-level .mdx files under components/ and hooks/ were picked up. - Wrap gray-matter and fs reads in try/catch per file: a single malformed frontmatter block no longer crashes the whole static-generation pass — we log the offending path and skip that file. - Fence-aware import stripper in the slug page so code samples containing import ... lines inside fences are not corrupted. (Duplicated inline here and in ag-ui page with a TODO(dedup) marker pointing at a future shared helper in @/lib/docs-render.) - loadReferenceItems memoizes in production (module-scope cache keyed by subdir). Dev bypasses the cache so edits show up without a server restart.
1 parent 6bd8128 commit a21efbd

3 files changed

Lines changed: 218 additions & 74 deletions

File tree

showcase/shell-docs/src/app/reference/[...slug]/page.tsx

Lines changed: 64 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,11 @@ import {
1313
Accordion,
1414
} from "@/components/mdx-components";
1515
import { SidebarNav } from "@/components/sidebar-nav";
16-
17-
const CONTENT_DIR = path.join(process.cwd(), "src/content/reference");
18-
19-
type NavItem = { slug: string; title: string; category: string };
20-
21-
function getAllItems(): NavItem[] {
22-
const items: NavItem[] = [];
23-
24-
for (const subdir of ["components", "hooks"]) {
25-
const dir = path.join(CONTENT_DIR, subdir);
26-
if (!fs.existsSync(dir)) continue;
27-
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".mdx"))) {
28-
const raw = fs.readFileSync(path.join(dir, f), "utf-8");
29-
const { data } = matter(raw);
30-
items.push({
31-
slug: `${subdir}/${f.replace(/\.mdx$/, "")}`,
32-
title: (data.title as string) || f.replace(/\.mdx$/, ""),
33-
category: subdir === "components" ? "Components" : "Hooks",
34-
});
35-
}
36-
}
37-
38-
return items;
39-
}
16+
import {
17+
REFERENCE_CONTENT_DIR,
18+
loadAllReferenceItems,
19+
referenceStaticParams,
20+
} from "@/lib/reference-items";
4021

4122
// next-mdx-remote components map
4223
const mdxComponents = {
@@ -49,18 +30,38 @@ const mdxComponents = {
4930
// Strip unknown imports — MDX import statements become no-ops in next-mdx-remote
5031
};
5132

52-
export function generateStaticParams() {
53-
const params: { slug: string[] }[] = [];
54-
55-
for (const subdir of ["components", "hooks"]) {
56-
const dir = path.join(CONTENT_DIR, subdir);
57-
if (!fs.existsSync(dir)) continue;
58-
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".mdx"))) {
59-
params.push({ slug: [subdir, f.replace(/\.mdx$/, "")] });
33+
// Strip leading `import …` lines from the top of an MDX source without
34+
// touching `import …` lines that appear inside fenced code blocks. The
35+
// previous implementation filtered any line matching /^import\s+/, which
36+
// silently mangled doc code samples like `import os` inside Python fences.
37+
// TODO(dedup): hoist into a shared helper once the ag-ui page and this
38+
// one both import it (both already have near-identical copies).
39+
function stripImportsFenceAware(source: string): string {
40+
const lines = source.split("\n");
41+
const out: string[] = [];
42+
let inFence = false;
43+
let fenceMarker = "";
44+
for (const line of lines) {
45+
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
46+
if (fenceMatch) {
47+
if (!inFence) {
48+
inFence = true;
49+
fenceMarker = fenceMatch[1];
50+
} else if (line.trim().startsWith(fenceMarker)) {
51+
inFence = false;
52+
fenceMarker = "";
53+
}
54+
out.push(line);
55+
continue;
6056
}
57+
if (!inFence && /^import\s+/.test(line)) continue;
58+
out.push(line);
6159
}
60+
return out.join("\n");
61+
}
6262

63-
return params;
63+
export function generateStaticParams() {
64+
return referenceStaticParams();
6465
}
6566

6667
export default async function ReferenceSlugPage({
@@ -70,24 +71,43 @@ export default async function ReferenceSlugPage({
7071
}) {
7172
const { slug } = await params;
7273
const slugPath = slug.join("/");
73-
const filePath = path.join(CONTENT_DIR, `${slugPath}.mdx`);
74+
const filePath = path.join(REFERENCE_CONTENT_DIR, `${slugPath}.mdx`);
7475

7576
if (!fs.existsSync(filePath)) {
7677
notFound();
7778
}
7879

79-
const raw = fs.readFileSync(filePath, "utf-8");
80-
const { content, data } = matter(raw);
80+
let raw: string;
81+
try {
82+
raw = fs.readFileSync(filePath, "utf-8");
83+
} catch (err) {
84+
console.error(`[reference] Failed to read ${filePath}:`, err);
85+
notFound();
86+
}
87+
88+
let content = "";
89+
let data: Record<string, unknown> = {};
90+
try {
91+
const parsed = matter(raw);
92+
content = parsed.content;
93+
data = parsed.data;
94+
} catch (err) {
95+
console.error(
96+
`[reference] Failed to parse frontmatter in ${filePath}:`,
97+
err,
98+
);
99+
notFound();
100+
}
81101

82-
// Strip import lines — next-mdx-remote doesn't support them
83-
const cleanedContent = content
84-
.split("\n")
85-
.filter((line) => !line.match(/^import\s+/))
86-
.join("\n");
102+
const cleanedContent = stripImportsFenceAware(content);
87103

88-
const allItems = getAllItems();
89-
const title = (data.title as string) || slug[slug.length - 1];
90-
const description = data.description as string | undefined;
104+
const allItems = loadAllReferenceItems();
105+
const title =
106+
typeof data.title === "string" && data.title.length > 0
107+
? data.title
108+
: slug[slug.length - 1];
109+
const description =
110+
typeof data.description === "string" ? data.description : undefined;
91111

92112
return (
93113
<div className="flex min-h-[calc(100vh-53px)]">

showcase/shell-docs/src/app/reference/page.tsx

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,32 @@ import Link from "next/link";
22
import fs from "fs";
33
import path from "path";
44
import matter from "gray-matter";
5-
6-
const CONTENT_DIR = path.join(process.cwd(), "src/content/reference");
7-
8-
type RefItem = { slug: string; title: string; description?: string };
9-
10-
function loadItems(subdir: string): RefItem[] {
11-
const dir = path.join(CONTENT_DIR, subdir);
12-
if (!fs.existsSync(dir)) return [];
13-
return fs
14-
.readdirSync(dir)
15-
.filter((f) => f.endsWith(".mdx"))
16-
.map((f) => {
17-
const raw = fs.readFileSync(path.join(dir, f), "utf-8");
18-
const { data } = matter(raw);
19-
return {
20-
slug: `${subdir}/${f.replace(/\.mdx$/, "")}`,
21-
title: (data.title as string) || f.replace(/\.mdx$/, ""),
22-
description: data.description as string | undefined,
23-
};
24-
});
25-
}
5+
import {
6+
REFERENCE_CONTENT_DIR,
7+
loadReferenceItems,
8+
} from "@/lib/reference-items";
269

2710
export default function ReferencePage() {
28-
const components = loadItems("components");
29-
const hooks = loadItems("hooks");
11+
const components = loadReferenceItems("components");
12+
const hooks = loadReferenceItems("hooks");
3013

31-
// Also load the index page frontmatter for the intro
32-
let intro = "";
33-
const indexPath = path.join(CONTENT_DIR, "index.mdx");
14+
// Also load the index page frontmatter for the intro. Guarded so a
15+
// malformed frontmatter block falls back to a default rather than
16+
// crashing the whole index page.
17+
let intro = "API Reference for the next-generation CopilotKit React API.";
18+
const indexPath = path.join(REFERENCE_CONTENT_DIR, "index.mdx");
3419
if (fs.existsSync(indexPath)) {
35-
const { data } = matter(fs.readFileSync(indexPath, "utf-8"));
36-
intro =
37-
(data.description as string) ||
38-
"API Reference for the next-generation CopilotKit React API.";
20+
try {
21+
const { data } = matter(fs.readFileSync(indexPath, "utf-8"));
22+
if (typeof data.description === "string" && data.description.length > 0) {
23+
intro = data.description;
24+
}
25+
} catch (err) {
26+
console.error(
27+
`[reference] Failed to parse frontmatter in ${indexPath}:`,
28+
err,
29+
);
30+
}
3931
}
4032

4133
return (
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Shared helpers for walking the `src/content/reference/` tree. Used by
2+
// both /reference (index page) and /reference/[...slug] so the two stay
3+
// in sync: same subdirs, same recursive traversal, same gray-matter
4+
// handling, same caching behavior.
5+
6+
import fs from "fs";
7+
import path from "path";
8+
import matter from "gray-matter";
9+
10+
export const REFERENCE_CONTENT_DIR = path.join(
11+
process.cwd(),
12+
"src/content/reference",
13+
);
14+
15+
// Top-level reference categories we index. Anything outside this list is
16+
// ignored (e.g. a stray snippet file at the root).
17+
export const REFERENCE_SUBDIRS = ["components", "hooks"] as const;
18+
export type ReferenceSubdir = (typeof REFERENCE_SUBDIRS)[number];
19+
20+
export type ReferenceItem = {
21+
/** subdir-relative slug, e.g. `components/chat` or `components/inputs/textarea`. */
22+
slug: string;
23+
title: string;
24+
description?: string;
25+
category: "Components" | "Hooks";
26+
};
27+
28+
function categoryFor(subdir: ReferenceSubdir): "Components" | "Hooks" {
29+
return subdir === "components" ? "Components" : "Hooks";
30+
}
31+
32+
/**
33+
* Recursively collect all `.mdx` files under `dir` and return their paths
34+
* relative to `dir` (without the `.mdx` extension). Silently skips
35+
* unreadable subdirectories so a single EACCES doesn't break the build.
36+
*/
37+
function walkMdx(dir: string, prefix: string = ""): string[] {
38+
let entries: fs.Dirent[];
39+
try {
40+
entries = fs.readdirSync(dir, { withFileTypes: true });
41+
} catch (err) {
42+
console.error(`[reference-items] Failed to read dir ${dir}:`, err);
43+
return [];
44+
}
45+
const out: string[] = [];
46+
for (const entry of entries) {
47+
if (entry.name.startsWith(".")) continue;
48+
const childAbs = path.join(dir, entry.name);
49+
const childRel = prefix ? `${prefix}/${entry.name}` : entry.name;
50+
if (entry.isDirectory()) {
51+
out.push(...walkMdx(childAbs, childRel));
52+
} else if (entry.isFile() && entry.name.endsWith(".mdx")) {
53+
out.push(childRel.replace(/\.mdx$/, ""));
54+
}
55+
}
56+
return out;
57+
}
58+
59+
/**
60+
* Load items from a single reference subdir, recursing into subfolders.
61+
* Malformed frontmatter on any file is logged and skipped — we never
62+
* crash the whole index just because one page has a bad YAML block.
63+
*/
64+
function loadSubdirItems(subdir: ReferenceSubdir): ReferenceItem[] {
65+
const dir = path.join(REFERENCE_CONTENT_DIR, subdir);
66+
if (!fs.existsSync(dir)) return [];
67+
68+
const items: ReferenceItem[] = [];
69+
for (const relSlug of walkMdx(dir)) {
70+
const filePath = path.join(dir, `${relSlug}.mdx`);
71+
let raw: string;
72+
try {
73+
raw = fs.readFileSync(filePath, "utf-8");
74+
} catch (err) {
75+
console.error(`[reference-items] Failed to read ${filePath}:`, err);
76+
continue;
77+
}
78+
let data: Record<string, unknown> = {};
79+
try {
80+
({ data } = matter(raw));
81+
} catch (err) {
82+
console.error(
83+
`[reference-items] Failed to parse frontmatter in ${filePath}:`,
84+
err,
85+
);
86+
continue;
87+
}
88+
const fallbackTitle = relSlug.split("/").pop() ?? relSlug;
89+
items.push({
90+
slug: `${subdir}/${relSlug}`,
91+
title:
92+
typeof data.title === "string" && data.title.length > 0
93+
? data.title
94+
: fallbackTitle,
95+
description:
96+
typeof data.description === "string" ? data.description : undefined,
97+
category: categoryFor(subdir),
98+
});
99+
}
100+
return items;
101+
}
102+
103+
// In-memory cache — keyed by subdir, rebuilt once per process in prod. In
104+
// dev we skip the cache so MDX edits show up without a server restart.
105+
const __itemsCache = new Map<ReferenceSubdir, ReferenceItem[]>();
106+
function isProd(): boolean {
107+
return process.env.NODE_ENV === "production";
108+
}
109+
110+
export function loadReferenceItems(subdir: ReferenceSubdir): ReferenceItem[] {
111+
if (isProd()) {
112+
const cached = __itemsCache.get(subdir);
113+
if (cached) return cached;
114+
}
115+
const items = loadSubdirItems(subdir);
116+
if (isProd()) __itemsCache.set(subdir, items);
117+
return items;
118+
}
119+
120+
export function loadAllReferenceItems(): ReferenceItem[] {
121+
return REFERENCE_SUBDIRS.flatMap((s) => loadReferenceItems(s));
122+
}
123+
124+
/**
125+
* For `generateStaticParams`: return every reference page as its Next.js
126+
* catch-all slug array. Recursive (unlike the previous one-level-only
127+
* implementation), so subfolder docs like `components/inputs/textarea`
128+
* are statically generated too.
129+
*/
130+
export function referenceStaticParams(): { slug: string[] }[] {
131+
return loadAllReferenceItems().map((item) => ({ slug: item.slug.split("/") }));
132+
}

0 commit comments

Comments
 (0)