Files
vercel__chat/scripts/sync-resources.ts
Ben Sabic b0ab804f18 - Bundle guide markdown and a templates manifest with the chat package at resources/guides/*.md and resources/templates.json so AI agents can discover Chat SDK resources offline (#423)
- Add scripts/sync-resources.ts (run via pnpm sync-resources) that reads apps/docs/resources-edge-config.json, fetches each guide's .md version over https with a timeout and size cap, writes templates.json, and regenerates the Available resources block in skills/chat/SKILL.md
- Migrate the Slack Next.js, Discord Nuxt, and Hono code-review guides from on-site MDX to Vercel KB and register them in the resources edge-config JSON alongside the existing external guides
- Remove /docs/guides MDX content, sidebar entries, top-level Guides nav entry, getting-started cards, and the dead /guides/ branch in the sitemap route now that all guides live externally and are surfaced on /resources
- Replace the homepage Guides/Templates section and the standalone Adapters pill section with a single two-column Resources + Adapters section (icons, headings, descriptions, outline buttons, divider), and drop the URL footer from ResourceCard on the Resources page
- Update skills/chat/SKILL.md to point at resources/guides and resources/templates.json and list the available guides and templates between marker comments that sync-resources rewrites
- Add tsx to knip's ignoreBinaries so npx tsx in the new script does not fail lint

Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
Co-authored-by: dancer <josh@afterima.ge>
2026-04-27 10:20:44 +10:00

164 lines
4.6 KiB
TypeScript

import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
interface Resource {
description: string;
href: string;
title: string;
type: "guide" | "template";
}
interface ResourcesFile {
resources: Resource[];
}
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, "..");
const sourceFile = join(repoRoot, "apps", "docs", "resources-edge-config.json");
const chatPackageRoot = join(repoRoot, "packages", "chat");
const resourcesDir = join(chatPackageRoot, "resources");
const guidesDir = join(resourcesDir, "guides");
const templatesFile = join(resourcesDir, "templates.json");
const skillFile = join(repoRoot, "skills", "chat", "SKILL.md");
const RESOURCES_START = "<!-- RESOURCES:START -->";
const RESOURCES_END = "<!-- RESOURCES:END -->";
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const FETCH_TIMEOUT_MS = 30_000;
const MAX_GUIDE_BYTES = 1_000_000;
const assertHttpsUrl = (href: string): URL => {
const url = new URL(href);
if (url.protocol !== "https:") {
throw new Error(`href must use https: ${href}`);
}
return url;
};
const slugFromHref = (href: string): string => {
const url = assertHttpsUrl(href);
const last = url.pathname.split("/").filter(Boolean).pop();
if (!last) {
throw new Error(`Cannot derive slug from href: ${href}`);
}
if (!SLUG_PATTERN.test(last)) {
throw new Error(
`Unexpected slug shape (expected kebab-case a-z0-9): ${last} from ${href}`
);
}
return last;
};
const fetchGuideMarkdown = async (href: string): Promise<string> => {
assertHttpsUrl(href);
const url = `${href}.md`;
const response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: ${response.status} ${response.statusText}`
);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/markdown")) {
throw new Error(
`Unexpected content-type for ${url}: ${contentType}. Expected text/markdown.`
);
}
const body = await response.text();
if (body.length > MAX_GUIDE_BYTES) {
throw new Error(
`Guide exceeds ${MAX_GUIDE_BYTES} bytes (${body.length}): ${url}`
);
}
return body;
};
const renderSkillBlock = (
guides: Resource[],
templates: Resource[]
): string => {
const guideLines = guides
.map(
(g) =>
`- \`node_modules/chat/resources/guides/${slugFromHref(g.href)}.md\` — ${g.description}`
)
.join("\n");
const templateLines = templates
.map((t) => `- **${t.title}** — ${t.description} (${t.href})`)
.join("\n");
return [
RESOURCES_START,
"",
"### Guides",
"",
guideLines,
"",
"### Templates",
"",
"Listed in `node_modules/chat/resources/templates.json`:",
"",
templateLines,
"",
RESOURCES_END,
].join("\n");
};
const updateSkillFile = async (guides: Resource[], templates: Resource[]) => {
const existing = await readFile(skillFile, "utf8");
const startIdx = existing.indexOf(RESOURCES_START);
const endIdx = existing.indexOf(RESOURCES_END);
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
throw new Error(
`Cannot find ${RESOURCES_START}/${RESOURCES_END} markers in ${skillFile}`
);
}
const before = existing.slice(0, startIdx);
const after = existing.slice(endIdx + RESOURCES_END.length);
const next = `${before}${renderSkillBlock(guides, templates)}${after}`;
if (next !== existing) {
await writeFile(skillFile, next, "utf8");
console.log(`Updated ${skillFile}`);
}
};
const main = async () => {
const raw = await readFile(sourceFile, "utf8");
const { resources } = JSON.parse(raw) as ResourcesFile;
const guides = resources.filter((r) => r.type === "guide");
const templates = resources.filter((r) => r.type === "template");
await rm(resourcesDir, { recursive: true, force: true });
await mkdir(guidesDir, { recursive: true });
for (const guide of guides) {
const slug = slugFromHref(guide.href);
const markdown = await fetchGuideMarkdown(guide.href);
const outPath = join(guidesDir, `${slug}.md`);
await writeFile(outPath, markdown, "utf8");
console.log(`Wrote ${outPath}`);
}
const templatesPayload = {
templates: templates.map(({ title, description, href }) => ({
title,
description,
href,
})),
};
await writeFile(
templatesFile,
`${JSON.stringify(templatesPayload, null, 2)}\n`,
"utf8"
);
console.log(`Wrote ${templatesFile}`);
await updateSkillFile(guides, templates);
};
await main();