5c33ed36a4
The docs build pulls composables (via demos) and doc sections into its bundle, which transitively import workspace packages like @robonen/platform/multi. Aliasing each @robonen/* package to its src means the build no longer requires every package's dist to be built first, fixing CI where dists are absent.
250 lines
9.1 KiB
TypeScript
250 lines
9.1 KiB
TypeScript
/**
|
|
* Nuxt module that extracts TypeScript metadata from source packages
|
|
* and provides it as a virtual module `#docs/metadata`.
|
|
*
|
|
* Runs extraction at build start and makes the data available to all
|
|
* pages/components via `import metadata from '#docs/metadata'`.
|
|
*/
|
|
|
|
import { addTemplate, createResolver, defineNuxtModule } from '@nuxt/kit';
|
|
import { dirname, resolve } from 'node:path';
|
|
import type { DocsMetadata } from './types';
|
|
|
|
export default defineNuxtModule({
|
|
meta: {
|
|
name: 'docs-extractor',
|
|
configKey: 'docsExtractor',
|
|
},
|
|
|
|
async setup(_options, nuxt) {
|
|
const { resolve: resolveModule } = createResolver(import.meta.url);
|
|
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
|
|
|
// Run extraction immediately during setup so metadata is available
|
|
// when templates are resolved (app:templates phase runs before build:before)
|
|
console.log('[docs-extractor] Running metadata extraction...');
|
|
const { extract } = await import('./extract');
|
|
const metadata: DocsMetadata = extract();
|
|
|
|
// Add Vite resolve aliases for source packages so demo.vue imports resolve.
|
|
// The vue/toolkit package uses `@/` path aliases (e.g. `@/composables/...`).
|
|
// We prepend them via vite:extendConfig so they take priority over Nuxt's
|
|
// built-in `@` → srcDir alias.
|
|
const vueSrc = resolve(ROOT, 'vue/toolkit/src');
|
|
|
|
// Resolve `@robonen/*` workspace imports (pulled in transitively by demos and
|
|
// doc sections) from SOURCE rather than built `dist/`. Without this the docs
|
|
// build depends on every workspace package being built first — which CI does
|
|
// not guarantee (e.g. a composable importing `@robonen/platform/multi` fails
|
|
// to resolve when platform's dist is absent). Prefix aliases also cover
|
|
// subpath exports (`@robonen/platform/multi` → `core/platform/src/multi`).
|
|
const workspaceSrc: Record<string, string> = {
|
|
'@robonen/stdlib': 'core/stdlib/src',
|
|
'@robonen/platform': 'core/platform/src',
|
|
'@robonen/fetch': 'core/fetch/src',
|
|
'@robonen/encoding': 'core/encoding/src',
|
|
'@robonen/crdt': 'core/crdt/src',
|
|
'@robonen/editor': 'vue/editor/src',
|
|
'@robonen/primitives': 'vue/primitives/src',
|
|
'@robonen/vue': vueSrc,
|
|
};
|
|
|
|
nuxt.hook('vite:extendConfig', (config) => {
|
|
const existing = config.resolve?.alias;
|
|
const sourceAliases = [
|
|
{ find: '@/composables', replacement: resolve(vueSrc, 'composables') },
|
|
{ find: '@/types', replacement: resolve(vueSrc, 'types') },
|
|
{ find: '@/utils', replacement: resolve(vueSrc, 'utils') },
|
|
...Object.entries(workspaceSrc).map(([find, rel]) => ({ find, replacement: resolve(ROOT, rel) })),
|
|
];
|
|
|
|
if (Array.isArray(existing)) {
|
|
existing.unshift(...sourceAliases);
|
|
}
|
|
else {
|
|
config.resolve ??= {};
|
|
config.resolve.alias = [
|
|
...sourceAliases,
|
|
...Object.entries(existing ?? {}).map(([find, replacement]) => ({ find, replacement: replacement as string })),
|
|
];
|
|
}
|
|
});
|
|
|
|
// Provide metadata as a virtual template
|
|
addTemplate({
|
|
filename: 'docs-metadata.ts',
|
|
write: true,
|
|
getContents: () => {
|
|
const json = JSON.stringify(metadata, null, 2);
|
|
return `export default ${json} as const;`;
|
|
},
|
|
});
|
|
|
|
// 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',
|
|
write: true,
|
|
getContents: () => {
|
|
const typesPath = resolveModule('./types');
|
|
return `
|
|
import type { DocsMetadata } from '${typesPath}';
|
|
declare module '#docs/metadata' {
|
|
const metadata: DocsMetadata;
|
|
export default metadata;
|
|
}
|
|
`;
|
|
},
|
|
});
|
|
|
|
// Register prerender routes from metadata — one detail route per documented
|
|
// leaf, regardless of package kind (api items / components / guide sections).
|
|
nuxt.hook('prerender:routes', async ({ routes }: { routes: Set<string> }) => {
|
|
if (metadata.packages.length === 0) return;
|
|
|
|
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)
|
|
routes.add(`/${pkg.slug}/${item.slug}`);
|
|
}
|
|
else if (pkg.kind === 'components') {
|
|
for (const component of pkg.components)
|
|
routes.add(`/${pkg.slug}/${component.slug}`);
|
|
}
|
|
else if (pkg.kind === 'guide') {
|
|
for (const section of pkg.sections)
|
|
routes.add(`/${pkg.slug}/${section.slug}`);
|
|
}
|
|
}
|
|
|
|
console.log(`[docs-extractor] Registered ${routes.size} routes for prerender`);
|
|
});
|
|
|
|
// Generate demo component import map
|
|
addTemplate({
|
|
filename: 'docs-demos.ts',
|
|
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
|
|
for (const cat of pkg.categories) {
|
|
for (const item of cat.items) {
|
|
if (item.hasDemo) {
|
|
const demoPath = resolve(ROOT, dirname(item.sourcePath), 'demo.vue');
|
|
add(`${pkg.slug}/${item.slug}`, demoPath);
|
|
}
|
|
}
|
|
}
|
|
// component groups
|
|
for (const component of pkg.components) {
|
|
if (component.hasDemo) {
|
|
const demoPath = resolve(ROOT, component.sourcePath, 'demo.vue');
|
|
add(`${pkg.slug}/${component.slug}`, demoPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (entries.length === 0) {
|
|
return `import type { Component } from 'vue';\nexport const demos: Record<string, Component> = {};\n`;
|
|
}
|
|
|
|
return [
|
|
`import { defineAsyncComponent } from 'vue';`,
|
|
`import type { Component } from 'vue';`,
|
|
``,
|
|
`export const demos: Record<string, Component> = {`,
|
|
...entries,
|
|
`};`,
|
|
``,
|
|
].join('\n');
|
|
},
|
|
});
|
|
|
|
nuxt.options.alias['#docs/demos'] = resolve(nuxt.options.buildDir, 'docs-demos');
|
|
|
|
addTemplate({
|
|
filename: 'docs-demos-types.d.ts',
|
|
write: true,
|
|
getContents: () => `
|
|
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>;
|
|
}
|
|
`,
|
|
});
|
|
},
|
|
});
|