88 */
99
1010import { 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'
1313import { fileURLToPath } from 'node:url'
1414
1515import { Node , Project } from 'ts-morph'
@@ -19,10 +19,14 @@ import { createChecker } from 'vue-component-meta'
1919import type { InterfaceDeclaration , JSDocableNode , TypeAliasDeclaration } from 'ts-morph'
2020import type { Plugin } from 'vite'
2121
22+ import { toCamel , toPascal } from './api-names'
23+ import { parseFrontmatter } from './frontmatter'
24+
2225const __dirname = dirname ( fileURLToPath ( import . meta. url ) )
2326const ROOT = resolve ( __dirname , '../../..' )
2427const COMPONENTS_DIR = resolve ( ROOT , 'packages/0/src/components' )
2528const COMPOSABLES_DIR = resolve ( ROOT , 'packages/0/src/composables' )
29+ const PAGES_DIR = resolve ( __dirname , '../src/pages' )
2630const TSCONFIG = resolve ( ROOT , 'tsconfig.json' )
2731const 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 ( / \. m d $ / , '' ) . replaceAll ( '\\' , '/' )
830+ related [ apiName ] = [ urlPath , ...( frontmatter . related ?? [ ] ) ]
831+ }
832+ }
833+
834+ return related
835+ }
836+
784837// ============================================================================
785838// Plugin
786839// ============================================================================
787840
788841export interface ApiData {
789842 components : Record < string , ComponentApi >
790843 composables : Record < string , ComposableApi >
844+ related : Record < string , string [ ] >
791845}
792846
793847export 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 } )
0 commit comments