1+ const fs = require ( 'fs' ) ;
2+ const path = require ( 'path' ) ;
3+ const yaml = require ( 'js-yaml' ) ;
4+ const { Octokit } = require ( '@octokit/rest' ) ;
5+
6+ const OUTPUT_FILE = path . join ( __dirname , 'my-mappings.md' ) ;
7+ const GITHUB_TOKEN = process . env . GITHUB_TOKEN ;
8+ const REPOSITORY = process . env . REPOSITORY ;
9+ const BRANCH = 'main' ;
10+
11+ if ( ! GITHUB_TOKEN || ! REPOSITORY ) {
12+ console . error ( 'GITHUB_TOKEN and REPOSITORY env vars are required.' ) ;
13+ process . exit ( 1 ) ;
14+ }
15+
16+ const [ owner , repo ] = REPOSITORY . split ( '/' ) ;
17+ const octokit = new Octokit ( { auth : GITHUB_TOKEN } ) ;
18+
19+ async function listDir ( pathInRepo ) {
20+ try {
21+ const res = await Promise . race ( [
22+ octokit . repos . getContent ( {
23+ owner,
24+ repo,
25+ path : pathInRepo ,
26+ ref : BRANCH
27+ } ) ,
28+ new Promise ( ( _ , reject ) => setTimeout ( ( ) => reject ( new Error ( 'Timeout' ) ) , 10000 ) )
29+ ] ) ;
30+ return Array . isArray ( res . data ) ? res . data : [ ] ;
31+ } catch ( e ) {
32+ return [ ] ;
33+ }
34+ }
35+
36+ async function getFileContent ( pathInRepo ) {
37+ try {
38+ const res = await Promise . race ( [
39+ octokit . repos . getContent ( {
40+ owner,
41+ repo,
42+ path : pathInRepo ,
43+ ref : BRANCH
44+ } ) ,
45+ new Promise ( ( _ , reject ) => setTimeout ( ( ) => reject ( new Error ( 'Timeout' ) ) , 10000 ) )
46+ ] ) ;
47+ if ( res . data && res . data . content ) {
48+ return Buffer . from ( res . data . content , 'base64' ) . toString ( 'utf-8' ) ;
49+ }
50+ return null ;
51+ } catch ( e ) {
52+ return null ;
53+ }
54+ }
55+
56+ function slugify ( str ) {
57+ return String ( str )
58+ . replace ( / ( - d e f | - r e f e r e n c e | - d o c s | - a p i ) $ / i, '' ) // Only remove at the end
59+ . replace ( / _ / g, '-' )
60+ . replace ( / \s + / g, '-' )
61+ . toLowerCase ( ) ;
62+ }
63+
64+ async function findDocsYml ( productDir ) {
65+ // Try <dir>.yml, docs.yml, or any .yml in the product dir
66+ const files = await listDir ( `fern/products/${ productDir } ` ) ;
67+ const candidates = [
68+ `${ productDir } .yml` ,
69+ `docs.yml`
70+ ] ;
71+ for ( const candidate of candidates ) {
72+ if ( files . find ( f => f . name === candidate ) ) {
73+ return candidate ;
74+ }
75+ }
76+ // fallback: first .yml file
77+ const yml = files . find ( f => f . name . endsWith ( '.yml' ) ) ;
78+ return yml ? yml . name : null ;
79+ }
80+
81+ async function findPageFile ( productDir , page ) {
82+ // Try to find the .mdx file in pages/ recursively using the API
83+ async function walk ( dir ) {
84+ console . log ( `[DEBUG] Listing directory: ${ dir } ` ) ;
85+ const items = await listDir ( dir ) ;
86+ for ( const item of items ) {
87+ if ( item . type === 'dir' ) {
88+ const found = await walk ( item . path ) ;
89+ if ( found ) return found ;
90+ } else if ( item . name . replace ( / \. m d x $ / , '' ) === page ) {
91+ console . log ( `[DEBUG] Found page file: ${ item . path } for page: ${ page } ` ) ;
92+ return item . path ;
93+ }
94+ }
95+ return null ;
96+ }
97+ return await walk ( `fern/products/${ productDir } /pages` ) ;
98+ }
99+
100+ async function walkNav ( nav , parentSlugs , pages , productDir , canonicalSlug , depth = 0 ) {
101+ for ( const item of nav ) {
102+ let sectionSlug = '' ;
103+ if ( item [ 'skip-slug' ] ) {
104+ sectionSlug = '' ;
105+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Skipping slug for section: ${ item . section || '' } ` ) ;
106+ } else if ( item . slug === true && item . section ) {
107+ sectionSlug = slugify ( item . section ) ;
108+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Section with slug:true: ${ sectionSlug } ` ) ;
109+ } else if ( typeof item . slug === 'string' ) {
110+ sectionSlug = slugify ( item . slug ) ;
111+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Section with explicit slug: ${ sectionSlug } ` ) ;
112+ } else if ( item . section ) {
113+ sectionSlug = slugify ( item . section ) ;
114+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Section with name: ${ sectionSlug } ` ) ;
115+ }
116+ const newSlugs = sectionSlug ? [ ...parentSlugs , sectionSlug ] : parentSlugs ;
117+ if ( item . contents ) {
118+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Entering section: ${ sectionSlug || '(no slug)' } with path: /learn/${ [ canonicalSlug , ...newSlugs ] . join ( '/' ) } ` ) ;
119+ await walkNav ( item . contents , newSlugs , pages , productDir , canonicalSlug , depth + 1 ) ;
120+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Exiting section: ${ sectionSlug || '(no slug)' } ` ) ;
121+ }
122+ if ( item . page ) {
123+ let pageSlug = typeof item . slug === 'string' ? slugify ( item . slug ) : slugify ( item . page ) ;
124+ // Only add pageSlug if it's not the same as the last section slug
125+ let urlSegments = [ '/learn' , canonicalSlug , ...newSlugs ] ;
126+ if ( newSlugs [ newSlugs . length - 1 ] !== pageSlug ) {
127+ urlSegments . push ( pageSlug ) ;
128+ }
129+ const learnUrl = urlSegments . filter ( Boolean ) . join ( '/' ) ;
130+ if ( item . path ) {
131+ // Remove leading './' if present
132+ let repoPath = item . path . replace ( / ^ \. \/ / , '' ) ;
133+ // Always make it relative to fern/products/<productDir>
134+ if ( ! repoPath . startsWith ( 'fern/products/' ) ) {
135+ repoPath = `fern/products/${ productDir } /${ repoPath } ` ;
136+ }
137+ pages . push ( { learnUrl, repoPath } ) ;
138+ console . log ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Mapping: ${ learnUrl } → ${ repoPath } ` ) ;
139+ } else {
140+ console . warn ( `[DEBUG] [${ ' ' . repeat ( depth ) } ] Skipping page: ${ item . page } in product: ${ productDir } (missing path)` ) ;
141+ }
142+ }
143+ }
144+ }
145+
146+ async function main ( ) {
147+ // Step 1: Find and parse the root docs.yml
148+ const rootDocsYmlContent = await getFileContent ( 'fern/docs.yml' ) ;
149+ if ( ! rootDocsYmlContent ) {
150+ console . error ( 'Could not find fern/docs.yml in the repo.' ) ;
151+ process . exit ( 1 ) ;
152+ }
153+ const rootDocsYml = yaml . load ( rootDocsYmlContent ) ;
154+
155+ // Step 2: Get the root URL subpath (e.g., /learn)
156+ let rootUrlSubpath = '' ;
157+ if ( rootDocsYml . url ) {
158+ const url = rootDocsYml . url ;
159+ const match = url . match ( / h t t p s ? : \/ \/ [ ^ / ] + ( \/ .* ) / ) ;
160+ rootUrlSubpath = match ? match [ 1 ] . replace ( / \/ $ / , '' ) : '' ;
161+ console . log ( `[DEBUG] Root URL subpath: ${ rootUrlSubpath } ` ) ;
162+ }
163+ if ( ! rootUrlSubpath ) rootUrlSubpath = '/learn' ;
164+ console . log ( `[DEBUG] rootUrlSubpath: "${ rootUrlSubpath } "` ) ;
165+
166+ // Step 3: Parse products from root docs.yml
167+ const products = rootDocsYml . products || [ ] ;
168+ let rootMappingLines = [ '## Product Root Directories' , '' ] ;
169+ let slugToDir = { } ;
170+ let allPages = [ ] ;
171+ for ( const product of products ) {
172+ if ( ! product . path || ! product . slug ) {
173+ console . warn ( `[DEBUG] Skipping product with missing path or slug: ${ JSON . stringify ( product ) } ` ) ;
174+ continue ;
175+ }
176+ // product.path is like ./products/openapi-def/openapi-def.yml
177+ const productDir = product . path . split ( '/' ) [ 2 ] ;
178+ const productYmlPath = product . path . replace ( './' , 'fern/' ) ;
179+ const ymlContent = await getFileContent ( productYmlPath ) ;
180+ if ( ! ymlContent ) {
181+ console . warn ( `[DEBUG] Could not fetch product YAML: ${ productYmlPath } ` ) ;
182+ continue ;
183+ }
184+ const productYml = yaml . load ( ymlContent ) ;
185+ const canonicalSlug = slugify ( product . slug ) ;
186+ slugToDir [ canonicalSlug ] = productDir ;
187+ rootMappingLines . push ( `${ canonicalSlug } : ${ productDir } ` ) ;
188+ console . log ( `[DEBUG] Product: ${ productDir } , Slug: ${ canonicalSlug } , YAML: ${ productYmlPath } ` ) ;
189+ if ( productYml && productYml . navigation ) {
190+ await walkNav ( productYml . navigation , [ ] , allPages , productDir , canonicalSlug ) ;
191+ } else {
192+ console . warn ( `[DEBUG] No navigation found in ${ productYmlPath } ` ) ;
193+ }
194+ }
195+ rootMappingLines . push ( '' ) ;
196+
197+ let lines = [
198+ '# Fern URL Mappings' ,
199+ '' ,
200+ `Generated on: ${ new Date ( ) . toISOString ( ) } ` ,
201+ '' ,
202+ ...rootMappingLines ,
203+ '## Products' ,
204+ ''
205+ ] ;
206+ let total = 0 ;
207+ for ( const slug in slugToDir ) {
208+ lines . push ( `## ${ slug . charAt ( 0 ) . toUpperCase ( ) + slug . slice ( 1 ) } ` ) ;
209+ lines . push ( '' ) ;
210+ const pages = allPages . filter ( p => p . learnUrl . startsWith ( `/learn/${ slug } /` ) ) ;
211+ if ( pages . length === 0 ) {
212+ lines . push ( '_No .mdx files found for this product._' ) ;
213+ }
214+ for ( const { learnUrl, repoPath } of pages ) {
215+ lines . push ( `- \`${ learnUrl } \` → \`${ repoPath } \`` ) ;
216+ console . log ( `[DEBUG] Mapping: ${ learnUrl } → ${ repoPath } ` ) ;
217+ total ++ ;
218+ }
219+ lines . push ( '' ) ;
220+ }
221+ lines [ 3 ] = `Total mappings: ${ total } ` ;
222+ fs . writeFileSync ( OUTPUT_FILE , lines . join ( '\n' ) , 'utf-8' ) ;
223+ console . log ( `Wrote ${ total } mappings to ${ OUTPUT_FILE } ` ) ;
224+ if ( total === 0 ) {
225+ console . warn ( 'Warning: No mappings were generated. Check your repo structure and permissions.' ) ;
226+ }
227+ }
228+
229+ main ( ) ;
0 commit comments