11#!/usr/bin/env node
22/**
33 * Prepares docs for Docusaurus by:
4- * 1. Copying from ../docs/ to ./docs/
4+ * 1. Copying from ../docs/ to ./docs/ (recursively, preserving subdirectories)
55 * 2. Adding frontmatter (id, title, sidebar_position)
66 * 3. Escaping MDX special characters
77 */
@@ -38,13 +38,17 @@ function extractPosition(filename) {
3838 return 999 ;
3939}
4040
41- // Generate document ID from filename
42- function extractId ( filename ) {
43- if ( filename === 'README.md' ) {
44- return 'index' ;
45- }
46- // Keep the number prefix to ensure unique IDs (e.g., "00-index" not "index")
47- return filename . replace ( / \. m d $ / , '' ) ;
41+ // Generate document ID from filename and relative directory.
42+ // IDs must be unique across the docs tree but cannot contain slashes
43+ // (Docusaurus rejects them in frontmatter ids), so we join the relative
44+ // directory and leaf with hyphens. The route URL is derived from the
45+ // file's path on disk and is unaffected by this id.
46+ function extractId ( filename , relativeDir ) {
47+ const base = filename === 'README.md'
48+ ? 'index'
49+ : filename . replace ( / \. m d $ / , '' ) ;
50+ if ( ! relativeDir ) return base ;
51+ return `${ relativeDir . replace ( / [ \\ / ] + / g, '-' ) } -${ base } ` ;
4852}
4953
5054// Escape MDX special characters in content (but not in code blocks)
@@ -89,34 +93,50 @@ function hasFrontmatter(content) {
8993 return content . startsWith ( '---\n' ) ;
9094}
9195
92- // Process a single markdown file
93- function processFile ( filename ) {
94- const sourcePath = path . join ( SOURCE_DIR , filename ) ;
95- const targetPath = path . join ( TARGET_DIR , filename ) ;
96+ // Process a single markdown file. `relativeDir` is the path of the file's
97+ // containing directory relative to SOURCE_DIR (empty string for top-level).
98+ function processFile ( filename , relativeDir ) {
99+ const sourcePath = path . join ( SOURCE_DIR , relativeDir , filename ) ;
100+ const targetDir = path . join ( TARGET_DIR , relativeDir ) ;
101+ const targetPath = path . join ( targetDir , filename ) ;
102+
103+ if ( ! fs . existsSync ( targetDir ) ) {
104+ fs . mkdirSync ( targetDir , { recursive : true } ) ;
105+ }
96106
97107 let content = fs . readFileSync ( sourcePath , 'utf8' ) ;
98108
99109 // Skip if already has frontmatter (shouldn't happen with clean source)
100110 if ( hasFrontmatter ( content ) ) {
101- console . log ( ` Skipping ${ filename } (already has frontmatter)` ) ;
111+ console . log ( ` Skipping ${ path . join ( relativeDir , filename ) } (already has frontmatter)` ) ;
102112 fs . writeFileSync ( targetPath , content ) ;
103113 return ;
104114 }
105115
106- const id = extractId ( filename ) ;
107116 const title = extractTitle ( content , filename ) ;
108117 const position = extractPosition ( filename ) ;
109118
110- // Build frontmatter
111- const frontmatter = [
112- '---' ,
113- `id: ${ id } ` ,
119+ // Build frontmatter. We only set an explicit `id` for top-level docs,
120+ // where it preserves backward-compatible URLs and matches the existing
121+ // sidebar ordering. For nested docs we omit `id` entirely so that
122+ // Docusaurus derives both the id and the route from the file path,
123+ // giving stable URLs like `/docs/adr/0001-aws-identity-center` without
124+ // any need to encode the directory in the id (which Docusaurus rejects
125+ // when it contains slashes).
126+ const frontmatter = [ '---' ] ;
127+ if ( relativeDir === '' ) {
128+ const id = extractId ( filename , relativeDir ) ;
129+ frontmatter . push ( `id: ${ id } ` ) ;
130+ }
131+ frontmatter . push (
114132 `title: "${ title . replace ( / " / g, '\\"' ) } "` ,
115133 `sidebar_position: ${ position } ` ,
116- ] ;
134+ ) ;
117135
118- // Add slug for README to make it the index
119- if ( filename === 'README.md' ) {
136+ // Add slug for the top-level README to make it the docs index. Nested
137+ // README/index files become the index of their containing folder
138+ // automatically.
139+ if ( filename === 'README.md' && relativeDir === '' ) {
120140 frontmatter . push ( 'slug: /' ) ;
121141 }
122142
@@ -129,7 +149,24 @@ function processFile(filename) {
129149 const output = frontmatter . join ( '\n' ) + content ;
130150
131151 fs . writeFileSync ( targetPath , output ) ;
132- console . log ( ` Processed ${ filename } -> id: ${ id } , position: ${ position } ` ) ;
152+ console . log ( ` Processed ${ path . join ( relativeDir , filename ) } -> position: ${ position } ` ) ;
153+ }
154+
155+ // Walk the source tree, processing every markdown file we find. Skips
156+ // dotfiles and dot-directories so that `docs/.meta/` and similar provenance
157+ // folders are left alone.
158+ function walk ( relativeDir ) {
159+ const dir = path . join ( SOURCE_DIR , relativeDir ) ;
160+ const entries = fs . readdirSync ( dir , { withFileTypes : true } )
161+ . filter ( e => ! e . name . startsWith ( '.' ) ) ;
162+
163+ for ( const entry of entries ) {
164+ if ( entry . isDirectory ( ) ) {
165+ walk ( path . join ( relativeDir , entry . name ) ) ;
166+ } else if ( entry . isFile ( ) && entry . name . endsWith ( '.md' ) ) {
167+ processFile ( entry . name , relativeDir ) ;
168+ }
169+ }
133170}
134171
135172// Main
@@ -144,14 +181,7 @@ function main() {
144181 }
145182 fs . mkdirSync ( TARGET_DIR , { recursive : true } ) ;
146183
147- // Get all markdown files
148- const files = fs . readdirSync ( SOURCE_DIR )
149- . filter ( f => f . endsWith ( '.md' ) && ! f . startsWith ( '.' ) ) ;
150-
151- console . log ( ` Found ${ files . length } markdown files` ) ;
152-
153- // Process each file
154- files . forEach ( processFile ) ;
184+ walk ( '' ) ;
155185
156186 console . log ( 'Done!' ) ;
157187}
0 commit comments