Merge pull request #141 from robonen/docs
feat(forms): add useMaskedField and useMaskedInput composables for in…
This commit is contained in:
@@ -19,7 +19,10 @@ const anatomyCode = computed(() => {
|
||||
|
||||
const imports = `import {\n${names.map(n => ` ${n},`).join('\n')}\n} from '${importPath.value}';`;
|
||||
|
||||
const [root, ...rest] = names;
|
||||
// Wrap the skeleton in the Root part (not whatever the barrel exports first),
|
||||
// with the remaining parts nested inside it.
|
||||
const root = (props.component.parts.find(p => p.role === 'Root') ?? props.component.parts[0]!).name;
|
||||
const rest = names.filter(n => n !== root);
|
||||
let tree: string;
|
||||
if (rest.length === 0) {
|
||||
tree = `<${root} />`;
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { demoSources } from '#docs/demo-sources';
|
||||
|
||||
const props = defineProps<{
|
||||
component: Component;
|
||||
source: string;
|
||||
/** Key into the lazy demo-source map (`${pkg}/${slug}`). */
|
||||
sourceKey: string;
|
||||
}>();
|
||||
|
||||
const showSource = ref(false);
|
||||
const source = ref('');
|
||||
|
||||
const { highlighted, highlightReactive } = useShiki();
|
||||
|
||||
// Fetch the raw demo source only when the user first opens it, then highlight.
|
||||
watch(showSource, async (show) => {
|
||||
if (show && !highlighted.value) {
|
||||
await highlightReactive(props.source, 'vue');
|
||||
}
|
||||
if (!show) return;
|
||||
if (!source.value)
|
||||
source.value = (await demoSources[props.sourceKey]?.()) ?? '';
|
||||
if (source.value && !highlighted.value)
|
||||
await highlightReactive(source.value, 'vue');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3">
|
||||
{{ method.description }}
|
||||
<DocsText :text="method.description" />
|
||||
</p>
|
||||
|
||||
<DocsCode
|
||||
@@ -38,7 +38,7 @@ defineProps<{
|
||||
<div v-if="method.returns" class="mt-2 text-sm">
|
||||
<span class="text-(--fg-subtle)">Returns</span>
|
||||
<code class="ml-1.5 text-xs font-mono bg-(--bg-inset) border border-(--border) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
|
||||
<span v-if="method.returns.description" class="ml-2 text-(--fg-muted)">{{ method.returns.description }}</span>
|
||||
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-(--fg-muted)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,8 @@ defineProps<{
|
||||
<span v-else class="text-(--fg-subtle)">—</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
|
||||
{{ param.description || '—' }}
|
||||
<DocsText v-if="param.description" :text="param.description" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -36,7 +36,8 @@ defineProps<{
|
||||
<span v-else class="text-(--fg-subtle)">—</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
|
||||
{{ prop.description || '—' }}
|
||||
<DocsText v-if="prop.description" :text="prop.description" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// Renders a short description with inline markdown (bold / `code` / links /
|
||||
// {@link}). Content is authored by us (JSDoc), so v-html is safe here.
|
||||
const props = defineProps<{ text?: string | null }>();
|
||||
|
||||
const html = computed(() => renderInline(props.text ?? ''));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="docs-text" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-text :deep(code) {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.05em 0.3em;
|
||||
}
|
||||
|
||||
.docs-text :deep(a) {
|
||||
color: var(--accent-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.docs-text :deep(strong) {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,9 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// JSDoc `{@link Symbol}` / `{@link Symbol|label}`. The capture starts with a
|
||||
// non-space char so the leading `\s+` can't overlap it (no super-linear backtracking).
|
||||
const JSDOC_LINK = /\{@link\s+([^\s}|][^}|]*)(?:\|[^}]+)?\}/g;
|
||||
|
||||
export interface Heading {
|
||||
depth: number;
|
||||
text: string;
|
||||
@@ -46,6 +50,17 @@ export function extractHeadings(markdown: string): Heading[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a short description as INLINE HTML (bold/code/links, no block wrapping).
|
||||
* Used for API/param/property descriptions, which are authored as one-line
|
||||
* markdown with the occasional JSDoc `{@link X}` (shown as inline code).
|
||||
*/
|
||||
export function renderInline(text: string): string {
|
||||
if (!text) return '';
|
||||
const withLinks = text.replaceAll(JSDOC_LINK, (_m, name: string) => `\`${name.trim()}\``);
|
||||
return marked.parseInline(withLinks, { async: false }) as string;
|
||||
}
|
||||
|
||||
/** Render markdown to HTML with stable heading ids (matching extractHeadings). */
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
@@ -118,10 +118,17 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<DocsBadge :kind="entry.item.kind" size="md" />
|
||||
<h1 class="min-w-0 break-words text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
||||
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
||||
<DocsTag v-if="entry.item.hasTests" label="tested" variant="test" />
|
||||
<DocsTag
|
||||
v-if="entry.item.hasTests"
|
||||
:label="typeof entry.item.coverage === 'number' ? `tested · ${entry.item.coverage}%` : 'tested'"
|
||||
variant="test"
|
||||
:title="typeof entry.item.coverage === 'number' ? `${entry.item.coverage}% statement coverage` : undefined"
|
||||
/>
|
||||
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
|
||||
</div>
|
||||
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed">{{ entry.item.description }}</p>
|
||||
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
|
||||
<DocsText :text="entry.item.description" />
|
||||
</p>
|
||||
<div class="flex items-center gap-4 mt-4 text-sm">
|
||||
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
|
||||
@@ -143,7 +150,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
|
||||
<section v-if="entry.item.hasDemo && demoComponent" id="demo" class="mb-8 scroll-mt-20">
|
||||
<h2 :class="sectionTitle">Demo</h2>
|
||||
<DocsDemo :component="demoComponent" :source="entry.item.demoSource" />
|
||||
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
|
||||
</section>
|
||||
|
||||
<section v-if="entry.item.signatures.length" id="signature" class="mb-8 scroll-mt-20">
|
||||
@@ -171,10 +178,11 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
|
||||
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
|
||||
<h2 :class="sectionTitle">Returns</h2>
|
||||
<div class="flex items-baseline gap-2 text-sm flex-wrap">
|
||||
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''">
|
||||
<code class="font-mono bg-(--bg-inset) border border-(--border) px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
|
||||
<span v-if="entry.item.returns.description" class="text-(--fg-muted)">{{ entry.item.returns.description }}</span>
|
||||
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-(--fg-muted)" />
|
||||
</div>
|
||||
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
|
||||
</section>
|
||||
|
||||
<section v-if="entry.item.properties.length" id="properties" class="mb-8 scroll-mt-20">
|
||||
@@ -195,7 +203,9 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<DocsBadge :kind="rt.kind" size="sm" />
|
||||
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3>
|
||||
</div>
|
||||
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3">{{ rt.description }}</p>
|
||||
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3">
|
||||
<DocsText :text="rt.description" />
|
||||
</p>
|
||||
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
|
||||
<DocsPropsTable v-if="rt.properties.length" :properties="rt.properties" class="mt-3" />
|
||||
</div>
|
||||
@@ -211,7 +221,9 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<h1 class="text-2xl font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
|
||||
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
|
||||
</div>
|
||||
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">{{ entry.component.description }}</p>
|
||||
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
|
||||
<DocsText :text="entry.component.description" />
|
||||
</p>
|
||||
<div class="flex items-center gap-4 mt-4 text-sm">
|
||||
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
|
||||
@@ -222,7 +234,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
|
||||
<section v-if="entry.component.hasDemo && demoComponent" class="mb-10">
|
||||
<h2 :class="sectionTitle">Demo</h2>
|
||||
<DocsDemo :component="demoComponent" :source="entry.component.demoSource" />
|
||||
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
|
||||
</section>
|
||||
|
||||
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
import { basename, dirname, relative, resolve } from 'node:path';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { Project } from 'ts-morph';
|
||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration } from 'ts-morph';
|
||||
import { Node, Project, SyntaxKind } from 'ts-morph';
|
||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
||||
import type {
|
||||
CategoryMeta,
|
||||
ComponentMeta,
|
||||
@@ -36,6 +36,34 @@ import type {
|
||||
/** Repository root — docs/modules/extractor → three levels up */
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Statement-coverage percentage per source file (repo-relative path), parsed
|
||||
* from Istanbul's `coverage/coverage-final.json` if present. Empty when coverage
|
||||
* hasn't been generated — items then simply omit the coverage badge.
|
||||
*/
|
||||
function loadCoverage(): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
const file = resolve(ROOT, 'coverage', 'coverage-final.json');
|
||||
if (!existsSync(file)) return map;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(file, 'utf-8')) as Record<string, { s?: Record<string, number> }>;
|
||||
for (const [absPath, entry] of Object.entries(data)) {
|
||||
const counts = Object.values(entry.s ?? {});
|
||||
if (counts.length === 0) continue;
|
||||
const covered = counts.filter(c => c > 0).length;
|
||||
map.set(relative(ROOT, absPath), Math.round((covered / counts.length) * 100));
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Malformed/partial coverage file — skip rather than fail extraction.
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
const COVERAGE = loadCoverage();
|
||||
|
||||
interface PackageConfig {
|
||||
/** Path relative to repo root */
|
||||
path: string;
|
||||
@@ -83,6 +111,18 @@ function slugify(name: string): string {
|
||||
return toKebabCase(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a type string for display: drop the `import("…").` qualifiers the type
|
||||
* checker emits when resolving types (e.g. `import("vue").Ref<T>` → `Ref<T>`) and
|
||||
* collapse whitespace. Prefer this over raw `.getType().getText()`.
|
||||
*/
|
||||
function cleanType(text: string): string {
|
||||
return text
|
||||
.replaceAll(/import\((?:"[^"]*"|'[^']*')\)\./g, '')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toPascalCase(slug: string): string {
|
||||
return slug
|
||||
.split(/[-_]/)
|
||||
@@ -118,8 +158,17 @@ function getExamples(tags: JSDocTag[]): string[] {
|
||||
return tags
|
||||
.filter(t => t.getTagName() === 'example')
|
||||
.map((t) => {
|
||||
const text = t.getCommentText()?.trim() ?? '';
|
||||
return text.replace(/^```(?:ts|typescript)?\n?/, '').replace(/\n?```$/, '').trim();
|
||||
let text = t.getCommentText()?.trim() ?? '';
|
||||
// A leading `<caption>…</caption>` (JSDoc example title) isn't valid code —
|
||||
// turn it into a leading comment so the snippet stays clean & highlightable.
|
||||
let caption = '';
|
||||
const cap = text.match(/^<caption>([\s\S]*?)<\/caption>\s*/i);
|
||||
if (cap) {
|
||||
caption = cap[1]!.trim();
|
||||
text = text.slice(cap[0].length);
|
||||
}
|
||||
text = text.replace(/^```(?:ts|typescript|vue|js|javascript)?\n?/, '').replace(/\n?```$/, '').trim();
|
||||
return caption ? `// ${caption}\n${text}` : text;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -130,7 +179,9 @@ function extractParams(tags: JSDocTag[], node: FunctionDeclaration | MethodDecla
|
||||
|
||||
for (const param of node.getParameters()) {
|
||||
const name = param.getName();
|
||||
const type = param.getType().getText(param);
|
||||
// Prefer the written annotation (`MaybeRefOrGetter<T>`) over the resolved
|
||||
// type, which expands aliases into noise (`T | import("vue").Ref<T> | …`).
|
||||
const type = cleanType(param.getTypeNode()?.getText() ?? param.getType().getText(param));
|
||||
const optional = param.isOptional();
|
||||
const defaultValue = param.getInitializer()?.getText() ?? null;
|
||||
|
||||
@@ -156,20 +207,75 @@ function extractParams(tags: JSDocTag[], node: FunctionDeclaration | MethodDecla
|
||||
function extractTypeParams(node: FunctionDeclaration | ClassDeclaration | InterfaceDeclaration | TypeAliasDeclaration): TypeParamMeta[] {
|
||||
return node.getTypeParameters().map(tp => ({
|
||||
name: tp.getName(),
|
||||
constraint: tp.getConstraint()?.getText() ?? null,
|
||||
default: tp.getDefault()?.getText() ?? null,
|
||||
constraint: tp.getConstraint() ? cleanType(tp.getConstraint()!.getText()) : null,
|
||||
default: tp.getDefault() ? cleanType(tp.getDefault()!.getText()) : null,
|
||||
description: '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* When a function returns a plain object — a named interface (`UseXReturn`) OR an
|
||||
* inline object literal (`{ first: HTMLElement | undefined; last: … }`) — expand
|
||||
* its properties so the renderer shows a Name/Type/Description table. Skips
|
||||
* unions/intersections, arrays/tuples, callable (function) types, primitives, and
|
||||
* built-ins (`Ref`/`ComputedRef`/`Promise`/`Map`… whose declaration is in
|
||||
* node_modules) — those keep just the type string.
|
||||
*/
|
||||
function extractReturnProperties(node: FunctionDeclaration | MethodDeclaration): PropertyMeta[] {
|
||||
const returnType = node.getReturnType();
|
||||
|
||||
if (
|
||||
returnType.isUnion()
|
||||
|| returnType.isIntersection()
|
||||
|| returnType.isArray()
|
||||
|| returnType.isTuple()
|
||||
|| returnType.getCallSignatures().length > 0
|
||||
|| !returnType.isObject()
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// A named declaration in node_modules (Ref/Promise/Map…) is a built-in we don't
|
||||
// expand; anonymous object literals have no such declaration → keep going.
|
||||
const symbol = returnType.getAliasSymbol() ?? returnType.getSymbol();
|
||||
const decl = symbol?.getDeclarations()?.[0];
|
||||
if (decl && decl.getSourceFile().isInNodeModules())
|
||||
return [];
|
||||
|
||||
const props: PropertyMeta[] = [];
|
||||
for (const prop of returnType.getProperties()) {
|
||||
const propDecl = prop.getDeclarations()?.[0];
|
||||
if (!propDecl || propDecl.getSourceFile().isInNodeModules())
|
||||
continue;
|
||||
|
||||
// Prefer the written annotation (clean); fall back to the resolved type for
|
||||
// method-style members and inferred object-literal returns.
|
||||
const typeNode = Node.isTyped(propDecl) ? propDecl.getTypeNode() : undefined;
|
||||
const jsdocs = Node.isJSDocable(propDecl) ? propDecl.getJsDocs() : [];
|
||||
|
||||
props.push({
|
||||
name: prop.getName(),
|
||||
type: cleanType(typeNode?.getText() ?? prop.getTypeAtLocation(node).getText(node)),
|
||||
description: getDescription(jsdocs, getJsDocTags(jsdocs)),
|
||||
optional: Node.isQuestionTokenable(propDecl) && propDecl.hasQuestionToken(),
|
||||
defaultValue: null,
|
||||
readonly: false,
|
||||
});
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
function extractReturnMeta(tags: JSDocTag[], node: FunctionDeclaration | MethodDeclaration): ReturnMeta | null {
|
||||
const returnType = node.getReturnType().getText(node);
|
||||
const returnType = cleanType(node.getReturnTypeNode()?.getText() ?? node.getReturnType().getText(node));
|
||||
if (returnType === 'void') return null;
|
||||
|
||||
const returnsTag = getTagValue(tags, 'returns') || getTagValue(tags, 'return');
|
||||
const description = returnsTag.replace(/^\{[^}]*\}\s*/, '').trim();
|
||||
|
||||
return { type: returnType, description };
|
||||
const properties = extractReturnProperties(node);
|
||||
|
||||
return { type: returnType, description, properties };
|
||||
}
|
||||
|
||||
function extractMethodMeta(method: MethodDeclaration): MethodMeta {
|
||||
@@ -192,7 +298,7 @@ function extractPropertyMeta(prop: PropertyDeclaration | PropertySignature): Pro
|
||||
|
||||
return {
|
||||
name: prop.getName(),
|
||||
type: prop.getType().getText(prop),
|
||||
type: cleanType(prop.getTypeNode?.()?.getText() ?? prop.getType().getText(prop)),
|
||||
description: getDescription(jsdocs, tags),
|
||||
optional: prop.hasQuestionToken?.() ?? false,
|
||||
defaultValue: getTagValue(tags, 'default') || null,
|
||||
@@ -208,10 +314,11 @@ function hasDemoFile(sourceFilePath: string): boolean {
|
||||
return existsSync(resolve(getSourceDir(sourceFilePath), 'demo.vue'));
|
||||
}
|
||||
|
||||
function readDemoSource(sourceFilePath: string): string {
|
||||
const demoPath = resolve(getSourceDir(sourceFilePath), 'demo.vue');
|
||||
if (!existsSync(demoPath)) return '';
|
||||
return readFileSync(demoPath, 'utf-8');
|
||||
// Demo SOURCE is loaded lazily on the client (via `#docs/demo-sources`) only when
|
||||
// "View source" is opened, so it is intentionally NOT embedded in the metadata
|
||||
// payload (it was ~850KB). `hasDemo`/the lazy map carry what the UI needs.
|
||||
function readDemoSource(_sourceFilePath: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasTestFile(sourceFilePath: string): boolean {
|
||||
@@ -274,7 +381,7 @@ function extractClass(cls: ClassDeclaration, sourceFilePath: string, entryPoint:
|
||||
.filter(g => (g.getScope() ?? 'public') === 'public')
|
||||
.map(g => ({
|
||||
name: g.getName(),
|
||||
type: g.getReturnType().getText(g),
|
||||
type: cleanType(g.getReturnTypeNode()?.getText() ?? g.getReturnType().getText(g)),
|
||||
description: getDescription(g.getJsDocs(), getJsDocTags(g.getJsDocs())),
|
||||
optional: false,
|
||||
defaultValue: null,
|
||||
@@ -377,6 +484,43 @@ function extractTypeAlias(typeAlias: TypeAliasDeclaration, sourceFilePath: strin
|
||||
};
|
||||
}
|
||||
|
||||
function extractVariable(
|
||||
decl: VariableDeclaration,
|
||||
jsdocs: JSDoc[],
|
||||
tags: JSDocTag[],
|
||||
sourceFilePath: string,
|
||||
entryPoint: string,
|
||||
): ItemMeta | null {
|
||||
const name = decl.getName();
|
||||
if (!name || name.startsWith('_')) return null;
|
||||
|
||||
const typeText = cleanType(decl.getTypeNode()?.getText() ?? decl.getType().getText(decl));
|
||||
const keyword = decl.getVariableStatement()?.getDeclarationKind() ?? 'const';
|
||||
// Show the declaration shape, not the (potentially huge) initializer value.
|
||||
const signature = `${keyword} ${name}: ${typeText}`;
|
||||
|
||||
return {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
kind: 'variable',
|
||||
description: getDescription(jsdocs, tags),
|
||||
since: getTagValue(tags, 'since'),
|
||||
signatures: [signature],
|
||||
params: [],
|
||||
returns: null,
|
||||
typeParams: [],
|
||||
examples: getExamples(tags),
|
||||
methods: [],
|
||||
properties: [],
|
||||
hasDemo: hasDemoFile(sourceFilePath),
|
||||
demoSource: readDemoSource(sourceFilePath),
|
||||
hasTests: hasTestFile(sourceFilePath),
|
||||
relatedTypes: [],
|
||||
sourcePath: relative(ROOT, sourceFilePath),
|
||||
entryPoint,
|
||||
};
|
||||
}
|
||||
|
||||
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited = new Set<string>()): ItemMeta[] {
|
||||
const filePath = sourceFile.getFilePath();
|
||||
if (visited.has(filePath)) return [];
|
||||
@@ -448,6 +592,21 @@ function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visite
|
||||
if (item) items.push(item);
|
||||
}
|
||||
|
||||
for (const varStatement of sourceFile.getVariableStatements()) {
|
||||
if (!varStatement.isExported()) continue;
|
||||
const jsdocs = varStatement.getJsDocs();
|
||||
const tags = getJsDocTags(jsdocs);
|
||||
// Gate (like types/interfaces): only documented consts, so we don't surface
|
||||
// every internal constant — desirable but not always.
|
||||
const hasCategory = getTagValue(tags, 'category') !== '';
|
||||
if (!hasCategory && jsdocs.length === 0) continue;
|
||||
|
||||
for (const decl of varStatement.getDeclarations()) {
|
||||
const item = extractVariable(decl, jsdocs, tags, filePath, entryPoint);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const exportDecl of sourceFile.getExportDeclarations()) {
|
||||
if (!exportDecl.getModuleSpecifierValue()) continue;
|
||||
const referencedFile = exportDecl.getModuleSpecifierSourceFile();
|
||||
@@ -461,13 +620,35 @@ function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visite
|
||||
* Groups types/interfaces from `types.ts` files with their sibling
|
||||
* class/function items from the same directory as `relatedTypes`.
|
||||
*/
|
||||
/**
|
||||
* A trimmed copy of a type/interface for embedding as a primary's `relatedType`:
|
||||
* keeps the shape (signature/properties/description) but drops the heavy fields
|
||||
* (demo source, examples, nested types, params/returns) that would otherwise be
|
||||
* duplicated into the metadata payload.
|
||||
*/
|
||||
function slimRelatedType(type: ItemMeta): ItemMeta {
|
||||
return {
|
||||
...type,
|
||||
examples: [],
|
||||
params: [],
|
||||
returns: null,
|
||||
methods: [],
|
||||
relatedTypes: [],
|
||||
hasDemo: false,
|
||||
demoSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
|
||||
const typesByDir = new Map<string, ItemMeta[]>();
|
||||
const primaryByDir = new Map<string, ItemMeta[]>();
|
||||
|
||||
for (const item of items) {
|
||||
const dir = dirname(item.sourcePath);
|
||||
const isSecondary = item.sourcePath.endsWith('/types.ts') && (item.kind === 'type' || item.kind === 'interface');
|
||||
// Types/interfaces are documentation-secondary: when a function/class lives
|
||||
// in the same directory they fold into it as `relatedTypes` instead of
|
||||
// competing as standalone pages (keeps the reference to the important items).
|
||||
const isSecondary = item.kind === 'type' || item.kind === 'interface';
|
||||
|
||||
const target = isSecondary ? typesByDir : primaryByDir;
|
||||
const existing = target.get(dir) ?? [];
|
||||
@@ -479,8 +660,24 @@ function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
|
||||
for (const [dir, types] of Array.from(typesByDir.entries())) {
|
||||
const primaries = primaryByDir.get(dir);
|
||||
if (!primaries || primaries.length === 0) continue;
|
||||
for (const primary of primaries) primary.relatedTypes = [...types];
|
||||
for (const t of types) absorbed.add(`${t.entryPoint}:${t.name}`);
|
||||
|
||||
for (const type of types) {
|
||||
// Attach each type to the SINGLE most-relevant primary (longest name-prefix
|
||||
// match, else the first) — never every primary — so it isn't duplicated N×,
|
||||
// and store a slim copy (no demo source / nested types).
|
||||
const typeName = type.name.toLowerCase();
|
||||
let owner = primaries[0]!;
|
||||
let bestLen = -1;
|
||||
for (const primary of primaries) {
|
||||
const primaryName = primary.name.toLowerCase();
|
||||
if (typeName.startsWith(primaryName) && primaryName.length > bestLen) {
|
||||
owner = primary;
|
||||
bestLen = primaryName.length;
|
||||
}
|
||||
}
|
||||
owner.relatedTypes.push(slimRelatedType(type));
|
||||
absorbed.add(`${type.entryPoint}:${type.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return items.filter(item => !absorbed.has(`${item.entryPoint}:${item.name}`));
|
||||
@@ -571,6 +768,28 @@ function buildApiCategories(pkgDir: string): CategoryMeta[] {
|
||||
|
||||
const groupedItems = groupCoLocatedTypes(uniqueItems);
|
||||
|
||||
// Per-package slug uniqueness — the [package]/[utility] route keys on slug, so
|
||||
// a function `foo` and interface `Foo` (same kebab slug) would otherwise clash.
|
||||
// Functions/classes keep the base slug; lower-priority kinds get suffixed.
|
||||
const KIND_PRIORITY: Record<string, number> = { function: 0, class: 1, variable: 2, enum: 3, interface: 4, type: 5 };
|
||||
const usedSlugs = new Set<string>();
|
||||
for (const item of [...groupedItems].sort((a, b) => (KIND_PRIORITY[a.kind] ?? 9) - (KIND_PRIORITY[b.kind] ?? 9))) {
|
||||
if (!usedSlugs.has(item.slug)) {
|
||||
usedSlugs.add(item.slug);
|
||||
continue;
|
||||
}
|
||||
let candidate = `${item.slug}-${item.kind}`;
|
||||
let n = 2;
|
||||
while (usedSlugs.has(candidate))
|
||||
candidate = `${item.slug}-${item.kind}-${n++}`;
|
||||
item.slug = candidate;
|
||||
usedSlugs.add(candidate);
|
||||
}
|
||||
|
||||
// Attach statement-coverage % (when coverage data exists) for the test badge.
|
||||
for (const item of groupedItems)
|
||||
item.coverage = COVERAGE.get(item.sourcePath) ?? null;
|
||||
|
||||
const categoryMap = new Map<string, ItemMeta[]>();
|
||||
for (const item of groupedItems) {
|
||||
const cat = inferCategoryFromItem(item);
|
||||
@@ -621,6 +840,43 @@ function extractEmits(setupScript: string): EmitMeta[] {
|
||||
|
||||
let partProjectCounter = 0;
|
||||
|
||||
/**
|
||||
* Parse `defineModel(...)` calls from a setup block into the v-model prop(s) +
|
||||
* their `update:*` emit(s) — these don't appear in the `XxxProps` interface or
|
||||
* `defineEmits`, so without this the controlled v-model API is invisible in docs.
|
||||
*/
|
||||
function extractModels(setupScript: string): { props: PropertyMeta[]; emits: EmitMeta[] } {
|
||||
const props: PropertyMeta[] = [];
|
||||
const emits: EmitMeta[] = [];
|
||||
if (!setupScript.includes('defineModel')) return { props, emits };
|
||||
|
||||
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true, compilerOptions: { allowJs: true, skipLibCheck: true } });
|
||||
const sf = project.createSourceFile(`__model_${partProjectCounter++}.ts`, setupScript);
|
||||
|
||||
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
if (call.getExpression().getText() !== 'defineModel') continue;
|
||||
|
||||
const typeArg = call.getTypeArguments()[0];
|
||||
const type = typeArg ? cleanType(typeArg.getText()) : 'unknown';
|
||||
const firstArg = call.getArguments()[0];
|
||||
const name = firstArg && Node.isStringLiteral(firstArg) ? firstArg.getLiteralValue() : 'modelValue';
|
||||
|
||||
props.push({
|
||||
name,
|
||||
type,
|
||||
description: name === 'modelValue'
|
||||
? 'Two-way bound value (`v-model`).'
|
||||
: `Two-way bound value (\`v-model:${name}\`).`,
|
||||
optional: true,
|
||||
defaultValue: null,
|
||||
readonly: false,
|
||||
});
|
||||
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
|
||||
}
|
||||
|
||||
return { props, emits };
|
||||
}
|
||||
|
||||
/** Parse the `XxxProps` interface from a `.vue` part using ts-morph in-memory. */
|
||||
function extractPartProps(plainScript: string): { props: PropertyMeta[]; description: string } {
|
||||
if (!plainScript.trim()) return { props: [], description: '' };
|
||||
@@ -688,12 +944,19 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
const slug = entry.name;
|
||||
const base = toPascalCase(slug);
|
||||
|
||||
// Preserve the anatomy order declared in index.ts; fall back to filenames.
|
||||
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
|
||||
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
|
||||
// that aren't part of the public API. Fall back to all .vue (minus demo) only
|
||||
// when the barrel exposes no parseable `export { default as X }`.
|
||||
const order = readPartOrder(resolve(dir, 'index.ts'));
|
||||
const orderedFiles = [
|
||||
...order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f)),
|
||||
...vueFiles.filter(f => !order.includes(f.replace(/\.vue$/, ''))),
|
||||
];
|
||||
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
|
||||
const candidates = publicFiles.length > 0
|
||||
? publicFiles
|
||||
: vueFiles.filter(f => f !== 'demo.vue');
|
||||
// Drop internal implementation/variant parts users never compose directly
|
||||
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
|
||||
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
|
||||
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
|
||||
|
||||
const parts: ComponentPartMeta[] = [];
|
||||
let groupDescription = '';
|
||||
@@ -706,7 +969,17 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
const name = file.replace(/\.vue$/, '');
|
||||
const role = roleFromName(name, base);
|
||||
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
||||
parts.push({ name, role, description, props, emits: extractEmits(setup) });
|
||||
|
||||
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
||||
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
||||
const models = extractModels(setup);
|
||||
const emits = extractEmits(setup);
|
||||
for (const mp of models.props)
|
||||
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
||||
for (const me of models.emits)
|
||||
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
||||
|
||||
parts.push({ name, role, description, props, emits });
|
||||
}
|
||||
|
||||
const entryPoint = `./${slug}`;
|
||||
@@ -720,7 +993,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
entryPoint,
|
||||
parts,
|
||||
hasDemo,
|
||||
demoSource: hasDemo ? readFileSync(demoPath, 'utf-8') : '',
|
||||
demoSource: '', // loaded lazily client-side via #docs/demo-sources
|
||||
sourcePath: relative(ROOT, dir),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ export default defineNuxtModule({
|
||||
};
|
||||
|
||||
nuxt.hook('vite:extendConfig', (config) => {
|
||||
// Workspace SOURCE (e.g. @robonen/primitives) references the `__DEV__`
|
||||
// compile-time flag (each package defines it in its own vitest/tsdown
|
||||
// config). The docs bundle consumes that source directly via the aliases
|
||||
// below, so it must define `__DEV__` too — otherwise it throws
|
||||
// "ReferenceError: __DEV__ is not defined" at runtime (e.g. in the
|
||||
// Primitive `as="template"` / Slot path), silently blanking every demo
|
||||
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
|
||||
config.define ??= {};
|
||||
(config.define as Record<string, unknown>).__DEV__ ??= 'import.meta.env.DEV';
|
||||
|
||||
const existing = config.resolve?.alias;
|
||||
const sourceAliases = [
|
||||
{ find: '@/composables', replacement: resolve(vueSrc, 'composables') },
|
||||
@@ -75,8 +85,9 @@ export default defineNuxtModule({
|
||||
filename: 'docs-metadata.ts',
|
||||
write: true,
|
||||
getContents: () => {
|
||||
const json = JSON.stringify(metadata, null, 2);
|
||||
return `export default ${json} as const;`;
|
||||
// No indentation (smaller module) and no `as const` — a multi-MB literal
|
||||
// type is pathological for tsc, and consumers cast to DocsMetadata anyway.
|
||||
return `export default ${JSON.stringify(metadata)};`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -204,6 +215,50 @@ declare module '#docs/demos' {
|
||||
`,
|
||||
});
|
||||
|
||||
// Lazy demo SOURCE loaders (raw text) — kept out of the metadata payload and
|
||||
// fetched only when a user opens "View source", so the ~850KB of demo source
|
||||
// never ships in the always-loaded metadata bundle.
|
||||
addTemplate({
|
||||
filename: 'docs-demo-sources.ts',
|
||||
write: true,
|
||||
getContents: () => {
|
||||
const entries: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (key: string, demoPath: string) => {
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
entries.push(` '${key}': () => import('${demoPath}?raw').then(m => m.default),`);
|
||||
};
|
||||
|
||||
for (const pkg of metadata.packages) {
|
||||
for (const cat of pkg.categories)
|
||||
for (const item of cat.items)
|
||||
if (item.hasDemo) add(`${pkg.slug}/${item.slug}`, resolve(ROOT, dirname(item.sourcePath), 'demo.vue'));
|
||||
for (const component of pkg.components)
|
||||
if (component.hasDemo) add(`${pkg.slug}/${component.slug}`, resolve(ROOT, component.sourcePath, 'demo.vue'));
|
||||
}
|
||||
|
||||
return [
|
||||
`export const demoSources: Record<string, () => Promise<string>> = {`,
|
||||
...entries,
|
||||
`};`,
|
||||
``,
|
||||
].join('\n');
|
||||
},
|
||||
});
|
||||
|
||||
nuxt.options.alias['#docs/demo-sources'] = resolve(nuxt.options.buildDir, 'docs-demo-sources');
|
||||
|
||||
addTemplate({
|
||||
filename: 'docs-demo-sources-types.d.ts',
|
||||
write: true,
|
||||
getContents: () => `
|
||||
declare module '#docs/demo-sources' {
|
||||
export const demoSources: Record<string, () => Promise<string>>;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Generate hand-authored doc-section import map (`<pkg>/docs/*.vue`)
|
||||
addTemplate({
|
||||
filename: 'docs-sections.ts',
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface ItemMeta {
|
||||
demoSource: string;
|
||||
/** Whether an index.test.ts file exists alongside */
|
||||
hasTests: boolean;
|
||||
/** Statement-coverage percentage for the source file, if coverage data exists */
|
||||
coverage?: number | null;
|
||||
/** Related types/interfaces co-located in the same module directory */
|
||||
relatedTypes: ItemMeta[];
|
||||
/** Relative path to the source file from repo root */
|
||||
@@ -188,6 +190,11 @@ export interface ParamMeta {
|
||||
export interface ReturnMeta {
|
||||
type: string;
|
||||
description: string;
|
||||
/**
|
||||
* Properties of the returned object, when the return type is one of the
|
||||
* package's own interfaces — rendered as a table like parameters.
|
||||
*/
|
||||
properties?: PropertyMeta[];
|
||||
}
|
||||
|
||||
export interface TypeParamMeta {
|
||||
|
||||
Reference in New Issue
Block a user