@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
138 lines • 6.63 kB
JavaScript
// The guides, as files an agent can read without leaving its editor.
//
// how_to answers are short and opinionated, and they are copies — of the
// guides at rsc-kit.dev, by hand, which is how a recipe once said <Form> worked
// without javascript when it did not yet. The guides are the source. This
// bundles them into the package at build time so read_guide answers with the
// same text the site shows, and a recipe can point at the full version instead
// of restating it.
//
// MDX to markdown is three edits: the frontmatter becomes a heading, the
// component imports go, and <CodeFromFile> becomes the code it names — cut the
// same way the site cuts it, region and all.
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
const COMPONENT_IMPORT = /^import .* from ["']@\/components\/.*["'];?\n/gm;
const CODE_FROM_FILE = /<CodeFromFile\s+([^>]*?)\/>/g;
function attribute(attrs, name) {
return new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1];
}
function frontmatterField(block, name) {
const match = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(block);
return (match?.[1] ?? '').trim().replace(/^["'](.*)["']$/, '$1');
}
/** The lines between `#region <name>` and its `#endregion`, dedented. */
function region(source, name, file) {
const lines = source.split('\n');
const start = lines.findIndex((line) => new RegExp(`#region\\s+${name}\\b`).test(line));
if (start === -1)
throw new Error(`No region "${name}" in ${file}`);
const end = lines.findIndex((line, i) => i > start && /#endregion\b/.test(line));
if (end === -1)
throw new Error(`Region "${name}" in ${file} is never closed`);
const body = lines.slice(start + 1, end).filter((line) => !/#(region|endregion)\b/.test(line));
const indent = Math.min(...body.filter((l) => l.trim()).map((l) => /^\s*/.exec(l)[0].length));
return body.map((line) => line.slice(indent)).join('\n');
}
/** One guide's MDX as markdown, with the samples it names inlined. */
export function toMarkdown(mdx, repoRoot) {
const fm = FRONTMATTER.exec(mdx);
const title = fm ? frontmatterField(fm[1], 'title') : '';
const description = fm ? frontmatterField(fm[1], 'description') : '';
let body = mdx.replace(FRONTMATTER, '').replace(COMPONENT_IMPORT, '');
body = body.replace(CODE_FROM_FILE, (_, attrs) => {
const file = attribute(attrs, 'file');
const source = readFileSync(join(repoRoot, file), 'utf-8');
const name = attribute(attrs, 'region');
const code = name ? region(source, name, file) : source.trimEnd();
const lang = attribute(attrs, 'lang') ?? extname(file).slice(1);
const heading = attribute(attrs, 'title') ?? file;
return `\`\`\`${lang} title="${heading}"\n${code}\n\`\`\``;
});
return {
entry: { title, description },
body: `# ${title}\n\n${description ? `> ${description}\n\n` : ''}${body.trim()}\n`,
};
}
/**
* Every page under each of `from`, written as markdown into `into`, with an
* index. The top-level pages - installation, coming from Next - and the
* guides land in one flat list, because a slug is what an agent asks for and
* which directory the site keeps it in is not its concern.
*/
export function bundleGuides(from, into, repoRoot) {
rmSync(into, { recursive: true, force: true });
mkdirSync(into, { recursive: true });
const index = [];
for (const dir of Array.isArray(from) ? from : [from]) {
for (const file of readdirSync(dir).filter((f) => f.endsWith('.mdx')).sort()) {
const slug = basename(file, '.mdx');
const { entry, body } = toMarkdown(readFileSync(join(dir, file), 'utf-8'), repoRoot);
writeFileSync(join(into, `${slug}.md`), body);
index.push({ slug, ...entry });
}
}
index.sort((a, b) => a.slug.localeCompare(b.slug));
writeFileSync(join(into, 'index.json'), JSON.stringify(index, null, 2) + '\n');
return index;
}
/** Where the bundled guides are, beside dist — or nowhere, before a build. */
export function guidesDir() {
const dir = new URL('../guides/', import.meta.url).pathname;
return existsSync(join(dir, 'index.json')) ? dir : null;
}
export function listGuides() {
const dir = guidesDir();
if (!dir)
return 'No guides are bundled in this install. They are at https://docs.rsc-kit.dev.';
const index = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf-8'));
const width = Math.max(...index.map((g) => g.slug.length));
return [
'The guides, as published. Read one with read_guide({ slug }).',
'',
...index.map((g) => `${g.slug.padEnd(width)} ${g.description || g.title}`),
].join('\n');
}
export function readGuide(slug) {
const dir = guidesDir();
if (!dir)
return `No guides are bundled in this install. This one is at https://docs.rsc-kit.dev/guides/${slug}.`;
const file = join(dir, `${basename(slug)}.md`);
if (!existsSync(file))
return `No guide called "${slug}". list_guides has the names.`;
return readFileSync(file, 'utf-8');
}
/**
* Lines matching a phrase across every bundled guide, with the guide and a
* little context. A grep, deliberately - the guides are 250 KB and an agent
* asking "where is fieldErrors mentioned" wants the lines, not a ranking.
*/
export function searchGuides(phrase, limit = 40) {
const dir = guidesDir();
if (!dir)
return 'No guides are bundled in this install. Search https://docs.rsc-kit.dev instead.';
const needle = phrase.trim().toLowerCase();
if (!needle)
return 'Give a word or phrase to search for.';
const index = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf-8'));
const hits = [];
for (const { slug } of index) {
const lines = readFileSync(join(dir, `${slug}.md`), 'utf-8').split('\n');
for (let i = 0; i < lines.length && hits.length < limit; i++) {
if (!lines[i].toLowerCase().includes(needle))
continue;
hits.push(`${slug}:${i + 1} ${lines[i].trim()}`);
}
if (hits.length >= limit)
break;
}
if (hits.length === 0)
return `Nothing in the guides mentions "${phrase}".`;
return [
`${hits.length}${hits.length === limit ? '+' : ''} lines mention "${phrase}". Read a guide with read_guide({ slug }).`,
'',
...hits,
].join('\n');
}
//# sourceMappingURL=bundleGuides.js.map