@@ -12,6 +12,9 @@ import { LinkError, makeLinkResolver } from "./lib/links.mjs";
1212import { llmsFullTxt , llmsTxt } from "./lib/llms.mjs" ;
1313import { deprecationReason , MarkdownError , renderBlocks , renderInline , splitEntryBody } from "./lib/markdown.mjs" ;
1414import { checkPage } from "./lib/page-checks.mjs" ;
15+ import { checkStacks } from "./checks.mjs" ;
16+ import { stackPageHtml } from "./lib/stack-page.mjs" ;
17+ import { licenceTerms , owes , parseStack , pickPath } from "./lib/stacks.mjs" ;
1518
1619const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
1720const ROOT = path . resolve ( __dirname , ".." ) ;
@@ -20,6 +23,8 @@ const PUBLIC = path.join(__dirname, "public");
2023const DIST = path . join ( __dirname , "dist" ) ;
2124const CONFIG_PATH = path . join ( __dirname , "config.json" ) ;
2225const VOCAB_PATH = path . join ( __dirname , "license-vocabulary.json" ) ;
26+ const SPDX_ALLOWED_PATH = path . join ( __dirname , "spdx-allowed.json" ) ;
27+ const STACKS = path . join ( ROOT , "stacks" ) ;
2328// Social preview image: a first-party screenshot of this site, kept with the
2429// other first-party stills (the validator allows binaries there) and copied
2530// into dist at build time.
@@ -329,10 +334,11 @@ function headMetaHtml(site, stats, generatedAt, hasCard) {
329334 ] . join ( "\n " ) ;
330335}
331336
332- function sitemapXml ( site , entries ) {
337+ function sitemapXml ( site , entries , stacks = [ ] ) {
333338 const base = site . siteUrl . replace ( / \/ + $ / , "" ) ;
334339 const urls = [
335340 ` <url><loc>${ esc ( base ) } /</loc></url>` ,
341+ ...stacks . map ( ( s ) => ` <url><loc>${ esc ( base ) } /stack/${ esc ( s . meta . id ) } /</loc><lastmod>${ esc ( s . meta . walked ) } </lastmod></url>` ) ,
336342 ...entries . map (
337343 ( e ) =>
338344 ` <url><loc>${ esc ( base ) } /entry/${ esc ( e . id ) } /</loc>${ e . verified ? `<lastmod>${ esc ( e . verified ) } </lastmod>` : "" } </url>`
@@ -382,19 +388,87 @@ function copyDir(src, dest) {
382388 }
383389}
384390
385- /** Returns entry => link resolver for links in that entry 's body. */
386- function linkResolvers ( entries , config ) {
391+ /** Returns file => link resolver for links in that file 's body (`file.path` is repo-relative) . */
392+ function linkResolvers ( entries , config , entryBase = "../" ) {
387393 const idByPath = new Map ( entries . map ( ( e ) => [ e . path , e . id ] ) ) ;
388394 const kindOf = ( repoPath ) => {
389395 const full = path . join ( ROOT , repoPath ) ;
390396 if ( ! fs . existsSync ( full ) ) return null ;
391397 return fs . statSync ( full ) . isDirectory ( ) ? "dir" : "file" ;
392398 } ;
393- return ( entry ) => makeLinkResolver ( { entryPath : entry . path , idByPath, repo : config . site . repo , kindOf } ) ;
399+ return ( file ) => makeLinkResolver ( { entryPath : file . path , idByPath, repo : config . site . repo , kindOf, entryBase } ) ;
400+ }
401+
402+ /** Reads stacks/*.md, checks them (V15) and resolves each pick to its entry. */
403+ function loadStacks ( entries , vocab , spdxAllowed ) {
404+ if ( ! fs . existsSync ( STACKS ) ) return { stacks : [ ] , errors : [ ] } ;
405+ const files = fs
406+ . readdirSync ( STACKS )
407+ . filter ( ( n ) => n . endsWith ( ".md" ) && n !== "README.md" )
408+ . sort ( )
409+ . map ( ( n ) => ( { rel : `stacks/${ n } ` , text : fs . readFileSync ( path . join ( STACKS , n ) , "utf8" ) } ) ) ;
410+ const terms = licenceTerms ( vocab , spdxAllowed ) ;
411+ const byPath = new Map ( entries . map ( ( e ) => [ e . path , e ] ) ) ;
412+ const errors = checkStacks ( files , byPath , terms , new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ) ;
413+ if ( errors . length ) return { stacks : [ ] , errors } ;
414+ const stacks = files . map ( ( { rel, text } ) => {
415+ const parsed = parseStack ( text , { file : rel , terms } ) ;
416+ const picked = parsed . sections . flatMap ( ( s ) =>
417+ s . picks . map ( ( p ) => ( { ...p , section : s . name , entry : byPath . get ( pickPath ( rel , p . href ) ) } ) )
418+ ) ;
419+ return { ...parsed , rel, picked } ;
420+ } ) ;
421+ return { stacks, errors } ;
422+ }
423+
424+ /** Writes dist/stack/<id>/index.html for every stack. */
425+ function writeStackPages ( { stacks, entries, config, stats, stamp, hasCard, now } ) {
426+ const errors = [ ] ;
427+ const written = [ ] ;
428+ const resolverFor = linkResolvers ( entries , config , "../../entry/" ) ;
429+ for ( const stack of stacks ) {
430+ try {
431+ const resolveLink = resolverFor ( { path : stack . rel } ) ;
432+ const leadHtml = renderBlocks ( stack . lead , { file : stack . rel , resolveLink, firstLine : stack . leadLine } ) ;
433+ const sections = stack . sections . map ( ( s ) => ( {
434+ name : s . name ,
435+ rows : stack . picked
436+ . filter ( ( p ) => p . section === s . name )
437+ . map ( ( p ) => ( { need : p . need , entry : p . entry , whyHtml : renderInline ( p . why , resolveLink ) } ) ) ,
438+ } ) ) ;
439+ const gapsHtml = stack . gaps . map ( ( g ) => renderInline ( g . text , resolveLink ) ) ;
440+ const html = stackPageHtml ( {
441+ stack,
442+ sections,
443+ gapsHtml,
444+ leadHtml,
445+ owed : owes ( stack . picked ) ,
446+ site : config . site ,
447+ stamp,
448+ total : stats . total ,
449+ hasCard,
450+ now,
451+ } ) ;
452+ const dir = path . join ( DIST , "stack" , stack . meta . id ) ;
453+ fs . mkdirSync ( dir , { recursive : true } ) ;
454+ fs . writeFileSync ( path . join ( dir , "index.html" ) , html ) ;
455+ written . push ( `stack/${ stack . meta . id } /index.html` ) ;
456+ } catch ( err ) {
457+ if ( err instanceof MarkdownError || err instanceof LinkError ) errors . push ( err . message ) ;
458+ else throw err ;
459+ }
460+ }
461+ return { errors, written } ;
462+ }
463+
464+ function stackRowsHtml ( stacks ) {
465+ return stacks
466+ . map ( ( s ) => `<li><a href="stack/${ esc ( s . meta . id ) } /">${ esc ( s . meta . title ) } <span>${ esc ( s . meta . task ) } </span></a></li>` )
467+ . join ( "\n" ) ;
394468}
395469
396470/** Writes dist/entry/<id>/index.html for every entry. */
397- function writeEntryPages ( { entries, bodies, config, stats, stamp, hasCard, now } ) {
471+ function writeEntryPages ( { entries, bodies, config, stats, stamp, hasCard, now, usedIn = new Map ( ) } ) {
398472 const errors = [ ] ;
399473 const written = [ ] ;
400474 const resolverFor = linkResolvers ( entries , config ) ;
@@ -428,6 +502,7 @@ function writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now }
428502 total : stats . total ,
429503 hasCard,
430504 now,
505+ stacks : usedIn . get ( entry . id ) || [ ] ,
431506 } ) ;
432507 const dir = path . join ( DIST , "entry" , entry . id ) ;
433508 fs . mkdirSync ( dir , { recursive : true } ) ;
@@ -456,6 +531,17 @@ function main() {
456531 const config = JSON . parse ( fs . readFileSync ( CONFIG_PATH , "utf8" ) ) ;
457532 const vocab = JSON . parse ( fs . readFileSync ( VOCAB_PATH , "utf8" ) ) ;
458533 const { entries, errors, bodies } = loadEntries ( vocab ) ;
534+ const spdxAllowed = JSON . parse ( fs . readFileSync ( SPDX_ALLOWED_PATH , "utf8" ) ) ;
535+ const { stacks, errors : stackErrors } = loadStacks ( entries , vocab , spdxAllowed ) ;
536+ // Entry id => the stacks that pick it, for "Used in" on entry pages.
537+ const usedIn = new Map ( ) ;
538+ for ( const s of stacks ) {
539+ for ( const p of s . picked ) {
540+ const list = usedIn . get ( p . entry . id ) || [ ] ;
541+ if ( ! list . some ( ( x ) => x . id === s . meta . id ) ) list . push ( { id : s . meta . id , title : s . meta . title } ) ;
542+ usedIn . set ( p . entry . id , list ) ;
543+ }
544+ }
459545
460546 if ( errors . length ) {
461547 console . error ( "Build warnings:" ) ;
@@ -514,6 +600,7 @@ function main() {
514600 const visible = entries . filter ( ( e ) => e . status !== "deprecated" ) ;
515601 const substitutions = {
516602 HEAD_META : headMetaHtml ( config . site , stats , payload . generatedAt , hasCard ) ,
603+ STACK_ROWS : stackRowsHtml ( stacks ) ,
517604 STARTER_ROWS : starterRowsHtml ( featured ) ,
518605 ENTRY_ROWS : groupedRowsHtml ( visible , config . categories , repoUrl , now ) ,
519606 CATEGORY_CHIPS : categoryChipsHtml ( visible , config . categories ) ,
@@ -533,12 +620,18 @@ function main() {
533620 }
534621 fs . writeFileSync ( indexPath , html ) ;
535622
536- fs . writeFileSync ( path . join ( DIST , "sitemap.xml" ) , sitemapXml ( config . site , visible ) ) ;
623+ fs . writeFileSync ( path . join ( DIST , "sitemap.xml" ) , sitemapXml ( config . site , visible , stacks ) ) ;
537624 fs . writeFileSync ( path . join ( DIST , "robots.txt" ) , robotsTxt ( config . site ) ) ;
538625 fs . writeFileSync ( path . join ( DIST , "404.html" ) , notFoundHtml ( config . site ) ) ;
539626
540- const pages = writeEntryPages ( { entries, bodies, config, stats, stamp, hasCard, now } ) ;
541- const pageErrors = [ ...pages . errors , ...checkPages ( [ "index.html" , ...pages . written ] ) ] ;
627+ const pages = writeEntryPages ( { entries, bodies, config, stats, stamp, hasCard, now, usedIn } ) ;
628+ const stackPages = writeStackPages ( { stacks, entries, config, stats, stamp, hasCard, now } ) ;
629+ const pageErrors = [
630+ ...stackErrors ,
631+ ...pages . errors ,
632+ ...stackPages . errors ,
633+ ...checkPages ( [ "index.html" , ...pages . written , ...stackPages . written ] ) ,
634+ ] ;
542635 if ( pageErrors . length ) {
543636 console . error ( `Page build failed (${ pageErrors . length } ):` ) ;
544637 for ( const e of pageErrors . slice ( 0 , 50 ) ) console . error ( ` - ${ e } ` ) ;
@@ -547,7 +640,7 @@ function main() {
547640
548641 // After the page gate: every body link has resolved by now, so the llms
549642 // files cannot hit a link error of their own.
550- fs . writeFileSync ( path . join ( DIST , "llms.txt" ) , llmsTxt ( { entries, site : config . site , categories : config . categories } ) ) ;
643+ fs . writeFileSync ( path . join ( DIST , "llms.txt" ) , llmsTxt ( { entries, site : config . site , categories : config . categories , stacks } ) ) ;
551644 fs . writeFileSync (
552645 path . join ( DIST , "llms-full.txt" ) ,
553646 llmsFullTxt ( {
@@ -556,14 +649,15 @@ function main() {
556649 categories : config . categories ,
557650 bodies,
558651 resolverFor : linkResolvers ( entries , config ) ,
652+ stacks,
559653 } )
560654 ) ;
561655
562656 console . log (
563657 `Built ${ entries . length } entries → site/dist (${ stats . active } active, ${ stats . commercialOk } commercial-ok, ${ stats . commercialVaries } per-file)`
564658 ) ;
565659 console . log (
566- `Prerendered ${ visible . length } entry rows into index.html; wrote ${ pages . written . length } entry pages, sitemap.xml, robots.txt, 404.html`
660+ `Prerendered ${ visible . length } entry rows into index.html; wrote ${ pages . written . length } entry pages, ${ stackPages . written . length } stack pages, sitemap.xml, robots.txt, 404.html`
567661 ) ;
568662 if ( errors . length ) process . exitCode = 0 ; // soft-fail missing fields as warnings
569663}
0 commit comments