Skip to content

Commit 16956e9

Browse files
Merge pull request #41 from ghostintheshell-192/chore/seo-kb-pages
chore: SEO pass on the KB pages, generated sitemap, analytics with consent
2 parents 72b13d2 + c16a584 commit 16956e9

44 files changed

Lines changed: 2231 additions & 398 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.development/CURRENT-STATUS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ its own (`reference/engine-robustness-and-extraction.md` §8).
3333
linked; a `kb:` block on levels 0/1/5/6/10 with the worked calculation filled from the
3434
engine; `generate-kb.js` (Node, vendored js-yaml, a markdown subset, an evaluator for
3535
`capacityTemplate`) writes 31 pages at commit time, tracked and served static —
36-
`kb.html` redirects, `kb.js` is gone, the sitemap lists them; three suites
36+
`kb.html` is redirected by `vercel.json`, `kb.js` is gone, the sitemap lists them; three suites
3737
(`kb-data`, `kb-worked`, `kb-generator`: 336 tests) keep data, engine and pages
3838
aligned. The engine changed where the pages contradicted it: a mirror's write
3939
parallelism is one copy's width and its penalty the copy count, so RAID 0+1 writes
@@ -210,14 +210,16 @@ decision.
210210
Small, any time:
211211

212212
- ~~**Phantom back links**~~ — gone 2026-09-06 with the KB: `index.html` has a footer
213-
(knowledge base · author), `kb.html` is a redirect.
213+
(knowledge base · author), `kb.html` is redirected by `vercel.json`.
214214
- **Contact form** — the site is static and stays so: `mailto:` + a link to GitHub
215215
issues is the honest baseline; a third-party form service adds a host to the path
216216
(the js-yaml argument); a Vercel function would be the first server-side code. **S**
217217
- ~~**SEO: the content is not in the served HTML**~~ — closed by item 3 (2026-09-06):
218218
the knowledge base is static HTML with a `TechArticle` per page and a sitemap; what
219219
remains is Search Console (the property is still not shared with the MCP account).
220-
- **Google Fonts is the last third-party blocking request** — self-host JetBrains Mono. **S**
220+
- **Google Fonts is the last third-party *blocking* request** — self-host JetBrains Mono.
221+
The site also loads two async third parties now (Cookiebot, Google tag), which do not
222+
block rendering. **S**
221223
- ~~**Push + PR flow**~~ — decided 2026-09-04 and written into `workflow.md` (PR #17):
222224
a PR per branch, merged from GitHub, so the `headless` check is a gate. One caveat
223225
learned the hard way (PR #32): a chained PR whose base branch is merged first must be

.development/automation/docs-update.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,7 @@ run_generator "ARCHITECTURE.md" bash "$SCRIPTS/generate-architecture.sh"
3333
run_generator "INDEX.md" python3 "$SCRIPTS/generate-index.py"
3434
run_generator "tech-debt/README.md" python3 "$SCRIPTS/update-tech-debt-index.py"
3535
# The knowledge base is derived documentation too, only its output is served to
36-
# readers instead of read in the repo (specs/planned/knowledge-base.md §7).
37-
run_generator "kb/" node "$SCRIPTS/generate-kb.js"
36+
# readers instead of read in the repo (specs/planned/knowledge-base.md §7). The
37+
# same run also (re)writes sitemap.xml at the repo root, from the same list of
38+
# pages, so the two cannot go stale relative to each other.
39+
run_generator "kb/ + sitemap.xml" node "$SCRIPTS/generate-kb.js"

.development/scripts/generate-kb.js

Lines changed: 188 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
142149
function 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() {
159181
const plain = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
160182
const 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

172224
const 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

8781040
function 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

8851054
if (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 };

.development/scripts/lib/kb-markdown.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,13 @@ function render(markdown, ctx) {
101101
continue;
102102
}
103103

104-
// heading — `##` in the source is an h3 on the page: the entry's own name
105-
// is the h2, so the author's top level nests one below it.
104+
// heading — `##` in the source is an h2 on the page: the page's own title
105+
// (the entry's name) is the h1, and the generator's own sections (kb.css
106+
// `.kb-section h2`) are h2 too, so the author's top level sits at the same
107+
// depth as those and no page skips from h1 straight to h3.
106108
const heading = HEADING.exec(line);
107109
if (heading) {
108-
const level = Math.min(heading[1].length + 1, 6);
110+
const level = Math.min(heading[1].length, 6);
109111
const text = heading[2].trim();
110112
const id = ctx.headingId ? ` id="${escapeHtml(ctx.headingId(text))}"` : '';
111113
out.push(`<h${level}${id}>${renderInline(text, ctx)}</h${level}>`);

0 commit comments

Comments
 (0)