Skip to content

Commit c1f20be

Browse files
committed
docs: surface related links on API pages
Closes #152
1 parent f87b352 commit c1f20be

File tree

3 files changed

+87
-7
lines changed

3 files changed

+87
-7
lines changed

apps/docs/build/frontmatter.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface Frontmatter {
1515
emphasized?: boolean
1616
devmode?: boolean
1717
}
18+
related?: string[]
1819
}
1920

2021
export interface ParseResult {
@@ -45,22 +46,30 @@ export function parseFrontmatter (content: string): ParseResult {
4546
const lines = frontmatterStr.split('\n')
4647
let inFeatures = false
4748
let inMeta = false
49+
let inRelated = false
4850
let currentMetaName = ''
4951
const features: NonNullable<Frontmatter['features']> = {}
52+
const related: string[] = []
5053

5154
for (const line of lines) {
5255
const trimmed = line.trim()
5356
if (!trimmed) continue
5457

5558
// Check if we're entering/exiting a nested block
56-
// Allow '-' prefix for YAML array items within meta block
59+
// Allow '-' prefix for YAML array items within meta/related blocks
5760
if (!line.startsWith(' ') && !line.startsWith('\t') && !line.startsWith('-')) {
5861
inFeatures = false
5962
inMeta = false
63+
inRelated = false
6064
}
6165

6266
if (trimmed.startsWith('title:')) {
6367
frontmatter.title = trimmed.slice(6).trim().replace(/^['"]|['"]$/g, '')
68+
} else if (trimmed === 'related:') {
69+
inRelated = true
70+
} else if (inRelated && trimmed.startsWith('- ')) {
71+
const value = trimmed.slice(2).trim().replace(/^['"]|['"]$/g, '')
72+
if (value) related.push(value)
6473
} else if (trimmed === 'meta:') {
6574
inMeta = true
6675
} else if (inMeta) {
@@ -95,6 +104,10 @@ export function parseFrontmatter (content: string): ParseResult {
95104
frontmatter.features = features
96105
}
97106

107+
if (related.length > 0) {
108+
frontmatter.related = related
109+
}
110+
98111
return { frontmatter, body }
99112
}
100113

apps/docs/build/generate-api.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
*/
99

1010
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11-
import { readdir } from 'node:fs/promises'
12-
import { dirname, resolve } from 'node:path'
11+
import { glob, readdir } from 'node:fs/promises'
12+
import { basename, dirname, relative, resolve } from 'node:path'
1313
import { fileURLToPath } from 'node:url'
1414

1515
import { Node, Project } from 'ts-morph'
@@ -19,10 +19,14 @@ import { createChecker } from 'vue-component-meta'
1919
import type { InterfaceDeclaration, JSDocableNode, TypeAliasDeclaration } from 'ts-morph'
2020
import type { Plugin } from 'vite'
2121

22+
import { toCamel, toPascal } from './api-names'
23+
import { parseFrontmatter } from './frontmatter'
24+
2225
const __dirname = dirname(fileURLToPath(import.meta.url))
2326
const ROOT = resolve(__dirname, '../../..')
2427
const COMPONENTS_DIR = resolve(ROOT, 'packages/0/src/components')
2528
const COMPOSABLES_DIR = resolve(ROOT, 'packages/0/src/composables')
29+
const PAGES_DIR = resolve(__dirname, '../src/pages')
2630
const TSCONFIG = resolve(ROOT, 'tsconfig.json')
2731
const PACKAGE_TSCONFIG = resolve(ROOT, 'packages/0/tsconfig.json')
2832

@@ -781,13 +785,63 @@ async function generateComposableApis (): Promise<Record<string, ComposableApi>>
781785
return apis
782786
}
783787

788+
// ============================================================================
789+
// Related Links (from docs page frontmatter)
790+
// ============================================================================
791+
792+
/**
793+
* Scan composable/component markdown pages and build a map of API name →
794+
* related URLs. The list is the counterpart doc page prepended to its own
795+
* `related` frontmatter entries, so API pages can surface both the counterpart
796+
* itself and the counterpart's cross-links.
797+
*/
798+
async function generateRelatedMap (
799+
components: Record<string, ComponentApi>,
800+
composables: Record<string, ComposableApi>,
801+
): Promise<Record<string, string[]>> {
802+
const related: Record<string, string[]> = {}
803+
const componentNames = new Set(Object.keys(components).map(key => key.split('.')[0]))
804+
805+
const patterns = [
806+
`${PAGES_DIR}/composables/**/*.md`,
807+
`${PAGES_DIR}/components/**/*.md`,
808+
]
809+
810+
for (const pattern of patterns) {
811+
for await (const file of glob(pattern)) {
812+
const name = basename(file, '.md')
813+
if (name === 'index') continue
814+
815+
const isComposable = file.includes(`${PAGES_DIR}/composables/`)
816+
const apiName = isComposable ? toCamel(name) : toPascal(name)
817+
818+
if (isComposable && !(apiName in composables)) continue
819+
if (!isComposable && !componentNames.has(apiName)) continue
820+
821+
let raw: string
822+
try {
823+
raw = readFileSync(file, 'utf8')
824+
} catch {
825+
continue
826+
}
827+
828+
const { frontmatter } = parseFrontmatter(raw)
829+
const urlPath = '/' + relative(PAGES_DIR, file).replace(/\.md$/, '').replaceAll('\\', '/')
830+
related[apiName] = [urlPath, ...(frontmatter.related ?? [])]
831+
}
832+
}
833+
834+
return related
835+
}
836+
784837
// ============================================================================
785838
// Plugin
786839
// ============================================================================
787840

788841
export interface ApiData {
789842
components: Record<string, ComponentApi>
790843
composables: Record<string, ComposableApi>
844+
related: Record<string, string[]>
791845
}
792846

793847
export default function generateApiPlugin (): Plugin {
@@ -800,9 +854,13 @@ export default function generateApiPlugin (): Plugin {
800854
// Try to read from cache first (SSR build reuses client build cache)
801855
if (existsSync(API_CACHE_FILE)) {
802856
try {
803-
apiData = JSON.parse(readFileSync(API_CACHE_FILE, 'utf8')) as ApiData
804-
console.log(`[generate-api] Loaded API from cache`)
805-
return apiData
857+
const cached = JSON.parse(readFileSync(API_CACHE_FILE, 'utf8')) as Partial<ApiData>
858+
// Invalidate caches written before the `related` field existed.
859+
if (cached.components && cached.composables && cached.related) {
860+
apiData = cached as ApiData
861+
console.log(`[generate-api] Loaded API from cache`)
862+
return apiData
863+
}
806864
} catch {
807865
// Cache read failed, regenerate
808866
}
@@ -815,7 +873,8 @@ export default function generateApiPlugin (): Plugin {
815873
generateComponentApis(),
816874
generateComposableApis(),
817875
])
818-
const data = { components, composables }
876+
const related = await generateRelatedMap(components, composables)
877+
const data: ApiData = { components, composables, related }
819878
// Write to cache for subsequent builds (SSR reuses this)
820879
try {
821880
mkdirSync(API_CACHE_DIR, { recursive: true })

apps/docs/src/pages/api/[name].vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
})
4343
const isComposable = toRef(() => itemName.value && itemName.value in data.composables)
4444
45+
const relatedFrontmatter = toRef(() => ({
46+
related: itemName.value ? data.related[itemName.value] ?? [] : [],
47+
}))
48+
4549
const componentApis = computed<ComponentApi[]>(() => {
4650
if (!isComponent.value || !itemName.value) return []
4751
@@ -123,6 +127,8 @@
123127

124128
<p class="lead">API reference for the {{ itemName }} component{{ componentApis.length > 1 ? 's' : '' }}.</p>
125129

130+
<DocsRelated :frontmatter="relatedFrontmatter" />
131+
126132
<template
127133
v-for="api in componentApis"
128134
:key="api.name"
@@ -191,6 +197,8 @@
191197

192198
<p class="lead">API reference for the {{ composableApi.name }} composable.</p>
193199

200+
<DocsRelated :frontmatter="relatedFrontmatter" />
201+
194202
<template v-if="composableApi.functions?.length">
195203
<DocsHeaderAnchor
196204
id="functions"

0 commit comments

Comments
 (0)