Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`, with a German variant at `/de/contract/<id>` — 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/<id>` 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-<context>.adoc` and `OPEN_QUESTIONS-<context>.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/<context>-adr-NNN-*.adoc`) so two contexts cannot clobber each other's decision records.
Expand Down
23 changes: 22 additions & 1 deletion scripts/generate-jsonld.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -102,14 +103,34 @@ function buildDefinedTermSet() {
return term
})

// Contracts are first-class defined terms too — each has a real page at
// /contract/<id> 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',
'@id': SET_ID,
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,
}
}
Expand Down
18 changes: 17 additions & 1 deletion scripts/generate-sitemap.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,28 @@ 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 += `</urlset>
`

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)`
)
55 changes: 54 additions & 1 deletion scripts/prerender-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,58 @@ 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/<id> plus a
* /de/contract/<id> 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
// 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))) {
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) {
Expand All @@ -636,8 +688,9 @@ function main() {
}
prerenderHome(shell)
prerenderAnchorPages(shell)
prerenderContractPages(shell)
console.log(
`\n✓ Pre-rendered ${ROUTES.length} routes + home + anchor pages to dist/<route>/index.html`
`\n✓ Pre-rendered ${ROUTES.length} routes + home + anchor + contract pages to dist/<route>/index.html`
)
}

Expand Down
99 changes: 87 additions & 12 deletions scripts/render-contracts.js
Original file line number Diff line number Diff line change
@@ -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/<id> and /de/contract/<id> pages (#611)
*
* Output: website/public/docs/contracts.html
* Output:
* website/public/docs/contracts.html
* website/public/docs/contracts/<id>.html
* website/public/docs/contracts/<id>.de.html
*
* Usage: node scripts/render-contracts.js
*/
Expand All @@ -18,6 +23,13 @@ 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'

// 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)
Expand All @@ -40,27 +52,69 @@ function renderTemplate(template) {
.join('\n')
}

function renderContract(contract) {
const anchors = contract.anchors
.map(
(id) =>
`<a href="/Semantic-Anchors/anchor/${escapeHtml(id)}" class="inline-block rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs text-blue-700 dark:text-blue-300">${escapeHtml(id)}</a>`
)
// 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 `<a href="${href}" class="inline-block rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs text-blue-700 dark:text-blue-300">${escapeHtml(id)}</a>`
})
.join(' ')
}

function renderContract(contract) {
return `
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-5 mb-4">
<h3 class="text-lg font-semibold mb-1">${escapeHtml(contract.title)}</h3>
<h3 class="text-lg font-semibold mb-1"><a href="${BASE}/contract/${escapeHtml(contract.id)}" class="hover:underline">${escapeHtml(contract.title)}</a></h3>
<p class="text-sm text-[var(--color-text-secondary)] mb-3">${escapeHtml(contract.description)}</p>
<div class="rounded-md bg-[var(--color-bg-secondary)] p-3 mb-3 text-sm leading-relaxed">
<ul class="list-disc list-inside space-y-1">
${renderTemplate(contract.template)}
</ul>
</div>
<div class="flex flex-wrap gap-1.5">${anchors}</div>
<div class="flex flex-wrap gap-1.5">${renderAnchorChips(contract, 'en')}</div>
</div>`
}

/**
* Detail-page fragment for one contract (the /contract/<id> 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 `
<p class="mb-4"><a href="${BASE}/contracts" class="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors">${backText}</a></p>
<h1>${escapeHtml(title)}</h1>
<p class="text-[var(--color-text-secondary)] mb-2">${escapeHtml(description)}</p>
<p class="text-sm text-[var(--color-text-secondary)] mb-4">${intro}</p>
<div class="rounded-md bg-[var(--color-bg-secondary)] p-4 mb-4 text-sm leading-relaxed">
<ul class="list-disc list-inside space-y-1">
${renderTemplate(template)}
</ul>
</div>
<h2 class="text-lg font-semibold mb-2">${relatedHeading}</h2>
<div class="flex flex-wrap gap-1.5">${renderAnchorChips(contract, lang)}</div>`
}

function main() {
if (!fs.existsSync(CONTRACTS_JSON)) {
console.error(`ERROR: ${CONTRACTS_JSON} not found`)
Expand All @@ -84,6 +138,27 @@ 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) {
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'),
'utf-8'
)
fs.writeFileSync(
path.join(DETAIL_DIR, `${contract.id}.de.html`),
renderContractDetail(contract, 'de'),
'utf-8'
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
console.log(
`Rendered: ${contracts.length} contract detail fragments (EN + DE) to website/public/docs/contracts/`
)
}

main()
2 changes: 1 addition & 1 deletion website/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
36 changes: 36 additions & 0 deletions website/src/utils/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading