@@ -138,6 +138,13 @@ function loadData() {
138138 * JSON-LD rather than restated here: the `isPartOf` and `author` of every
139139 * generated page are then the same facts the home page already publishes, and
140140 * they cannot drift (the no-unverifiable-claims rule).
141+ *
142+ * The Open Graph / Twitter identity is copied the same way, from index.html's
143+ * own `<meta property="og:...">` tags: `og:site_name` and the `og:image`
144+ * quadruple. A generated page has no figure of its own, so the site's one
145+ * preview image (the same the home page uses) is the only honest choice —
146+ * copying it, rather than hardcoding it here, is what keeps the two from
147+ * drifting if the image is ever replaced.
141148 */
142149function readSiteIdentity ( ) {
143150 const html = fs . readFileSync ( path . join ( ROOT , 'index.html' ) , 'utf8' ) ;
@@ -146,8 +153,23 @@ function readSiteIdentity() {
146153 let ld ;
147154 try { ld = JSON . parse ( m [ 1 ] ) ; } catch ( e ) { fail ( `index.html: the JSON-LD block does not parse (${ e . message } )` ) ; }
148155 if ( ! ld . name || ! ld . url ) fail ( 'index.html: the JSON-LD block has no name/url' ) ;
156+
157+ const ogMeta = ( property ) => {
158+ const re = new RegExp ( `<meta property="${ property } " content="([^"]*)"` ) ;
159+ const hit = re . exec ( html ) ;
160+ if ( ! hit || ! hit [ 1 ] ) fail ( `index.html: no <meta property="${ property } "> to copy into the knowledge base pages` ) ;
161+ return hit [ 1 ] ;
162+ } ;
163+ const og = {
164+ siteName : ogMeta ( 'og:site_name' ) ,
165+ image : ogMeta ( 'og:image' ) ,
166+ imageWidth : ogMeta ( 'og:image:width' ) ,
167+ imageHeight : ogMeta ( 'og:image:height' ) ,
168+ imageAlt : ogMeta ( 'og:image:alt' ) ,
169+ } ;
170+
149171 return { app : { '@type' : ld [ '@type' ] || 'WebApplication' , name : ld . name , url : ld . url } ,
150- author : ld . author || null , publisher : ld . publisher || null } ;
172+ author : ld . author || null , publisher : ld . publisher || null , og } ;
151173}
152174
153175// ---------------------------------------------------------------------------
@@ -159,14 +181,44 @@ function readSiteIdentity() {
159181const plain = ( s ) => String ( s == null ? '' : s ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
160182const shortOf = ( entry ) => plain ( entry . kind ? entry . short : entry . kb . short ) ;
161183
162- /** A meta description: one or two sentences of the short form, never invented. */
163- function metaDescription ( text , limit = 200 ) {
184+ // A cheap guard against the one abbreviation the sentence-boundary regex
185+ // below would otherwise misread as a sentence end (a period, a space, a
186+ // capital letter): "e.g. Something" reads exactly like two sentences to that
187+ // rule. A short, known list is enough — the alternative (a real abbreviation
188+ // dictionary) is not worth it for a handful of knowledge-base authors.
189+ const ABBREVIATIONS = [ 'e.g' , 'i.e' , 'etc' , 'vs' , 'cf' ] ;
190+ const endsWithAbbreviation = ( textBeforePunctuation ) => {
191+ const lower = textBeforePunctuation . toLowerCase ( ) ;
192+ return ABBREVIATIONS . some ( ( abbr ) => lower . endsWith ( abbr ) ) ;
193+ } ;
194+
195+ /**
196+ * A meta description: whole sentences from the start of `short`, kept while
197+ * the result stays within `limit` characters (search engines truncate a
198+ * `<meta name="description">` around 160) — never a mid-sentence cut, and
199+ * never empty: the first sentence is kept even if it alone runs past the
200+ * limit, because search engines truncating on their own reads better than a
201+ * cut we chose ourselves. A sentence ends at ". ", "! " or "? " followed by a
202+ * capital letter — cheap enough that a data file never has to spell one out.
203+ */
204+ function metaDescription ( text , limit = 160 ) {
164205 const s = plain ( text ) ;
165- if ( s . length <= limit ) return s ;
166- const cut = s . slice ( 0 , limit ) ;
167- const stop = Math . max ( cut . lastIndexOf ( '. ' ) , cut . lastIndexOf ( '; ' ) ) ;
168- if ( stop > limit / 2 ) return cut . slice ( 0 , stop + 1 ) ;
169- return cut . slice ( 0 , cut . lastIndexOf ( ' ' ) ) + '…' ;
206+ const boundary = / [ . ! ? ] (? = [ A - Z ] ) / g;
207+ // Every sentence but the last ends right before a capital letter that
208+ // starts the next one, which is what `boundary` finds; the last sentence's
209+ // end has nothing after it to match on, so it is added explicitly — without
210+ // it, a two-sentence `short` could never keep its second sentence at all.
211+ const cuts = [ ] ;
212+ let m ;
213+ while ( ( m = boundary . exec ( s ) ) ) {
214+ if ( endsWithAbbreviation ( s . slice ( 0 , m . index ) ) ) continue ;
215+ cuts . push ( m . index + 1 ) ; // include the punctuation, drop the space after it
216+ }
217+ cuts . push ( s . length ) ;
218+
219+ let end = cuts [ 0 ] ; // the first sentence is kept regardless of length
220+ for ( let i = 1 ; i < cuts . length && cuts [ i ] <= limit ; i ++ ) end = cuts [ i ] ;
221+ return s . slice ( 0 , end ) ;
170222}
171223
172224const classWords = ( shape ) => {
@@ -464,19 +516,32 @@ function pageToc(toc) {
464516 ] . join ( '\n' ) ;
465517}
466518
467- function chrome ( { file, title, description, heading, subtitle, body, ctx, side = true , toc = null , kind = 'page' } ) {
519+ // kb/index.html is the map: the sitemap and the home page both link to it as
520+ // the directory `kb/`, not the file, so its canonical, og:url and JSON-LD url
521+ // have to say the same thing rather than a URL nothing else ever points at.
522+ // Every other page is a file of its own — the directory form makes no sense
523+ // for it.
524+ const pageUrl = ( file ) => file === 'index.html' ? `${ SITE } /kb/` : `${ SITE } /kb/${ file } ` ;
525+
526+ // `description` (capped at a sentence boundary, metaDescription()) feeds the
527+ // meta tag and og:description, both of which real crawlers truncate anyway.
528+ // `fullDescription` feeds the JSON-LD `description`, which is not a snippet
529+ // shown in a results list but the page's own first lines restated — cutting
530+ // it the same way would just be losing text nothing forced us to lose.
531+ function chrome ( { file, title, description, fullDescription = description , heading, subtitle, body, ctx, side = true , toc = null , kind = 'page' } ) {
468532 const nav = NAV . map ( ( n ) => n . file === file
469533 ? ` <span class="kb-nav-item active" aria-current="page">${ n . label } </span>`
470534 : ` <a class="kb-nav-item" href="${ n . file } ">${ n . label } </a>` )
471535 . concat ( [ ` <a class="kb-nav-item" href="../index.html">Sandbox</a>` ] ) . join ( '\n' ) ;
472536
537+ const url = pageUrl ( file ) ;
473538 const ld = {
474539 '@context' : 'https://schema.org' ,
475540 '@type' : 'TechArticle' ,
476541 headline : heading ,
477- description,
542+ description : fullDescription ,
478543 inLanguage : 'en' ,
479- url : ` ${ SITE } /kb/ ${ file } ` ,
544+ url,
480545 isPartOf : { '@type' : ctx . site . app [ '@type' ] , name : ctx . site . app . name , url : ctx . site . app . url } ,
481546 } ;
482547 if ( ctx . site . author ) ld . author = ctx . site . author ;
@@ -487,11 +552,62 @@ function chrome({ file, title, description, heading, subtitle, body, ctx, side =
487552<head>
488553 <meta charset="UTF-8">
489554 <meta name="viewport" content="width=device-width, initial-scale=1.0">
555+
556+ <!-- Cookiebot loads first so its consent decision exists before any Google
557+ script can read it, then the Consent Mode default state, then Google's
558+ tag. This block is byte-identical on every page (source of truth:
559+ index.html, copied into the generator template). -->
560+ <script id="Cookiebot" src="https://consent.cookiebot.com/uc.js" data-cbid="11322285-cc73-4d07-a7e8-be34dc027c4e" type="text/javascript" async></script>
561+ <!-- Consent Mode v2 default state: Cookiebot's own inline-implementation guide
562+ says a default must be set manually and must precede gtag.js — this is
563+ that snippet, verbatim, from
564+ https://support.cookiebot.com/hc/en-us/articles/360016047000-Implementing-Google-Consent-Mode -->
565+ <script data-cookieconsent="ignore">
566+ window.dataLayer = window.dataLayer || [];
567+ function gtag(){dataLayer.push(arguments);}
568+ gtag('consent', 'default', {
569+ 'ad_personalization': 'denied',
570+ 'ad_storage': 'denied',
571+ 'ad_user_data': 'denied',
572+ 'analytics_storage': 'denied',
573+ 'functionality_storage': 'denied',
574+ 'personalization_storage': 'denied',
575+ 'security_storage': 'granted',
576+ 'wait_for_update': 500,
577+ });
578+ gtag('set', 'ads_data_redaction', true);
579+ gtag('set', 'url_passthrough', false);
580+ </script>
581+ <!-- Google tag (gtag.js) -->
582+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-DQR5VQ6VXX"></script>
583+ <script>
584+ window.dataLayer = window.dataLayer || [];
585+ function gtag(){dataLayer.push(arguments);}
586+ gtag('js', new Date());
587+
588+ gtag('config', 'G-DQR5VQ6VXX');
589+ </script>
590+
490591 <title>${ escapeHtml ( title ) } </title>
491592 <meta name="description" content="${ escapeHtml ( description ) } ">
492- <link rel="canonical" href="${ SITE } /kb/ ${ file } ">
593+ <link rel="canonical" href="${ url } ">
493594 <meta name="theme-color" content="#0a0e14">
494595
596+ <!-- Social preview. og:url/og:title/og:description mirror what this page
597+ already declares above; og:image is the site's own preview image
598+ (copied from index.html — see readSiteIdentity), since a knowledge-base
599+ page has no figure of its own to offer instead. -->
600+ <meta property="og:type" content="article">
601+ <meta property="og:site_name" content="${ escapeHtml ( ctx . site . og . siteName ) } ">
602+ <meta property="og:url" content="${ url } ">
603+ <meta property="og:title" content="${ escapeHtml ( title ) } ">
604+ <meta property="og:description" content="${ escapeHtml ( description ) } ">
605+ <meta property="og:image" content="${ escapeHtml ( ctx . site . og . image ) } ">
606+ <meta property="og:image:width" content="${ escapeHtml ( ctx . site . og . imageWidth ) } ">
607+ <meta property="og:image:height" content="${ escapeHtml ( ctx . site . og . imageHeight ) } ">
608+ <meta property="og:image:alt" content="${ escapeHtml ( ctx . site . og . imageAlt ) } ">
609+ <meta name="twitter:card" content="summary_large_image">
610+
495611 <link rel="icon" href="../favicon.svg" type="image/svg+xml">
496612 <link rel="apple-touch-icon" href="../apple-touch-icon.png">
497613
@@ -560,7 +676,11 @@ function levelPage(def, ctx) {
560676 } ;
561677
562678 // 1 — the level's own story, under its class in words and its short form.
563- out . push ( ' <section class="kb-section" id="what-it-is">' ) ;
679+ // No id on the section itself: kb.long always opens with "## What it is",
680+ // whose rendered heading already gets that id (headingId: slug below) —
681+ // giving the wrapper the same id would duplicate it (invalid HTML) and make
682+ // the anchor resolve to the section instead of the heading it names.
683+ out . push ( ' <section class="kb-section">' ) ;
564684 out . push ( ` <p class="kb-lede">${ escapeHtml ( shortOf ( def ) ) } </p>` ) ;
565685 if ( def . kb . long ) out . push ( indent ( md ( def . kb . long , `${ where } : kb.long` ) , 4 ) ) ;
566686 out . push ( ' </section>' ) ;
@@ -614,11 +734,12 @@ function levelPage(def, ctx) {
614734 // 9 — related concepts, and the levels this one is confused with
615735 section ( 'see-also' , 'See also' , seeAlso ( def , ctx ) ) ;
616736
617- const description = metaDescription ( shortOf ( def ) ) ;
737+ const full = shortOf ( def ) ;
618738 return chrome ( {
619739 file : `${ def . id } .html` ,
620740 title : `${ def . name } — RAID Sandbox knowledge base` ,
621- description,
741+ description : metaDescription ( full ) ,
742+ fullDescription : full ,
622743 heading : def . name ,
623744 subtitle : escapeHtml ( classWords ( def . shape ) ) ,
624745 body : out . join ( '\n' ) ,
@@ -732,6 +853,7 @@ function mapPage(ctx) {
732853 file : 'index.html' ,
733854 title : 'RAID knowledge base — the map' ,
734855 description : metaDescription ( summary ) ,
856+ fullDescription : summary ,
735857 heading : 'RAID knowledge base' ,
736858 subtitle : 'Map' ,
737859 body : out . join ( '\n' ) ,
@@ -787,6 +909,7 @@ function conceptPage(entry, ctx) {
787909 file : `${ entry . id } .html` ,
788910 title : `${ entry . name } — RAID Sandbox knowledge base` ,
789911 description : metaDescription ( shortOf ( entry ) ) ,
912+ fullDescription : shortOf ( entry ) ,
790913 heading : entry . name ,
791914 subtitle : entry . kind === 'concept' ? 'Concept' : 'Term' ,
792915 body : out . join ( '\n' ) ,
@@ -841,6 +964,45 @@ function indent(text, n) {
841964 } ) . join ( '\n' ) ;
842965}
843966
967+ // ---------------------------------------------------------------------------
968+ // SITEMAP — built from the same list of pages this run produced, so a page
969+ // added later cannot be forgotten the way a hand-maintained file can.
970+ // ---------------------------------------------------------------------------
971+
972+ /**
973+ * sitemap.xml: the home page, the map (as the directory `kb/` — its own
974+ * canonical, see `pageUrl`), then every other generated page in the order
975+ * `generate()` produced it. A bare loc, nothing else: no last-modified date,
976+ * change frequency or priority. Google ignores the last two, and the only
977+ * last-modified date this script could name — the git commit date of
978+ * whichever source file built a page — lags one commit behind under the
979+ * pre-commit hook (the commit that changes the source is not yet made when
980+ * this runs) and is unavailable in a shallow CI clone; a wrong date is worse
981+ * than none.
982+ */
983+ function buildSitemap ( pageNames ) {
984+ const urls = [
985+ `${ SITE } /` ,
986+ `${ SITE } /kb/` ,
987+ ...pageNames . filter ( ( name ) => name !== 'index.html' ) . map ( ( name ) => `${ SITE } /kb/${ name } ` ) ,
988+ ] ;
989+ const body = urls . map ( ( u ) => ` <url>\n <loc>${ u } </loc>\n </url>` ) . join ( '\n' ) ;
990+ return `<?xml version="1.0" encoding="UTF-8"?>
991+ <!-- Generated by .development/scripts/generate-kb.js from the list of pages it
992+ writes — do not edit by hand, run the generator instead. Each entry is a
993+ bare loc, nothing else: no last-modified date, change frequency or
994+ priority. The only last-modified date this script could name is the git
995+ commit date of whichever source file built a page, which is unavailable
996+ in a shallow CI clone and lags one commit behind under the pre-commit
997+ hook, so a wrong date is worse than none; change frequency and priority
998+ are dropped for the same reason Google gives for ignoring them — they
999+ carry no information a crawler trusts. -->
1000+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1001+ ${ body }
1002+ </urlset>
1003+ ` ;
1004+ }
1005+
8441006// ---------------------------------------------------------------------------
8451007// MAIN
8461008// ---------------------------------------------------------------------------
@@ -876,15 +1038,22 @@ function generate(outDir) {
8761038}
8771039
8781040function main ( argv ) {
879- const i = argv . indexOf ( '--out' ) ;
880- const outDir = i >= 0 ? path . resolve ( argv [ i + 1 ] ) : path . join ( ROOT , 'kb' ) ;
1041+ const outFlag = argv . indexOf ( '--out' ) ;
1042+ const outDir = outFlag >= 0 ? path . resolve ( argv [ outFlag + 1 ] ) : path . join ( ROOT , 'kb' ) ;
1043+ // --sitemap exists so the test suite can redirect this file the same way
1044+ // --out redirects the pages: sitemap.xml lives at the repo root regardless
1045+ // of --out, because it is a top-level site file, not part of kb/'s output.
1046+ const sitemapFlag = argv . indexOf ( '--sitemap' ) ;
1047+ const sitemapPath = sitemapFlag >= 0 ? path . resolve ( argv [ sitemapFlag + 1 ] ) : path . join ( ROOT , 'sitemap.xml' ) ;
1048+
8811049 const written = generate ( outDir ) ;
882- console . log ( `generate-kb: ${ written . length } pages in ${ path . relative ( ROOT , outDir ) || '.' } ` ) ;
1050+ fs . writeFileSync ( sitemapPath , buildSitemap ( written ) , 'utf8' ) ;
1051+ console . log ( `generate-kb: ${ written . length } pages in ${ path . relative ( ROOT , outDir ) || '.' } , sitemap at ${ path . relative ( ROOT , sitemapPath ) || '.' } ` ) ;
8831052}
8841053
8851054if ( require . main === module ) {
8861055 try { main ( process . argv . slice ( 2 ) ) ; }
8871056 catch ( e ) { console . error ( `generate-kb: ${ e . message } ` ) ; process . exit ( 1 ) ; }
8881057}
8891058
890- module . exports = { generate, LAYER_ORDER , CONCEPT_ORDER } ;
1059+ module . exports = { generate, buildSitemap , metaDescription , LAYER_ORDER , CONCEPT_ORDER } ;
0 commit comments