feat(docs): doc-sections system, crdt package, MCP server, and responsive fixes

Adds a hand-authored .vue doc-sections system (intro + guide pages per package, #docs/sections map, sidebar Guide group, client-side TOC), registers @robonen/crdt, renders demos client-only, base64-encodes the server-metadata virtual, plus the MCP docs endpoint and responsive/overflow fixes across pages and tables.
This commit is contained in:
2026-06-08 15:52:03 +07:00
parent def1db8b6c
commit 09433415b6
23 changed files with 1107 additions and 35 deletions
+67 -5
View File
@@ -18,6 +18,7 @@ import type {
CategoryMeta,
ComponentMeta,
ComponentPartMeta,
DocSection,
DocsMetadata,
EmitMeta,
GuideSection,
@@ -56,6 +57,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/platform', slug: 'platform', kind: 'api', group: 'core' },
{ path: 'core/fetch', slug: 'fetch', kind: 'api', group: 'core' },
{ path: 'core/encoding', slug: 'encoding', kind: 'api', group: 'core' },
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
@@ -72,8 +74,8 @@ const PACKAGES: PackageConfig[] = [
function toKebabCase(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2')
.replaceAll(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}
@@ -375,7 +377,7 @@ function extractTypeAlias(typeAlias: TypeAliasDeclaration, sourceFilePath: strin
};
}
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited: Set<string> = new Set()): ItemMeta[] {
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited = new Set<string>()): ItemMeta[] {
const filePath = sourceFile.getFilePath();
if (visited.has(filePath)) return [];
visited.add(filePath);
@@ -656,7 +658,7 @@ function readPartOrder(indexPath: string): string[] {
if (!existsSync(indexPath)) return [];
const src = readFileSync(indexPath, 'utf-8');
const order: string[] = [];
const re = /export\s*\{\s*default\s+as\s+(\w+)\s*\}\s*from\s*['"]\.\/([\w.-]+)\.vue['"]/g;
const re = /export\s*\{\s*default\s+as\s+(\w+)\s*\}\s*from\s*['"]\.\/[\w.-]+\.vue['"]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) order.push(m[1]!);
return order;
@@ -751,7 +753,7 @@ function resolveGuideFiles(pkgDir: string, patterns: string[]): string[] {
}
function titleFromMarkdown(md: string, fallback: string): string {
const m = md.match(/^\s*#\s+(.+)$/m);
const m = md.match(/^\s*#\s+(\S.*)$/m);
return m ? m[1]!.trim() : fallback;
}
@@ -783,6 +785,65 @@ function buildGuideSections(pkgDir: string, patterns: string[], pkgDescription:
return sections;
}
// ── Hand-authored .vue doc sections ─────────────────────────────────────────────
/** Read an optional `<!-- key: value -->` directive from a doc SFC. */
function readDocDirective(src: string, key: string): string | undefined {
const m = src.match(new RegExp(`<!--\\s*${key}\\s*:\\s*([^]*?)\\s*-->`));
return m ? m[1]!.trim() : undefined;
}
function humanizeTitle(slug: string): string {
return slug
.split(/[-_]/)
.filter(Boolean)
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
/**
* Discover hand-authored documentation pages from `<pkg>/docs/*.vue`.
* - `intro.vue` becomes the package landing (isIntro, sorted first).
* - Other files are ordered by a `<!-- order: N -->` directive or a numeric
* filename prefix (`02-concepts.vue`); titles come from `<!-- title: … -->`
* or the humanized filename.
*/
function buildDocSections(pkgDir: string): DocSection[] {
const docsDir = resolve(pkgDir, 'docs');
if (!existsSync(docsDir)) return [];
const sections: DocSection[] = [];
for (const file of readdirSync(docsDir)) {
if (!file.endsWith('.vue')) continue;
const full = resolve(docsDir, file);
const src = readFileSync(full, 'utf-8');
const base = file.replace(/\.vue$/, '');
const isIntro = base === 'intro';
// Optional numeric prefix on the filename, e.g. "02-concepts" or "02.concepts".
const prefixed = base.match(/^(\d+)[-.](.+)$/);
const rawName = prefixed ? prefixed[2]! : base;
const orderDirective = readDocDirective(src, 'order');
const order = isIntro
? -1
: orderDirective !== undefined
? Number(orderDirective)
: prefixed
? Number(prefixed[1])
: 100;
const slug = isIntro ? 'introduction' : slugify(rawName);
const title = readDocDirective(src, 'title')
?? (isIntro ? 'Introduction' : humanizeTitle(rawName));
sections.push({ title, slug, order, isIntro, sourcePath: relative(ROOT, full) });
}
return sections.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
}
// ── Package Extraction ─────────────────────────────────────────────────────────
function extractPackage(config: PackageConfig): PackageMeta | null {
@@ -807,6 +868,7 @@ function extractPackage(config: PackageConfig): PackageMeta | null {
categories: [],
components: [],
sections: [],
docs: buildDocSections(pkgDir),
};
if (config.kind === 'api') {
+68 -2
View File
@@ -65,6 +65,17 @@ export default defineNuxtModule({
// Register the alias for the virtual module
nuxt.options.alias['#docs/metadata'] = resolve(nuxt.options.buildDir, 'docs-metadata');
// Expose the same metadata to Nitro so server routes (e.g. the MCP endpoint
// at `server/routes/mcp.post.ts`) can import it without re-running extraction.
nuxt.hook('nitro:config', (nitroConfig: { virtual?: Record<string, string | (() => string)> }) => {
nitroConfig.virtual ??= {};
// Base64-encode the payload so Nitro's build-time text replacements (e.g.
// `typeof window` → "undefined") can't corrupt source snippets embedded in
// the metadata JSON (demo sources, examples, type signatures).
const encoded = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64');
nitroConfig.virtual['#docs/server-metadata'] = () => `export default JSON.parse(Buffer.from(${JSON.stringify(encoded)}, 'base64').toString('utf8'))`;
});
// Add types reference
addTemplate({
filename: 'docs-metadata-types.d.ts',
@@ -89,6 +100,11 @@ declare module '#docs/metadata' {
for (const pkg of metadata.packages) {
routes.add(`/${pkg.slug}`);
// Hand-authored doc sections (any kind). The intro renders on the
// package landing, so only non-intro sections get their own route.
for (const section of pkg.docs)
if (!section.isIntro) routes.add(`/${pkg.slug}/${section.slug}`);
if (pkg.kind === 'api') {
for (const category of pkg.categories)
for (const item of category.items)
@@ -113,6 +129,14 @@ declare module '#docs/metadata' {
write: true,
getContents: () => {
const entries: string[] = [];
// An item re-exported from several entry points yields the same key more
// than once; dedupe so the generated object literal has no duplicate keys.
const seen = new Set<string>();
const add = (key: string, demoPath: string) => {
if (seen.has(key)) return;
seen.add(key);
entries.push(` '${key}': defineAsyncComponent(() => import('${demoPath}')),`);
};
for (const pkg of metadata.packages) {
// api items
@@ -120,7 +144,7 @@ declare module '#docs/metadata' {
for (const item of cat.items) {
if (item.hasDemo) {
const demoPath = resolve(ROOT, dirname(item.sourcePath), 'demo.vue');
entries.push(` '${pkg.slug}/${item.slug}': defineAsyncComponent(() => import('${demoPath}')),`);
add(`${pkg.slug}/${item.slug}`, demoPath);
}
}
}
@@ -128,7 +152,7 @@ declare module '#docs/metadata' {
for (const component of pkg.components) {
if (component.hasDemo) {
const demoPath = resolve(ROOT, component.sourcePath, 'demo.vue');
entries.push(` '${pkg.slug}/${component.slug}': defineAsyncComponent(() => import('${demoPath}')),`);
add(`${pkg.slug}/${component.slug}`, demoPath);
}
}
}
@@ -159,6 +183,48 @@ import type { Component } from 'vue';
declare module '#docs/demos' {
export const demos: Record<string, Component>;
}
`,
});
// Generate hand-authored doc-section import map (`<pkg>/docs/*.vue`)
addTemplate({
filename: 'docs-sections.ts',
write: true,
getContents: () => {
const entries: string[] = [];
for (const pkg of metadata.packages) {
for (const section of pkg.docs) {
const sectionPath = resolve(ROOT, section.sourcePath);
entries.push(` '${pkg.slug}/${section.slug}': defineAsyncComponent(() => import('${sectionPath}')),`);
}
}
if (entries.length === 0) {
return `import type { Component } from 'vue';\nexport const sections: Record<string, Component> = {};\n`;
}
return [
`import { defineAsyncComponent } from 'vue';`,
`import type { Component } from 'vue';`,
``,
`export const sections: Record<string, Component> = {`,
...entries,
`};`,
``,
].join('\n');
},
});
nuxt.options.alias['#docs/sections'] = resolve(nuxt.options.buildDir, 'docs-sections');
addTemplate({
filename: 'docs-sections-types.d.ts',
write: true,
getContents: () => `
import type { Component } from 'vue';
declare module '#docs/sections' {
export const sections: Record<string, Component>;
}
`,
});
},
+23
View File
@@ -46,6 +46,14 @@ export interface PackageMeta {
// ── kind: 'guide' ──────────────────────────────────────────────────────────
/** Prose sections rendered from Markdown (kind === 'guide') */
sections: GuideSection[];
// ── any kind ───────────────────────────────────────────────────────────────
/**
* Hand-authored `.vue` documentation pages discovered from `<pkg>/docs/*.vue`.
* Independent of `kind` — an `api` package can still ship a rich intro and
* several prose sections alongside its auto-generated reference.
*/
docs: DocSection[];
}
// ── API kind ─────────────────────────────────────────────────────────────────
@@ -152,6 +160,21 @@ export interface GuideSection {
markdown: string;
}
// ── Hand-authored .vue doc sections (any kind) ──────────────────────────────────
export interface DocSection {
/** Display title (from a `<!-- title: … -->` comment or the filename). */
title: string;
/** URL-friendly slug, e.g. "introduction" or "concepts". */
slug: string;
/** Sort order (from a `<!-- order: N -->` comment or a numeric filename prefix). */
order: number;
/** `true` for `docs/intro.vue` — rendered as the package landing page. */
isIntro: boolean;
/** Relative path to the `.vue` file from repo root (for the GitHub source link). */
sourcePath: string;
}
// ── Shared leaf types ──────────────────────────────────────────────────────────
export interface ParamMeta {