@@ -115,6 +115,65 @@ function extractTitle(rel, src) {
115115 return { title, body : src . slice ( match [ 0 ] . length ) . replace ( / ^ \r ? \n / , '' ) } ;
116116}
117117
118+ // The changelog is one long page upstream; render it as one URL per release
119+ // (reference/changelog/v1.31.1.html) with reference/changelog.html as the
120+ // release index. Sections look like:
121+ //
122+ // ## [1.31.1](https://github.com/sqlc-dev/sqlc/releases/tag/v1.31.1)
123+ // Released 2026-04-22
124+ // ### Bug Fixes
125+ // - ...
126+ //
127+ // Anything else is contract drift and fails the build.
128+ function splitChangelog ( rel , body ) {
129+ // Leftover MyST anchor targets ("(v1-31-1)=") from the old toolchain
130+ // render as literal text; drop them.
131+ const lines = body . split ( '\n' ) . filter ( ( l ) => ! / ^ \( [ a - z 0 - 9 . _ - ] + \) = \s * $ / i. test ( l ) ) ;
132+
133+ const sections = [ ] ;
134+ let intro = [ ] ;
135+ let current = null ;
136+ for ( const line of lines ) {
137+ if ( line . startsWith ( '## ' ) ) {
138+ const m = / ^ # # \[ v ? ( [ ^ \] ] + ) \] \( ( h t t p s : \/ \/ g i t h u b \. c o m \/ [ ^ ) ] + ) \) \s * $ / . exec ( line ) ;
139+ if ( ! m ) fail ( `${ rel } : unrecognized release heading: ${ line } ` ) ;
140+ current = { version : `v${ m [ 1 ] } ` , url : m [ 2 ] , date : null , body : [ ] } ;
141+ sections . push ( current ) ;
142+ } else if ( current === null ) {
143+ intro . push ( line ) ;
144+ } else {
145+ const released = / ^ R e l e a s e d ( \d { 4 } - \d { 2 } - \d { 2 } ) \s * $ / . exec ( line ) ;
146+ if ( released && current . date === null ) {
147+ current . date = released [ 1 ] ;
148+ current . body . push ( `Released ${ released [ 1 ] } · [View on GitHub](${ current . url } )` ) ;
149+ } else {
150+ // Promote subsections: the release heading became the page title.
151+ current . body . push ( line . startsWith ( '### ' ) ? line . slice ( 1 ) : line ) ;
152+ }
153+ }
154+ }
155+ if ( sections . length === 0 ) fail ( `${ rel } : no release sections found` ) ;
156+
157+ const pages = [ ] ;
158+ for ( const s of sections ) {
159+ if ( s . date === null ) fail ( `${ rel } : release ${ s . version } has no "Released YYYY-MM-DD" line` ) ;
160+ pages . push ( {
161+ rel : `reference/changelog/${ s . version } .md` ,
162+ title : s . version ,
163+ body : s . body . join ( '\n' ) . trim ( ) + '\n' ,
164+ } ) ;
165+ }
166+
167+ const index = [
168+ ...intro . join ( '\n' ) . trim ( ) . split ( '\n' ) ,
169+ '' ,
170+ ...sections . map ( ( s ) => `- [${ s . version } ](changelog/${ s . version } .md) — released ${ s . date } ` ) ,
171+ '' ,
172+ ] ;
173+ pages . push ( { rel, title : 'Changelog' , body : index . join ( '\n' ) } ) ;
174+ return pages ;
175+ }
176+
118177function main ( ) {
119178 const args = parseArgs ( process . argv . slice ( 2 ) ) ;
120179 const srcDir = args . src ? path . resolve ( args . src ) : cloneDocs ( args . ref ) ;
@@ -142,26 +201,42 @@ function main() {
142201 fs . rmSync ( OUT_DIR , { recursive : true , force : true } ) ;
143202 fs . mkdirSync ( OUT_DIR , { recursive : true } ) ;
144203
145- for ( const rel of files ) {
146- const src = fs . readFileSync ( path . join ( srcDir , rel . split ( '/' ) . join ( path . sep ) ) , 'utf8' ) ;
147- const { title, body } = extractTitle ( rel , src ) ;
204+ let written = 0 ;
205+ const writePage = ( rel , title , body ) => {
148206 const dest = path . join ( OUT_DIR , rel . split ( '/' ) . join ( path . sep ) ) ;
149207 fs . mkdirSync ( path . dirname ( dest ) , { recursive : true } ) ;
150208 // JSON string literals are valid YAML, so this quoting is always safe.
151209 fs . writeFileSync ( dest , `---\ntitle: ${ JSON . stringify ( title ) } \n---\n\n${ body } ` ) ;
210+ written ++ ;
211+ } ;
212+
213+ for ( const rel of files ) {
214+ const src = fs . readFileSync ( path . join ( srcDir , rel . split ( '/' ) . join ( path . sep ) ) , 'utf8' ) ;
215+ const { title, body } = extractTitle ( rel , src ) ;
216+ if ( rel === 'reference/changelog.md' ) {
217+ for ( const page of splitChangelog ( rel , body ) ) writePage ( page . rel , page . title , page . body ) ;
218+ } else {
219+ writePage ( rel , title , body ) ;
220+ }
152221 }
153222
154223 // toc.yaml sections map 1:1 to sidebar separators; unlisted pages get
155224 // routes but stay out of the sidebar by not appearing in `pages`.
156- const routeOf = ( page ) => page . replace ( / \. m d $ / , '' ) ;
225+ // Fumadocs resolves an extensionless meta.json item to a folder first, so
226+ // a page shadowed by a same-named folder (reference/changelog next to the
227+ // split-out reference/changelog/ releases) needs its explicit .md path.
228+ const routeOf = ( page ) => {
229+ const route = page . replace ( / \. m d $ / , '' ) ;
230+ return fs . existsSync ( path . join ( OUT_DIR , route . split ( '/' ) . join ( path . sep ) ) ) ? page : route ;
231+ } ;
157232 const pages = [ routeOf ( toc . index ) ] ;
158233 for ( const section of toc . sections ) {
159234 pages . push ( `---${ section . title } ---` ) ;
160235 pages . push ( ...section . pages . map ( routeOf ) ) ;
161236 }
162237 fs . writeFileSync ( path . join ( OUT_DIR , 'meta.json' ) , JSON . stringify ( { pages } , null , 2 ) + '\n' ) ;
163238
164- console . log ( `ingest: wrote ${ files . length } pages (${ toc . unlisted . length } unlisted) from ${ srcDir } ` ) ;
239+ console . log ( `ingest: wrote ${ written } pages (${ toc . unlisted . length } unlisted) from ${ srcDir } ` ) ;
165240}
166241
167242main ( ) ;
0 commit comments