From 863740a87d312d279ad4edd0b75502c64cbdaf50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Fri, 12 Jun 2026 14:49:20 +0200 Subject: [PATCH 1/2] feat: pre-rendered detail pages for every contract (#611) Contracts were the only first-class catalog entity without their own URLs. Each of the 19 contracts now gets a real pre-rendered page: - render-contracts.js emits per-contract detail fragments (EN + DE) alongside the existing /contracts overview fragment; overview cards link to the detail pages - prerender-routes.js gains prerenderContractPages(), mirroring the anchor-pages loop from #597: /contract/ plus /de/contract/, each with own title, description, canonical URL, og/twitter tags and hreflang pair via applyHead - the SPA router resolves /contract/ to the contracts page, scrolled to the highlighted card, with the real contract title (not a de-kebab-cased slug) in the document title; ids are regex-validated before entering selectors (5 new unit tests, TDD) - sitemap gains the 38 contract URLs; contracts join the JSON-LD DefinedTermSet with their /contract/ URLs App version 0.6.1 -> 0.7.0. Closes #611 Co-Authored-By: Claude Fable 5 --- docs/changelog.adoc | 6 +++ scripts/generate-jsonld.js | 23 +++++++- scripts/generate-sitemap.js | 18 ++++++- scripts/prerender-routes.js | 48 ++++++++++++++++- scripts/render-contracts.js | 90 +++++++++++++++++++++++++++----- website/src/main.js | 2 +- website/src/utils/router.js | 36 +++++++++++++ website/src/utils/router.test.js | 74 ++++++++++++++++++++++++++ 8 files changed, 281 insertions(+), 16 deletions(-) diff --git a/docs/changelog.adoc b/docs/changelog.adoc index 51140b4..1ad10b8 100644 --- a/docs/changelog.adoc +++ b/docs/changelog.adoc @@ -4,6 +4,12 @@ A chronological record of all semantic anchors added to the catalog. Community c == 2026-06-12 +*Direct links to contracts (#611):* + +* Every semantic contract now has its own pre-rendered page at `/contract/`, with a German variant at `/de/contract/` — own title, description, canonical URL, social-share metadata, and hreflang pairs, exactly like the anchor pages from #597. Sharing a single contract (e.g. _Specification_) now produces the right preview instead of the generic contracts-page card. +* The cards on `/contracts` link to their detail pages; in the SPA, a `/contract/` URL opens the contracts page scrolled to the highlighted card with the real contract title in the tab. +* All 38 contract pages joined the sitemap, and contracts are now `DefinedTerm` entities in the site's structured data alongside the anchors — each citable by search engines and retrieval-grounded AI under its own URL. + *Socratic Code-Theory Recovery skill v0.3 (#531, #532):* * *Per-context output files (#531)* — Phase 1 now writes `QUESTION_TREE-.adoc` and `OPEN_QUESTIONS-.adoc` instead of fixed filenames, so the documented "one bounded context at a time" workflow no longer silently overwrites earlier runs; Phase 2 reads the context-specific tree, and ADRs get a context prefix (`adrs/-adr-NNN-*.adoc`) so two contexts cannot clobber each other's decision records. diff --git a/scripts/generate-jsonld.js b/scripts/generate-jsonld.js index 2c0bc97..9db1bd6 100644 --- a/scripts/generate-jsonld.js +++ b/scripts/generate-jsonld.js @@ -28,6 +28,7 @@ const path = require('path') const ROOT = path.join(__dirname, '..') const ANCHORS_JSON = path.join(ROOT, 'website/public/data/anchors.json') +const CONTRACTS_JSON = path.join(ROOT, 'website/public/data/contracts.json') const DIST = path.join(ROOT, 'website/dist') const BASE = 'https://llm-coding.github.io/Semantic-Anchors' const SET_ID = `${BASE}/#catalog` @@ -102,6 +103,26 @@ function buildDefinedTermSet() { return term }) + // Contracts are first-class defined terms too — each has a real page at + // /contract/ since #611. + const contracts = fs.existsSync(CONTRACTS_JSON) + ? JSON.parse(fs.readFileSync(CONTRACTS_JSON, 'utf-8')) + : [] + for (const c of contracts) { + if (!c || !c.id || !c.title) continue + const url = `${BASE}/contract/${c.id}` + const term = { + '@type': 'DefinedTerm', + '@id': url, + name: c.title, + termCode: c.id, + url, + inDefinedTermSet: SET_ID, + } + if (c.description) term.description = c.description + terms.push(term) + } + return { '@context': 'https://schema.org', '@type': 'DefinedTermSet', @@ -109,7 +130,7 @@ function buildDefinedTermSet() { name: 'Semantic Anchors', url: `${BASE}/`, description: - 'A curated catalog of semantic anchors — well-defined terms, methodologies, and frameworks used as shared vocabulary when communicating with Large Language Models.', + 'A curated catalog of semantic anchors and semantic contracts — well-defined terms, methodologies, and frameworks used as shared vocabulary when communicating with Large Language Models.', hasDefinedTerm: terms, } } diff --git a/scripts/generate-sitemap.js b/scripts/generate-sitemap.js index d03ae01..d0be949 100755 --- a/scripts/generate-sitemap.js +++ b/scripts/generate-sitemap.js @@ -120,6 +120,22 @@ anchorsData.forEach((anchor) => { } }) +// Individual contract pages (pre-rendered static pages since #611), each with +// a /de variant — contracts.json carries full German fields for all entries. +const contractsData = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'website/public/data/contracts.json'), 'utf-8') +) +contractsData.forEach((contract) => { + sitemap += urlEntry( + `${BASE_URL}/contract/${contract.id}`, + today, + 'monthly', + '0.6', + `Contract: ${contract.title}` + ) + sitemap += urlEntry(`${BASE_URL}/de/contract/${contract.id}`, today, 'monthly', '0.5') +}) + sitemap += ` ` @@ -127,5 +143,5 @@ fs.writeFileSync(OUTPUT_FILE, sitemap, 'utf-8') console.log(`✓ Sitemap generated: ${OUTPUT_FILE}`) console.log( - `✓ Total URLs: ${PAGES.length + DE_PAGES.length + anchorsData.length + deAnchorCount} (${PAGES.length} pages + ${DE_PAGES.length} German pages + ${anchorsData.length} anchors + ${deAnchorCount} German anchors)` + `✓ Total URLs: ${PAGES.length + DE_PAGES.length + anchorsData.length + deAnchorCount + contractsData.length * 2} (${PAGES.length} pages + ${DE_PAGES.length} German pages + ${anchorsData.length} anchors + ${deAnchorCount} German anchors + ${contractsData.length * 2} contract pages)` ) diff --git a/scripts/prerender-routes.js b/scripts/prerender-routes.js index 1876053..71b18a6 100644 --- a/scripts/prerender-routes.js +++ b/scripts/prerender-routes.js @@ -628,6 +628,51 @@ function writeHomeVariant(shell, lang) { * Throws (via prerenderRoute) if any fragment is missing, so the build * fails non-zero instead of shipping an incomplete set of static pages. */ +/** + * Pre-render one real page per semantic contract — /contract/ plus a + * /de/contract/ variant (#611). Mirrors prerenderAnchorPages: the body + * fragments are written by render-contracts.js into docs/contracts/ and the + * per-page head metadata flows through applyHead. + */ +function prerenderContractPages(shell) { + const contracts = loadWebsiteJson('public/data/contracts.json') + let count = 0 + let skipped = 0 + for (const contract of contracts) { + const fragment = `docs/contracts/${contract.id}.html` + const fragmentDe = `docs/contracts/${contract.id}.de.html` + if (!fs.existsSync(path.join(DIST, fragment))) { + console.warn(` ! skipped /contract/${contract.id} — fragment ${fragment} missing`) + skipped++ + continue + } + const enUrl = `${SITE}/contract/${contract.id}` + const deUrl = `${SITE}/de/contract/${contract.id}` + + writeRouteVariant(shell, `/contract/${contract.id}`, fragment, { + title: `${contract.title} — Semantic Anchors`, + description: `${contract.title} — a semantic contract: ${contract.description}. Composable shared vocabulary for your CLAUDE.md / AGENTS.md.`, + canonicalUrl: enUrl, + enUrl, + deUrl, + lang: 'en', + }) + + writeRouteVariant(shell, `/de/contract/${contract.id}`, fragmentDe, { + title: `${contract.titleDe || contract.title} — Semantic Anchors`, + description: `${contract.titleDe || contract.title} — ein Semantic Contract: ${contract.descriptionDe || contract.description}. Komponierbares gemeinsames Vokabular für deine CLAUDE.md / AGENTS.md.`, + canonicalUrl: deUrl, + enUrl, + deUrl, + lang: 'de', + }) + count++ + } + console.log( + ` ✓ pre-rendered ${count} contract pages (EN + DE${skipped ? `, ${skipped} skipped` : ''})` + ) +} + function main() { const shell = readShell() for (const route of ROUTES) { @@ -636,8 +681,9 @@ function main() { } prerenderHome(shell) prerenderAnchorPages(shell) + prerenderContractPages(shell) console.log( - `\n✓ Pre-rendered ${ROUTES.length} routes + home + anchor pages to dist//index.html` + `\n✓ Pre-rendered ${ROUTES.length} routes + home + anchor + contract pages to dist//index.html` ) } diff --git a/scripts/render-contracts.js b/scripts/render-contracts.js index 999086f..2140c39 100644 --- a/scripts/render-contracts.js +++ b/scripts/render-contracts.js @@ -1,13 +1,18 @@ #!/usr/bin/env node /** - * Render a static HTML fragment from contracts.json for pre-rendering. + * Render static HTML fragments from contracts.json for pre-rendering. * * The contracts page is JS-interactive (checkboxes, download), but crawlers * and LLMs need to see the content without executing JavaScript. - * This script generates a plain HTML summary of all contracts that - * prerender-routes.js injects into the static shell. + * This script generates: + * - a plain HTML summary of all contracts (the /contracts page fragment) + * - one detail fragment per contract, EN and DE, for the pre-rendered + * /contract/ and /de/contract/ pages (#611) * - * Output: website/public/docs/contracts.html + * Output: + * website/public/docs/contracts.html + * website/public/docs/contracts/.html + * website/public/docs/contracts/.de.html * * Usage: node scripts/render-contracts.js */ @@ -18,6 +23,8 @@ const path = require('path') const ROOT = path.join(__dirname, '..') const CONTRACTS_JSON = path.join(ROOT, 'website/public/data/contracts.json') const OUTPUT = path.join(ROOT, 'website/public/docs/contracts.html') +const DETAIL_DIR = path.join(ROOT, 'website/public/docs/contracts') +const BASE = '/Semantic-Anchors' function escapeHtml(str) { return String(str) @@ -40,27 +47,69 @@ function renderTemplate(template) { .join('\n') } -function renderContract(contract) { - const anchors = contract.anchors - .map( - (id) => - `${escapeHtml(id)}` - ) +// German anchor pages exist only where a .de.adoc source does — mirror the +// hasDePage check from prerender-routes.js so DE contract pages link to DE +// anchor pages where available and fall back to the EN page otherwise. +function hasDeAnchorPage(id) { + return fs.existsSync(path.join(ROOT, 'docs/anchors', `${id}.de.adoc`)) +} + +function renderAnchorChips(contract, lang) { + return contract.anchors + .map((id) => { + const href = + lang === 'de' && hasDeAnchorPage(id) + ? `${BASE}/de/anchor/${escapeHtml(id)}` + : `${BASE}/anchor/${escapeHtml(id)}` + return `${escapeHtml(id)}` + }) .join(' ') +} +function renderContract(contract) { return `
-

${escapeHtml(contract.title)}

+

${escapeHtml(contract.title)}

${escapeHtml(contract.description)}

    ${renderTemplate(contract.template)}
-
${anchors}
+
${renderAnchorChips(contract, 'en')}
` } +/** + * Detail-page fragment for one contract (the /contract/ body). + * @param {object} contract - entry from contracts.json + * @param {('en'|'de')} lang + */ +function renderContractDetail(contract, lang) { + const de = lang === 'de' + const title = de ? contract.titleDe || contract.title : contract.title + const description = de ? contract.descriptionDe || contract.description : contract.description + const template = de ? contract.templateDe || contract.template : contract.template + const backText = de ? '← Alle Semantic Contracts' : '← All Semantic Contracts' + const intro = de + ? 'Ein Semantic Contract: Vokabular, das dein Projekt selbst mitliefert — zum Einsetzen in deine CLAUDE.md / AGENTS.md.' + : 'A semantic contract: vocabulary your project supplies itself — ready to drop into your CLAUDE.md / AGENTS.md.' + const relatedHeading = de ? 'Verwandte Anker' : 'Related Anchors' + + return ` +

${backText}

+

${escapeHtml(title)}

+

${escapeHtml(description)}

+

${intro}

+
+
    + ${renderTemplate(template)} +
+
+

${relatedHeading}

+
${renderAnchorChips(contract, lang)}
` +} + function main() { if (!fs.existsSync(CONTRACTS_JSON)) { console.error(`ERROR: ${CONTRACTS_JSON} not found`) @@ -84,6 +133,23 @@ function main() { fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }) fs.writeFileSync(OUTPUT, html, 'utf-8') console.log(`Rendered: ${path.relative(ROOT, OUTPUT)}`) + + fs.mkdirSync(DETAIL_DIR, { recursive: true }) + for (const contract of contracts) { + fs.writeFileSync( + path.join(DETAIL_DIR, `${contract.id}.html`), + renderContractDetail(contract, 'en'), + 'utf-8' + ) + fs.writeFileSync( + path.join(DETAIL_DIR, `${contract.id}.de.html`), + renderContractDetail(contract, 'de'), + 'utf-8' + ) + } + console.log( + `Rendered: ${contracts.length} contract detail fragments (EN + DE) to website/public/docs/contracts/` + ) } main() diff --git a/website/src/main.js b/website/src/main.js index dc23444..049f667 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -27,7 +27,7 @@ import { } from './components/onboarding-modal.js' import { renderContractsPage, initContractsPage } from './components/contracts-page.js' -const APP_VERSION = '0.6.1' +const APP_VERSION = '0.7.0' window.copyAnchorLink = async function copyAnchorLink(anchorId) { const url = `${window.location.origin}${import.meta.env.BASE_URL}anchor/${anchorId}` diff --git a/website/src/utils/router.js b/website/src/utils/router.js index 4a20788..a834c51 100644 --- a/website/src/utils/router.js +++ b/website/src/utils/router.js @@ -245,6 +245,42 @@ function handleRoute() { return } + // Check for contract route (/contract/:id) — pre-rendered as real pages, + // resolved by the SPA to the contracts page scrolled to that contract's + // card (#611). Unlike anchors there is no modal; the card is highlighted + // in place. + if (path.startsWith('/contract/')) { + const contractId = path.replace('/contract/', '') + const safeContractId = /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(contractId) ? contractId : null + if (!safeContractId) return + + closeOpenAnchorModal() + const contractsHandler = routes.get('/contracts') + if (typeof contractsHandler === 'function') { + currentRoute = '/contracts' + contractsHandler() + } + trackPageview() + + // The contracts page renders asynchronously; locate the card on the + // next tick, title the page after its heading (real title, not a + // de-kebab-cased slug), and bring it into view. + setTimeout(() => { + const card = document.querySelector(`[data-contract-id="${safeContractId}"]`) + const heading = card ? card.querySelector('h3') : null + const readableName = + (heading && heading.textContent.trim()) || + safeContractId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + document.title = `${readableName} — Semantic Anchors` + if (card) { + card.scrollIntoView({ behavior: 'smooth', block: 'start' }) + card.classList.add('ring-2', 'ring-blue-400') + setTimeout(() => card.classList.remove('ring-2', 'ring-blue-400'), 2500) + } + }, 0) + return + } + // Leaving an anchor route: close any open anchor modal so Back/forward and // in-app navigation don't leave it stranded as an overlay over the page. closeOpenAnchorModal() diff --git a/website/src/utils/router.test.js b/website/src/utils/router.test.js index f6ac672..8230770 100644 --- a/website/src/utils/router.test.js +++ b/website/src/utils/router.test.js @@ -90,3 +90,77 @@ describe('router language prefix (/de)', () => { expect(i18n.currentLang()).toBe('en') }) }) + +describe('router contract route (/contract/:id)', () => { + beforeEach(() => { + history.replaceState(null, '', '/') + window.location.hash = '' + localStorage.clear() + i18n.init() + document.body.innerHTML = '' + // jsdom does not implement scrollIntoView + window.Element.prototype.scrollIntoView = vi.fn() + }) + + function addContractsRoute() { + const handler = vi.fn(() => { + document.body.innerHTML = ` +

Specification

` + }) + addRoute('/contracts', handler) + return handler + } + + it('renders the contracts page for /contract/:id', async () => { + const handler = addContractsRoute() + history.replaceState(null, '', '/contract/specification') + + window.dispatchEvent(new PopStateEvent('popstate')) + await new Promise((r) => setTimeout(r, 0)) + + expect(handler).toHaveBeenCalled() + }) + + it('sets the document title from the contract card heading', async () => { + addContractsRoute() + history.replaceState(null, '', '/contract/specification') + + window.dispatchEvent(new PopStateEvent('popstate')) + await new Promise((r) => setTimeout(r, 0)) + + expect(document.title).toBe('Specification — Semantic Anchors') + }) + + it('scrolls the contract card into view', async () => { + addContractsRoute() + const scrollSpy = vi.fn() + window.Element.prototype.scrollIntoView = scrollSpy + history.replaceState(null, '', '/contract/specification') + + window.dispatchEvent(new PopStateEvent('popstate')) + await new Promise((r) => setTimeout(r, 0)) + + expect(scrollSpy).toHaveBeenCalled() + }) + + it('rejects unsafe contract ids', async () => { + const handler = addContractsRoute() + history.replaceState(null, '', '/contract/NOT%20a%20valid..id') + + window.dispatchEvent(new PopStateEvent('popstate')) + await new Promise((r) => setTimeout(r, 0)) + + expect(handler).not.toHaveBeenCalled() + }) + + it('switches to German for /de/contract/:id', async () => { + const handler = addContractsRoute() + history.replaceState(null, '', '/de/contract/specification') + + window.dispatchEvent(new PopStateEvent('popstate')) + await new Promise((r) => setTimeout(r, 0)) + + expect(handler).toHaveBeenCalled() + expect(i18n.currentLang()).toBe('de') + }) +}) From ef8560773737442c1aa13c156f2d35e28c0fb892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Fri, 12 Jun 2026 15:01:30 +0200 Subject: [PATCH 2/2] fix: validate contract ids in build scripts before path and URL use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both render-contracts.js and prerenderContractPages() now check contract.id against the same SAFE_ID_PATTERN the SPA router enforces, and skip (with a warning) any entry that fails — defense-in-depth so a malformed contracts.json entry cannot reach path.join() or URL templates. Found by CodeRabbit review. Refs #611 Co-Authored-By: Claude Fable 5 --- scripts/prerender-routes.js | 7 +++++++ scripts/render-contracts.js | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/scripts/prerender-routes.js b/scripts/prerender-routes.js index 71b18a6..9e5439f 100644 --- a/scripts/prerender-routes.js +++ b/scripts/prerender-routes.js @@ -638,7 +638,14 @@ function prerenderContractPages(shell) { const contracts = loadWebsiteJson('public/data/contracts.json') let count = 0 let skipped = 0 + // Mirror the router's id validation before ids enter paths and URLs. + const SAFE_CONTRACT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ for (const contract of contracts) { + if (!SAFE_CONTRACT_ID.test(contract.id)) { + console.warn(` ! skipped contract with unsafe id: ${JSON.stringify(contract.id)}`) + skipped++ + continue + } const fragment = `docs/contracts/${contract.id}.html` const fragmentDe = `docs/contracts/${contract.id}.de.html` if (!fs.existsSync(path.join(DIST, fragment))) { diff --git a/scripts/render-contracts.js b/scripts/render-contracts.js index 2140c39..83dd4d0 100644 --- a/scripts/render-contracts.js +++ b/scripts/render-contracts.js @@ -26,6 +26,11 @@ const OUTPUT = path.join(ROOT, 'website/public/docs/contracts.html') const DETAIL_DIR = path.join(ROOT, 'website/public/docs/contracts') const BASE = '/Semantic-Anchors' +// Same pattern the SPA router enforces before using ids in DOM selectors — +// here it guards path.join/file writes against a malformed contracts.json +// entry (defense-in-depth; the file is repo-maintained and trusted). +const SAFE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + function escapeHtml(str) { return String(str) .replace(/&/g, '&') @@ -136,6 +141,10 @@ function main() { fs.mkdirSync(DETAIL_DIR, { recursive: true }) for (const contract of contracts) { + if (!SAFE_ID_PATTERN.test(contract.id)) { + console.warn(` ! skipped contract with unsafe id: ${JSON.stringify(contract.id)}`) + continue + } fs.writeFileSync( path.join(DETAIL_DIR, `${contract.id}.html`), renderContractDetail(contract, 'en'),