From 80021d2bbf2b55a165467512b159f5d2d91433d7 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 11 Jul 2026 21:47:52 +0200 Subject: [PATCH 01/22] feat: build the flashtrace marketing + documentation website Static site generated by a plain Node script (build.mjs) with marked as the only dependency, in line with flashtrace's zero-dependency ethos: - Landing page with a CSS-built IDE hero mock, how-it-works steps, feature grid, curated examples captured from real flashtrace runs, and install snippets. - Docs rendered from the tool repo's docs/ at its latest release tag; nav order derived from docs/index.md, inter-doc links rewritten to clean URLs, right-rail TOC with scrollspy, light/dark theme. - deploy.yml publishes to GitHub Pages on push, manual dispatch, and the repository_dispatch (flashtrace-release) fired by the tool repo. - serve.mjs: stdlib-only dev server with watch + rebuild. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 71 +++ .gitignore | 3 + README.md | 41 ++ build.mjs | 178 ++++++ package.json | 17 + pnpm-lock.yaml | 24 + public/.nojekyll | 0 public/favicon.svg | 4 + public/logo.svg | 9 + serve.mjs | 63 ++ src/examples.mjs | 174 ++++++ src/landing.mjs | 254 ++++++++ src/layout.mjs | 159 +++++ src/scripts/site.js | 130 ++++ src/styles/site.css | 1085 ++++++++++++++++++++++++++++++++++ 15 files changed, 2212 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 public/.nojekyll create mode 100644 public/favicon.svg create mode 100644 public/logo.svg create mode 100644 serve.mjs create mode 100644 src/examples.mjs create mode 100644 src/landing.mjs create mode 100644 src/layout.mjs create mode 100644 src/scripts/site.js create mode 100644 src/styles/site.css diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7510849 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy site + +on: + push: + branches: [main] + workflow_dispatch: {} + # fired by flashtrace/flashtrace's release workflow after every release, + # with client_payload.tag = the new release tag + repository_dispatch: + types: [flashtrace-release] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # docs are built from the released tool, not its main branch: take the + # tag from the dispatch payload, or fall back to the latest release + - name: Resolve flashtrace release ref + id: ref + run: | + tag="${{ github.event.client_payload.tag }}" + if [ -z "$tag" ]; then + tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: flashtrace/flashtrace + ref: ${{ steps.ref.outputs.tag }} + path: flashtrace + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + env: + FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06ad4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +flashtrace/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..92e9067 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# flashtrace.github.io + +The website of [flashtrace](https://github.com/flashtrace/flashtrace) — a landing page plus +documentation rendered from the tool repository's `docs/`, served by GitHub Pages at +[flashtrace.github.io](https://flashtrace.github.io/). + +Built in the flashtrace spirit: plain static HTML/CSS/JS, no runtime framework, and a single +build-time dependency ([marked](https://github.com/markedjs/marked)). + +## How it works + +- `build.mjs` reads the tool repository's `docs/` (nav order derived from `docs/index.md`), + renders every page through `marked`, rewrites inter-doc links to clean URLs and emits a + fully static site into `dist/`. +- The landing page (`src/landing.mjs`) is hand-written HTML: a CSS-only IDE mock in the hero, + feature grid, curated interactive examples (`src/examples.mjs`, captured from real + `flashtrace` runs) and the install snippets. +- `.github/workflows/deploy.yml` builds and deploys on every push to `main`, on manual + `workflow_dispatch`, and on a `repository_dispatch` of type `flashtrace-release` that the + tool repository fires after each release. Docs are always checked out at the release tag + (from the dispatch payload, or the latest release otherwise) — the site documents released + behavior, not `main`. + +## Local development + +```sh +git clone https://github.com/flashtrace/flashtrace ../flashtrace # docs source, once +pnpm install +pnpm build # emits dist/ +pnpm dev # build + watch + serve on http://localhost:8788 +``` + +The docs directory is resolved in this order: `$FLASHTRACE_DOCS` → `./flashtrace/docs` +(the CI checkout location) → `../flashtrace/docs` (local sibling checkout). Set +`FLASHTRACE_REF` to override the version label rendered in the header and footer. + +`dist/` is never committed; CI builds it fresh on every deploy. + +## License + +[Apache 2.0](LICENSE), like flashtrace itself. diff --git a/build.mjs b/build.mjs new file mode 100644 index 0000000..2b2635f --- /dev/null +++ b/build.mjs @@ -0,0 +1,178 @@ +// Static site generator: renders the flashtrace tool repo's docs/ plus the +// hand-written landing page into dist/. Pure Node + marked, no framework. +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { Marked } from 'marked'; + +import { docShell, esc, GITHUB_URL, highlightTokens } from './src/layout.mjs'; +import { renderLanding } from './src/landing.mjs'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(root, 'dist'); + +// --- locate the tool repo's docs (env → CI checkout → local sibling) ------- + +function locateDocs() { + const candidates = [ + process.env.FLASHTRACE_DOCS, + path.join(root, 'flashtrace', 'docs'), + path.join(root, '..', 'flashtrace', 'docs'), + ].filter(Boolean); + for (const c of candidates) if (existsSync(path.join(c, 'index.md'))) return c; + console.error( + 'error: flashtrace docs not found. Set FLASHTRACE_DOCS, or clone the tool repo:\n' + + ' git clone https://github.com/flashtrace/flashtrace ../flashtrace', + ); + process.exit(1); +} + +const docsDir = locateDocs(); + +// --- version: release tag from env, else the tool repo's package.json ------ + +function readVersion() { + const ref = process.env.FLASHTRACE_REF; + if (ref) return ref.startsWith('v') ? ref : `v${ref}`; + try { + const pkg = JSON.parse(readFileSync(path.join(docsDir, '..', 'package.json'), 'utf8')); + if (pkg.version) return `v${pkg.version}`; + } catch { + /* fall through */ + } + return ''; +} + +const version = readVersion(); +const gitRef = version || 'main'; // for links into the tool repo on github.com + +// --- nav order: derived from docs/index.md, the single source of truth ----- + +const indexMd = readFileSync(path.join(docsDir, 'index.md'), 'utf8'); +const specPages = [...indexMd.matchAll(/\[([^\]]+)\]\(docs\/([A-Za-z0-9_-]+)\.md\)/g)].map( + (m) => ({ title: m[1], slug: m[2], file: `${m[2]}.md` }), +); +if (specPages.length === 0) { + console.error('error: no doc links found in docs/index.md — nav derivation failed.'); + process.exit(1); +} + +const pages = [ + { title: 'Usage Guide', slug: 'usage', file: 'USAGE.md' }, + { title: 'Overview', slug: '', file: 'index.md' }, + ...specPages, +]; +const slugByName = new Map(pages.map((p) => [p.file.replace(/\.md$/, ''), p.slug])); + +// --- markdown rendering ------------------------------------------------------ + +function slugify(html) { + return html + .replace(/<[^>]*>/g, '') + .replace(/&[a-z]+;|&#\d+;/gi, '') + .toLowerCase() + .trim() + .replace(/[^\w\- ]/g, '') + .replace(/ /g, '-'); +} + +// Rewrite the tool repo's relative links to the site's clean URLs. index.md +// links as docs/.md; USAGE.md and the spec pages link bare .md — +// both forms are handled. Other repo-relative paths go to github.com. +function rewriteHref(href) { + if (/^(https?:|mailto:|#)/.test(href)) return href; + const clean = href.replace(/^\.\//, ''); + const m = clean.match(/^(?:docs\/)?([A-Za-z0-9_-]+)\.(?:md|markdown)(#.*)?$/); + if (m) { + const [, name, anchor = ''] = m; + if (name === 'index') return `/docs/${anchor}`; + const slug = slugByName.get(name); + if (slug !== undefined) return slug === '' ? `/docs/${anchor}` : `/docs/${slug}/${anchor}`; + } + return `${GITHUB_URL}/blob/${gitRef}/${clean}`; +} + +// Per-page render state (marked renderer hooks close over this). +const state = { toc: [], slugCounts: new Map() }; + +const marked = new Marked({ + gfm: true, + renderer: { + heading({ tokens, depth }) { + const text = this.parser.parseInline(tokens); + let id = slugify(text); + const n = state.slugCounts.get(id) ?? 0; + state.slugCounts.set(id, n + 1); + if (n > 0) id = `${id}-${n}`; + if (depth === 2 || depth === 3) state.toc.push({ id, text, level: depth }); + return `${text}#\n`; + }, + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const t = title ? ` title="${esc(title)}"` : ''; + const url = rewriteHref(href); + const ext = /^https?:/.test(url) ? ' rel="external"' : ''; + return `${text}`; + }, + code({ text, lang }) { + const cls = lang ? ` class="language-${esc(lang)}"` : ''; + return `
${highlightTokens(esc(text))}
\n`; + }, + codespan({ text }) { + return `${highlightTokens(esc(text))}`; + }, + }, +}); + +function renderDoc(page) { + state.toc = []; + state.slugCounts = new Map(); + const md = readFileSync(path.join(docsDir, page.file), 'utf8'); + const content = marked.parse(md); + const navGroups = [ + { + label: 'Getting started', + items: [{ title: 'Usage Guide', href: '/docs/usage/', current: page.slug === 'usage' }], + }, + { + label: 'Specification', + items: [ + { title: 'Overview', href: '/docs/', current: page.slug === '' }, + ...specPages.map((p) => ({ + title: p.title, + href: `/docs/${p.slug}/`, + current: p.slug === page.slug, + })), + ], + }, + ]; + return docShell({ + title: `${page.title} · flashtrace`, + description: `flashtrace documentation — ${page.title}.`, + version, + navGroups, + toc: state.toc, + content, + }); +} + +// --- emit -------------------------------------------------------------------- + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist, { recursive: true }); + +writeFileSync(path.join(dist, 'index.html'), renderLanding({ version })); + +for (const page of pages) { + const dir = page.slug ? path.join(dist, 'docs', page.slug) : path.join(dist, 'docs'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); +} + +cpSync(path.join(root, 'public'), dist, { recursive: true }); +cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css')); +cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js')); + +console.log(`built ${pages.length + 1} pages into dist/ (flashtrace ${version || 'unknown version'})`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..af4419c --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "flashtrace-website", + "private": true, + "type": "module", + "packageManager": "pnpm@11.10.0", + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "node build.mjs", + "dev": "node serve.mjs", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"" + }, + "devDependencies": { + "marked": "^18.0.6" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..447fd92 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + marked: + specifier: ^18.0.6 + version: 18.0.6 + +packages: + + marked@18.0.6: + resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==} + engines: {node: '>= 20'} + hasBin: true + +snapshots: + + marked@18.0.6: {} diff --git a/public/.nojekyll b/public/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..8264ebe --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..374c683 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/serve.mjs b/serve.mjs new file mode 100644 index 0000000..ef70d40 --- /dev/null +++ b/serve.mjs @@ -0,0 +1,63 @@ +// Tiny dev server: builds once, rebuilds on changes to build inputs, serves +// dist/ with clean URLs. Node stdlib only — not used in CI or production. +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, watch } from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(root, 'dist'); +const port = Number(process.env.PORT) || 8788; + +const types = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.json': 'application/json', + '.txt': 'text/plain; charset=utf-8', +}; + +function build() { + const res = spawnSync(process.execPath, [path.join(root, 'build.mjs')], { stdio: 'inherit' }); + if (res.status !== 0) console.error('build failed — still serving the previous output'); +} + +build(); + +let pending = null; +for (const dir of ['src', 'public']) { + watch(path.join(root, dir), { recursive: true }, () => { + clearTimeout(pending); + pending = setTimeout(build, 100); + }); +} +watch(path.join(root, 'build.mjs'), () => { + clearTimeout(pending); + pending = setTimeout(build, 100); +}); + +http + .createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${port}`); + let file = path.join(dist, path.normalize(decodeURIComponent(url.pathname))); + if (!file.startsWith(dist)) { + res.writeHead(403).end('forbidden'); + return; + } + if (url.pathname.endsWith('/')) file = path.join(file, 'index.html'); + else if (!path.extname(file) && existsSync(path.join(file, 'index.html'))) + file = path.join(file, 'index.html'); + if (!existsSync(file)) { + res.writeHead(404, { 'content-type': 'text/plain' }).end('404 not found'); + return; + } + res.writeHead(200, { + 'content-type': types[path.extname(file)] ?? 'application/octet-stream', + }); + res.end(readFileSync(file)); + }) + .listen(port, () => console.log(`serving dist/ on http://localhost:${port}`)); diff --git a/src/examples.mjs b/src/examples.mjs new file mode 100644 index 0000000..9024ac2 --- /dev/null +++ b/src/examples.mjs @@ -0,0 +1,174 @@ +// Curated interactive examples: real inputs and terminal output captured from +// actual `flashtrace` runs (v0.7.0). No in-browser execution — the CLI needs +// node:fs and git, so v1 ships pre-computed, trustworthy captures. +import { esc } from './layout.mjs'; + +// Re-create the CLI's coloring (src/report.mjs in the tool repo) as HTML +// spans on the captured plain-text output. +export function colorizeReport(text) { + let s = esc(text); + // status line: colored mark + bold item ID + s = s.replace(/^([✔✘~]) (\S+)/gm, (m, mark, id) => { + const cls = mark === '✔' ? 't-green' : mark === '✘' ? 't-red' : 't-yellow'; + return `${mark} ${id}`; + }); + s = s.replace(/✘ missing/g, '✘ missing'); + s = s.replace(/\((→ [^)]*)\)/g, '($1)'); + s = s.replace(/✔/g, ''); + s = s.replace(/(?)✘/g, ''); + s = s.replace(/→/g, ''); + s = s.replace(/⚠/g, ''); + s = s.replace(/•/g, ''); + s = s.replace(/\[deep-covered\]/g, '[deep-covered]'); + s = s.replace(/\[shallow-covered\]/g, '[shallow-covered]'); + s = s.replace(/\[defective\]/g, '[defective]'); + s = s.replace(/"[^&]*"/g, (m) => `${m}`); + s = s.replace(/(^|\s)([\w./-]+\.(?:md|markdown|ts|js|mjs|cjs|tsx|py|rb|go|rs|java|cs|sql|lua|html|vue)(?::\d+)?)(?=\s|$)/gm, '$1$2'); + s = s.replace(/^( )(needs|covers|wanted by)( )/gm, '$1$2$3'); + s = s.replace(/^Summary$/m, 'Summary'); + s = s.replace(/^( items +\d+ )(\(.*\))$/m, '$1$2'); + s = s.replace(/^( ok +)(\d+)$/m, '$1$2'); + s = s.replace(/^( defective +)([1-9]\d*)$/m, '$1$2'); + s = s.replace(/^( )(of the ok items.*)$/m, '$1$2'); + s = s.replace(/^ok$/m, 'ok'); + s = s.replace(/^not ok$/m, 'not ok'); + return s; +} + +export const heroTerminal = { + command: 'npx flashtrace', + output: `Summary + items 2 (1 from markdown, 1 from code) + ok 2 + defective 0 + +ok`, +}; + +export const examples = [ + { + id: 'clean', + title: 'A clean, deep-covered trace', + blurb: + 'A requirement needs an implementation at any 2.x revision; the wildcard resolves to impl:login#2.4 and the whole chain is deep-covered.', + command: 'flashtrace -v', + files: [ + { + name: 'spec.md', + body: `## Login + +\`req:login#1\` + +Users can sign in with a session token. + +Needs: impl:login#2.x`, + }, + { + name: 'login.ts', + body: `// [impl:login#2.4] +export function login(token: SessionToken) { + return openSession(token); +}`, + }, + ], + output: `✔ impl:login#2.4 login.ts:1 [deep-covered] + wanted by req:login#1 spec.md:3 + +✔ req:login#1 "Login" spec.md:3 [deep-covered] + needs impl:login#2.x (→ impl:login#2.4) ✔ login.ts:1 + +Summary + items 2 (1 from markdown, 1 from code) + ok 2 + defective 0 + +ok`, + }, + { + id: 'uncovered', + title: 'An uncovered defect', + blurb: + 'The spec needs test:auth/login#2, but only revision 1 exists — flashtrace flags the revision mismatch, and the outdated test as unwanted.', + command: 'flashtrace', + files: [ + { + name: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Needs: impl:auth/login#1, test:auth/login#2`, + }, + { + name: 'login.ts', + body: `// [impl:auth/login#1] +export function login(email: string, password: string) { + return session.open(email, password); +} + +// [test:auth/login#1] +test('login opens a session', () => { ... });`, + }, + ], + output: `✘ test:auth/login#1 login.ts:6 + • unwanted: no item needs test:auth/login#1 + +✘ req:auth/login#1 "Login requirement" spec.md:3 + • uncovered: needs test:auth/login#2, which does not exist (revision mismatch: existing revision(s) of test:auth/login: 1) + +Summary + items 3 (1 from markdown, 2 from code) + ok 1 + defective 2 + +not ok`, + }, + { + id: 'forwarding', + title: 'Forwarding a requirement', + blurb: + 'req:login#1 delegates its coverage obligation to the auth design with a --> tag; it is deep-covered exactly when dsn:auth#2 is.', + command: 'flashtrace -v', + files: [ + { + name: 'spec.md', + body: `## Login + +\`req:login#1\` + +Login is specified in detail by the auth design. + +\`[req:login#1 --> dsn:auth#2]\` + +## Auth design + +\`dsn:auth#2\` + +Sessions are opened through the central auth service. + +Needs: impl:auth#1`, + }, + { + name: 'auth.ts', + body: `// [impl:auth#1] +export function openSession(token: SessionToken) { ... }`, + }, + ], + output: `✔ impl:auth#1 auth.ts:1 [deep-covered] + wanted by dsn:auth#2 spec.md:11 + +✔ req:login#1 "Login" spec.md:3 [deep-covered] + → dsn:auth#2 ✔ spec.md:11 +✔ dsn:auth#2 "Auth design" spec.md:11 [deep-covered] + needs impl:auth#1 ✔ auth.ts:1 + +Summary + items 3 (2 from markdown, 1 from code) + ok 3 + defective 0 + +ok`, + }, +]; diff --git a/src/landing.mjs b/src/landing.mjs new file mode 100644 index 0000000..12afb9a --- /dev/null +++ b/src/landing.mjs @@ -0,0 +1,254 @@ +// The landing page: hero with a CSS-built IDE mock, how-it-works, features, +// curated interactive examples, install snippets. All static HTML. +import { colorizeReport, examples, heroTerminal } from './examples.mjs'; +import { esc, GITHUB_URL, highlightTokens, pageShell } from './layout.mjs'; + +// --- hero IDE mock ----------------------------------------------------------- + +const heroSpecPane = `
## Login requirement
+
+\`req:auth/login#1\`
+
+Users must be able to log in
+with email and password.
+
+Needs: impl:auth/login#1
`; + +const heroCodePane = `
// [impl:auth/login#1]
+export function login(
+  email: string,
+  password: string,
+) {
+  return session.open(
+    email, password);
+}
`; + +function windowDots() { + return ``; +} + +function ideMock() { + return ``; +} + +// --- how it works ------------------------------------------------------------- + +const steps = [ + { + title: 'Write specs in Markdown', + text: 'An item is a line holding only its ID in backticks. The heading above becomes its title; Needs lists the IDs that must cover it.', + snippet: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in. + +Needs: impl:auth/login#1`, + file: 'spec.md', + }, + { + title: 'Tag your code', + text: 'Drop the ID into a comment — flashtrace understands the comment syntax of dozens of languages, from TypeScript to SQL to Vue.', + snippet: `// [impl:auth/login#1] +// [>>utest:auth/login#1] +export function login(token) { + … +}`, + file: 'login.ts', + }, + { + title: 'Run flashtrace', + text: 'One command traces every requirement to its coverage — transitively — and reports what is missing, orphaned, unwanted, outdated or duplicated.', + // captured from a real run over the two files above plus the demanded test + snippet: `$ npx flashtrace + +Summary + items 3 (1 from markdown, 2 from code) + ok 3 + defective 0 + +ok`, + file: 'terminal', + terminal: true, + }, +]; + +function stepSnippet(s) { + if (!s.terminal) return highlightSnippet(s.snippet); + const report = s.snippet.replace(/^\$ .*\n\n/, ''); + return `$ npx flashtrace\n\n${colorizeReport(report)}`; +} + +function howItWorks() { + return `
+

How it works

+
    + ${steps + .map( + (s, i) => `
  1. +
    ${i + 1}

    ${esc(s.title)}

    +

    ${esc(s.text)}

    +
    ${windowDots()}${esc(s.file)}
    ${stepSnippet(s)}
    +
  2. `, + ) + .join('\n ')} +
+
`; +} + +// Token coloring for landing snippets — same rules as the docs build. +const highlightSnippet = (text) => highlightTokens(esc(text)); + +// --- features ----------------------------------------------------------------- + +const features = [ + ['Zero runtime dependencies', 'One self-contained script. Vendor it or install it — nothing else comes along.', null], + ['Runs anywhere', 'Everything Node.js ≥ 18 runs on: your laptop, your CI, your air-gapped build box.', null], + ['Many languages', 'Tags live in ordinary comments — C-family, Python, Ruby, shell, SQL, Lua, HTML, Vue and more.', '/docs/code-tags/'], + ['Deep, transitive coverage', 'An item is only deep-covered when its whole tracing chain is. Broken links show up wherever they hide.', '/docs/coverage-rules/'], + ['Wildcards & forwarding', 'Accept any 2.x revision, or delegate a requirement’s obligation to another item with a forwarding tag.', '/docs/forwarding/'], + ['Git-aware scanning', 'Files ignored by git are excluded automatically — no config to keep in sync.', '/docs/usage/'], + ['CI-friendly', 'Plain-text report, meaningful exit codes: 0 clean, 1 defects found, 2 usage error.', '/docs/command-line/'], +]; + +function featureGrid() { + return `
+

Built to stay out of your way

+
+ ${features + .map( + ([title, text, href]) => `
+

${title}

+

${text}${href ? ` Learn more` : ''}

+
`, + ) + .join('\n ')} +
+
`; +} + +// --- interactive examples ------------------------------------------------------- + +function exampleFiles(ex) { + return `
+ ${ex.files + .map( + (f) => `
${windowDots()}${esc(f.name)}
${highlightSnippet(f.body)}
`, + ) + .join('\n ')} +
`; +} + +function exampleOutput(ex) { + return `
${windowDots()}terminal
$ ${esc(ex.command)}
+${colorizeReport(ex.output)}
`; +} + +function examplesSection() { + const scenarioTabs = examples + .map( + (ex, i) => + ``, + ) + .join('\n '); + const scenarioPanels = examples + .map( + (ex, i) => `
+

${esc(ex.blurb)}

+
+
+ + +
+
${exampleFiles(ex)}
+ +
+
`, + ) + .join('\n '); + return `
+

See it trace

+

Three scenarios, captured from real flashtrace runs. Flip each between its input files and the report it produces.

+
+
+ ${scenarioTabs} +
+ ${scenarioPanels} +
+
`; +} + +// --- install ----------------------------------------------------------------- + +function installSection(version) { + return `
+

Install in seconds

+

Two ways in, both ending at the same single file. Current release: ${esc(version)}.

+
+
+

As a dev dependency

+
npm install flashtrace -D
+npx flashtrace
+
+
+

Vendored, zero install

+

Download dist/flashtrace.mjs into your repository and run it directly.

+
node flashtrace.mjs
+
+
+

Node.js ≥ 18 required. Read the Usage Guide for options and details.

+
`; +} + +// --- page --------------------------------------------------------------------- + +export function renderLanding({ version }) { + const body = `
+
+
+

Lightning-fast, reference-based requirement tracing

+

flashtrace verifies that every requirement in your Markdown specs is covered by the code and tests it demands — with zero runtime dependencies, anywhere Node.js runs.

+ +
+ ${ideMock()} +
+${howItWorks()} +${featureGrid()} +${examplesSection()} +${installSection(version)} +
`; + return pageShell({ + title: 'flashtrace — lightning-fast, reference-based requirement tracing', + description: + 'flashtrace traces requirement coverage between Markdown specs and code comments. Zero runtime dependencies, runs anywhere Node.js ≥ 18 does.', + version, + active: 'home', + body, + bodyClass: 'page-landing', + }); +} diff --git a/src/layout.mjs b/src/layout.mjs new file mode 100644 index 0000000..5ef0b4d --- /dev/null +++ b/src/layout.mjs @@ -0,0 +1,159 @@ +// Shared page shells: plain template-literal functions, no template engine. + +export const GITHUB_URL = 'https://github.com/flashtrace/flashtrace'; +export const DISCUSSIONS_URL = 'https://github.com/flashtrace/flashtrace/discussions'; + +export function esc(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +// Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be +// HTML-escaped already. Generic code stays uncolored. +export function highlightTokens(escaped) { + return escaped.replace( + /(-->)|(>>)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm, + (m, fwd, need, id, kw) => { + if (fwd || need) return `${m}`; + if (id) return `${m}`; + return `${kw}`; + }, + ); +} + +const themeInit = `(function(){try{var t=localStorage.getItem('ft-theme');if(t!=='light'&&t!=='dark'){t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='light';}})();`; + +const sunIcon = ``; +const moonIcon = ``; +const githubIcon = ``; +const menuIcon = ``; + +function topBar({ version, active, withSidebar }) { + const link = (href, label, key) => + `${label}`; + return `
+
+ ${withSidebar ? `` : ''} + + + flashtrace + + ${esc(version)} + +
+ ${githubIcon} + +
+
+
`; +} + +function footer({ version }) { + return ``; +} + +// Full HTML document. `body` is everything between top bar and footer. +export function pageShell({ title, description, version, active, body, bodyClass = '', withSidebar = false }) { + return ` + + + + +${esc(title)} + + + + + + + +${topBar({ version, active, withSidebar })} +${body} +${footer({ version })} + + + +`; +} + +// Sidebar nav: `groups` is [{ label, items: [{ title, href, current }] }]. +function sidebar(groups) { + const groupHtml = groups + .map( + (g) => ``, + ) + .join('\n'); + return ` +`; +} + +// Right-rail "On this page" TOC from [{ id, text, level }] (h2/h3). +function tocRail(toc) { + if (!toc.length) return ''; + return ``; +} + +// Docs shell: top bar, left sidebar, centered article, right TOC rail. +export function docShell({ title, description, version, navGroups, toc, content }) { + const body = `
+${sidebar(navGroups)} +
+
+${content} +
+
+${tocRail(toc)} +
`; + return pageShell({ + title, + description, + version, + active: 'docs', + body, + bodyClass: 'page-docs', + withSidebar: true, + }); +} diff --git a/src/scripts/site.js b/src/scripts/site.js new file mode 100644 index 0000000..b2f98cf --- /dev/null +++ b/src/scripts/site.js @@ -0,0 +1,130 @@ +// flashtrace website — vanilla JS enhancements. Everything degrades gracefully. +(function () { + 'use strict'; + + // --- theme toggle (initial theme is set inline in ) --- + document.querySelectorAll('.theme-toggle').forEach(function (btn) { + btn.addEventListener('click', function () { + var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { + localStorage.setItem('ft-theme', next); + } catch (e) { + /* private mode etc. — theme just won't persist */ + } + }); + }); + + // --- mobile sidebar --- + var sidebar = document.getElementById('sidebar'); + var toggle = document.querySelector('.sidebar-toggle'); + var backdrop = document.querySelector('.sidebar-backdrop'); + function setSidebar(open) { + if (!sidebar) return; + sidebar.classList.toggle('is-open', open); + if (toggle) toggle.setAttribute('aria-expanded', String(open)); + if (backdrop) backdrop.hidden = !open; + } + if (toggle) { + toggle.addEventListener('click', function () { + setSidebar(!sidebar.classList.contains('is-open')); + }); + } + if (backdrop) { + backdrop.addEventListener('click', function () { + setSidebar(false); + }); + } + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') setSidebar(false); + }); + + // --- tabs (scenario switcher + input/output toggles) --- + document.querySelectorAll('[data-tabs]').forEach(function (container) { + var tablist = container.querySelector(':scope > [role="tablist"]'); + if (!tablist) return; + var tabs = Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]')); + function select(tab) { + tabs.forEach(function (t) { + var on = t === tab; + t.setAttribute('aria-selected', String(on)); + t.tabIndex = on ? 0 : -1; + var panel = document.getElementById(t.getAttribute('aria-controls')); + if (panel) panel.hidden = !on; + }); + } + tabs.forEach(function (tab, i) { + tab.addEventListener('click', function () { + select(tab); + }); + tab.addEventListener('keydown', function (e) { + var dir = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + if (!dir) return; + e.preventDefault(); + var next = tabs[(i + dir + tabs.length) % tabs.length]; + next.focus(); + select(next); + }); + }); + }); + + // --- copy buttons --- + function wireCopy(btn, getText) { + btn.addEventListener('click', function () { + navigator.clipboard.writeText(getText()).then(function () { + btn.classList.add('is-copied'); + var label = btn.textContent; + btn.textContent = 'Copied'; + setTimeout(function () { + btn.classList.remove('is-copied'); + btn.textContent = label; + }, 1600); + }); + }); + } + document.querySelectorAll('.copy-btn[data-copy]').forEach(function (btn) { + wireCopy(btn, function () { + return btn.getAttribute('data-copy'); + }); + }); + // add a copy button to every docs code block + document.querySelectorAll('.doc-content pre').forEach(function (pre) { + var code = pre.querySelector('code'); + if (!code || !navigator.clipboard) return; + var btn = document.createElement('button'); + btn.className = 'copy-btn'; + btn.type = 'button'; + btn.textContent = 'Copy'; + btn.setAttribute('aria-label', 'Copy code'); + wireCopy(btn, function () { + return code.textContent; + }); + pre.appendChild(btn); + }); + + // --- right-rail TOC scrollspy --- + var tocLinks = document.querySelectorAll('.toc a[href^="#"]'); + if (tocLinks.length && 'IntersectionObserver' in window) { + var byId = {}; + tocLinks.forEach(function (a) { + byId[a.getAttribute('href').slice(1)] = a; + }); + var current = null; + var observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + var link = byId[entry.target.id]; + if (!link) return; + if (current) current.classList.remove('is-active'); + link.classList.add('is-active'); + current = link; + }); + }, + { rootMargin: '-56px 0px -70% 0px', threshold: 0 }, + ); + document.querySelectorAll('.doc-content h2[id], .doc-content h3[id]').forEach(function (h) { + observer.observe(h); + }); + } +})(); diff --git a/src/styles/site.css b/src/styles/site.css new file mode 100644 index 0000000..dfb3231 --- /dev/null +++ b/src/styles/site.css @@ -0,0 +1,1085 @@ +/* flashtrace website — single stylesheet, light + dark via [data-theme]. */ + +/* ---------- tokens ---------- */ + +:root { + --bg: #ffffff; + --bg-soft: #f7f6f3; + --bg-raised: #ffffff; + --text: #1c1917; + --muted: #57534e; + --border: #e7e5e4; + --accent: #d97d0e; + --accent-strong: #b45309; + --accent-soft: #fdf3e3; + --link: #b45309; + --code-bg: #f7f6f3; + --code-text: #292524; + --tk-id: #b45309; + --tk-kw: #92400e; + --tk-arrow: #0e7490; + --shadow: 0 1px 3px rgb(0 0 0 / 0.08), 0 8px 24px rgb(0 0 0 / 0.06); + --topbar-h: 3.5rem; + color-scheme: light; +} + +[data-theme='dark'] { + --bg: #151312; + --bg-soft: #1e1b19; + --bg-raised: #201d1b; + --text: #e7e5e4; + --muted: #a8a29e; + --border: #2e2a27; + --accent: #f9c21d; + --accent-strong: #fbbf24; + --accent-soft: #2c2317; + --link: #fbbf24; + --code-bg: #1e1b19; + --code-text: #d6d3d1; + --tk-id: #f9c21d; + --tk-kw: #e8964a; + --tk-arrow: #56b6c2; + --shadow: 0 1px 3px rgb(0 0 0 / 0.5), 0 8px 24px rgb(0 0 0 / 0.35); + color-scheme: dark; +} + +/* no-JS fallback: follow the OS preference */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #151312; + --bg-soft: #1e1b19; + --bg-raised: #201d1b; + --text: #e7e5e4; + --muted: #a8a29e; + --border: #2e2a27; + --accent: #f9c21d; + --accent-strong: #fbbf24; + --accent-soft: #2c2317; + --link: #fbbf24; + --code-bg: #1e1b19; + --code-text: #d6d3d1; + --tk-id: #f9c21d; + --tk-kw: #e8964a; + --tk-arrow: #56b6c2; + color-scheme: dark; + } +} + +[data-theme='dark'] .only-light, +:root:not([data-theme='dark']) .only-dark { + display: none; +} + +/* ---------- base ---------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-padding-top: calc(var(--topbar-h) + 1rem); +} + +body { + margin: 0; + overflow-x: clip; + font-family: ui-sans-serif, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.65; + color: var(--text); + background: var(--bg); + -webkit-text-size-adjust: 100%; +} + +code, +pre, +kbd { + font-family: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace; + font-size: 0.9em; +} + +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +img { + max-width: 100%; +} + +h1, +h2, +h3, +h4 { + line-height: 1.25; + font-weight: 650; +} + +button { + font: inherit; + color: inherit; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + z-index: 100; + padding: 0.5rem 1rem; + background: var(--accent); + color: #1e1e1e; + border-radius: 0 0 6px 0; +} + +.skip-link:focus { + left: 0; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ---------- top bar ---------- */ + +.topbar { + position: sticky; + top: 0; + z-index: 40; + height: var(--topbar-h); + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} + +.topbar-inner { + max-width: 80rem; + height: 100%; + margin: 0 auto; + padding: 0 1rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text); + font-weight: 700; +} + +.brand:hover { + text-decoration: none; +} + +.version-badge { + font-size: 0.75rem; + font-family: ui-monospace, Consolas, monospace; + padding: 0.1rem 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + background: var(--bg-soft); +} + +.topbar-nav { + display: flex; + gap: 0.25rem; + margin-left: 0.5rem; +} + +.topbar-link { + padding: 0.35rem 0.75rem; + border-radius: 6px; + color: var(--muted); + font-size: 0.95rem; +} + +.topbar-link:hover { + color: var(--text); + background: var(--bg-soft); + text-decoration: none; +} + +.topbar-link.is-active { + color: var(--text); + font-weight: 600; +} + +.topbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border: none; + border-radius: 8px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.icon-btn:hover { + color: var(--text); + background: var(--bg-soft); +} + +.sidebar-toggle { + display: none; +} + +/* ---------- footer ---------- */ + +.footer { + border-top: 1px solid var(--border); + margin-top: 4rem; + background: var(--bg-soft); +} + +.footer-inner { + max-width: 80rem; + margin: 0 auto; + padding: 2rem 1rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 2rem; + font-size: 0.9rem; + color: var(--muted); +} + +.footer-brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 650; + color: var(--text); +} + +.footer-links { + display: flex; + gap: 1.25rem; +} + +.footer-links a { + color: var(--muted); +} + +.footer-links a:hover { + color: var(--text); +} + +.footer-note { + margin: 0; + margin-left: auto; +} + +/* ---------- landing ---------- */ + +.page-landing main { + display: block; +} + +.hero { + max-width: 80rem; + margin: 0 auto; + padding: 4rem 1rem 3rem; + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + gap: 3rem; + align-items: center; +} + +.hero-copy h1 { + font-size: clamp(2rem, 4.5vw, 2.9rem); + margin: 0 0 1rem; + letter-spacing: -0.02em; + background: linear-gradient(120deg, var(--text) 55%, var(--accent-strong)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero-sub { + color: var(--muted); + font-size: 1.1rem; + margin: 0 0 1.75rem; +} + +.hero-ctas { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.btn { + display: inline-flex; + align-items: center; + padding: 0.6rem 1.25rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.95rem; +} + +.btn:hover { + text-decoration: none; +} + +.btn-primary { + background: var(--accent); + color: #1e1e1e; +} + +.btn-primary:hover { + filter: brightness(1.06); +} + +.btn-secondary { + border: 1px solid var(--border); + color: var(--text); + background: var(--bg-raised); +} + +.btn-secondary:hover { + background: var(--bg-soft); +} + +/* sections */ + +.section { + max-width: 80rem; + margin: 0 auto; + padding: 3.5rem 1rem 0; +} + +.section > h2 { + font-size: clamp(1.5rem, 3vw, 2rem); + margin: 0 0 0.5rem; + letter-spacing: -0.01em; +} + +.section-sub { + color: var(--muted); + margin: 0 0 1.75rem; + max-width: 46rem; +} + +/* editor windows (hero + snippets), always dark like a real editor */ + +.ide-mock, +.mini-window { + --code-bg: #1e1e1e; + --code-text: #d4d4d4; + --tk-id: #f9c21d; + --tk-kw: #e8964a; + --tk-arrow: #56b6c2; + border-radius: 10px; + overflow: hidden; + border: 1px solid #33302c; + background: #1e1e1e; + box-shadow: var(--shadow); + color: #d4d4d4; +} + +.dots { + display: inline-flex; + gap: 5px; + margin-right: 0.6rem; +} + +.dots i { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ff5f57; +} + +.dots i:nth-child(2) { + background: #febc2e; +} + +.dots i:nth-child(3) { + background: #28c840; +} + +.ide-chrome, +.mini-bar, +.term-bar { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + background: #161616; + border-bottom: 1px solid #33302c; + font-size: 0.75rem; + color: #8b8b8b; + font-family: ui-monospace, Consolas, monospace; +} + +.ide-title { + color: #8b8b8b; +} + +.ide-panes { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; +} + +.ide-pane { + min-width: 0; +} + +.pane-tab { + display: inline-block; + padding: 0.35rem 1rem; + font-size: 0.75rem; + font-family: ui-monospace, Consolas, monospace; + color: #d4d4d4; + background: #1e1e1e; + border-right: 1px solid #33302c; + border-bottom: 2px solid var(--accent); +} + +.ide-pane .code { + border-top: 1px solid #33302c; +} + +.code { + margin: 0; + padding: 0.9rem 1rem; + background: var(--code-bg); + color: var(--code-text); + font-size: 0.8rem; + line-height: 1.6; + overflow-x: auto; +} + +.ide-trace { + display: flex; + align-items: flex-start; + width: 3rem; + padding-top: 3.1rem; + align-self: stretch; + border-top: 1px solid #33302c; + margin-top: 1.75rem; +} + +.ide-trace svg { + width: 100%; + height: 24px; +} + +.ide-trace path { + stroke: var(--accent); + stroke-width: 2; + fill: none; +} + +[data-trace] { + outline: 1px solid color-mix(in srgb, var(--accent) 65%, transparent); + outline-offset: 2px; + border-radius: 2px; + background: rgb(249 194 29 / 0.08); +} + +.ide-term .term-bar { + border-top: 1px solid #33302c; +} + +.term-body { + margin: 0; + padding: 0.9rem 1rem; + background: #141414; + color: #d4d4d4; + font-size: 0.8rem; + line-height: 1.55; + overflow-x: auto; +} + +/* editor token colors */ + +.cmt { + color: #6a9955; +} + +.kw { + color: #c586c0; +} + +.ty { + color: #4ec9b0; +} + +.md-h { + color: #569cd6; + font-weight: 600; +} + +.tk-id { + color: var(--tk-id); +} + +.tk-kw { + color: var(--tk-kw); + font-weight: 600; +} + +.tk-arrow { + color: var(--tk-arrow); + font-weight: 600; +} + +/* terminal report colors */ + +.t-green { + color: #3fb950; +} + +.t-red { + color: #f85149; +} + +.t-yellow { + color: #d29922; +} + +.t-cyan { + color: #56b6c2; +} + +.t-dim { + color: #8b8b8b; +} + +.t-bold { + font-weight: 700; +} + +/* how it works */ + +.steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.5rem; +} + +/* grid/flex items must not let long code lines widen the page */ +.step, +.feature, +.install-card, +.hero > *, +.ex-files > .mini-window, +[role='tabpanel'] { + min-width: 0; +} + +.step-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.25rem; +} + +.step-head h3 { + margin: 0; + font-size: 1.05rem; +} + +.step-no { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent-strong); + font-weight: 700; + font-size: 0.85rem; + flex: none; +} + +.step p { + color: var(--muted); + font-size: 0.92rem; + margin: 0 0 0.9rem; + min-height: 4.5em; +} + +/* features */ + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; +} + +.feature { + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.1rem 1.25rem; + background: var(--bg-raised); +} + +.feature h3 { + margin: 0 0 0.35rem; + font-size: 1rem; +} + +.feature p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +/* examples */ + +[role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +[role='tab'] { + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--muted); + border-radius: 999px; + padding: 0.4rem 1rem; + font-size: 0.9rem; + cursor: pointer; +} + +[role='tab']:hover { + color: var(--text); +} + +[role='tab'][aria-selected='true'] { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-strong); + font-weight: 600; +} + +.example-tabs > [role='tabpanel'] { + margin-top: 1.25rem; +} + +.ex-blurb { + color: var(--muted); + max-width: 46rem; + margin: 0 0 1rem; +} + +.io-tabs [role='tablist'] { + margin-bottom: 0.9rem; +} + +.io-tabs [role='tab'] { + border-radius: 6px; + padding: 0.3rem 0.9rem; + font-size: 0.85rem; +} + +.ex-files { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; +} + +/* install */ + +.install-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; +} + +.install-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + background: var(--bg-raised); +} + +.install-card h3 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.install-note { + color: var(--muted); + font-size: 0.9rem; + margin: 0.9rem 0 0.75rem; +} + +.cmd-block { + position: relative; + border-radius: 8px; + background: #1e1e1e; + border: 1px solid #33302c; +} + +.cmd-block pre { + margin: 0; + padding: 0.8rem 5rem 0.8rem 1rem; + color: #d4d4d4; + font-size: 0.85rem; + overflow-x: auto; +} + +.copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: 1px solid #44403c; + background: #2a2724; + color: #d4d4d4; + border-radius: 6px; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; +} + +.copy-btn:hover { + border-color: var(--accent); +} + +.copy-btn.is-copied { + color: #3fb950; + border-color: #3fb950; +} + +/* ---------- docs ---------- */ + +.doc-layout { + max-width: 90rem; + margin: 0 auto; + display: grid; + grid-template-columns: 16rem minmax(0, 1fr) 14rem; + gap: 2rem; + padding: 0 1rem; +} + +.sidebar { + position: sticky; + top: var(--topbar-h); + align-self: start; + max-height: calc(100vh - var(--topbar-h)); + overflow-y: auto; + padding: 1.5rem 0.5rem 2rem 0; + border-right: 1px solid var(--border); +} + +.nav-group + .nav-group { + margin-top: 1.5rem; +} + +.nav-group-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0 0 0.4rem 0.75rem; +} + +.nav-group ul { + list-style: none; + margin: 0; + padding: 0; +} + +.nav-group a { + display: block; + padding: 0.32rem 0.75rem; + border-radius: 6px; + color: var(--muted); + font-size: 0.92rem; +} + +.nav-group a:hover { + color: var(--text); + background: var(--bg-soft); + text-decoration: none; +} + +.nav-group a.is-current { + color: var(--accent-strong); + background: var(--accent-soft); + font-weight: 600; +} + +.sidebar-backdrop { + display: none; +} + +.doc-main { + padding: 2rem 0 3rem; + min-width: 0; +} + +.doc-content { + max-width: 46rem; +} + +.doc-content h1 { + font-size: 2rem; + margin: 0 0 1rem; + letter-spacing: -0.01em; +} + +.doc-content h2 { + font-size: 1.4rem; + margin: 2.25rem 0 0.75rem; + padding-top: 0.5rem; +} + +.doc-content h3 { + font-size: 1.1rem; + margin: 1.75rem 0 0.5rem; +} + +.heading-anchor { + margin-left: 0.4rem; + color: var(--muted); + opacity: 0; + font-weight: 400; +} + +h1:hover .heading-anchor, +h2:hover .heading-anchor, +h3:hover .heading-anchor, +.heading-anchor:focus-visible { + opacity: 1; +} + +.doc-content p, +.doc-content li { + color: var(--text); +} + +.doc-content li + li { + margin-top: 0.25rem; +} + +.doc-content pre { + position: relative; + margin: 1rem 0; + padding: 0.9rem 1rem; + border-radius: 8px; + background: var(--code-bg); + border: 1px solid var(--border); + overflow-x: auto; + font-size: 0.85rem; + line-height: 1.6; + color: var(--code-text); +} + +.doc-content :not(pre) > code { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.08em 0.35em; + font-size: 0.85em; +} + +.doc-content table { + border-collapse: collapse; + display: block; + overflow-x: auto; + max-width: 100%; + margin: 1rem 0; + font-size: 0.9rem; +} + +.doc-content th, +.doc-content td { + border: 1px solid var(--border); + padding: 0.45rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.doc-content th { + background: var(--bg-soft); +} + +.doc-content blockquote { + margin: 1rem 0; + padding: 0.25rem 1rem; + border-left: 3px solid var(--accent); + background: var(--bg-soft); + color: var(--muted); +} + +.doc-content pre .copy-btn { + opacity: 0; +} + +.doc-content pre:hover .copy-btn, +.doc-content pre .copy-btn:focus-visible { + opacity: 1; +} + +/* right rail */ + +.toc { + position: sticky; + top: var(--topbar-h); + align-self: start; + max-height: calc(100vh - var(--topbar-h)); + overflow-y: auto; + padding: 2rem 0 2rem 0.5rem; + font-size: 0.85rem; +} + +.toc-label { + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0 0 0.5rem; +} + +.toc ul { + list-style: none; + margin: 0; + padding: 0; + border-left: 1px solid var(--border); +} + +.toc li a { + display: block; + padding: 0.2rem 0 0.2rem 0.75rem; + color: var(--muted); + border-left: 2px solid transparent; + margin-left: -1.5px; +} + +.toc li.toc-l3 a { + padding-left: 1.5rem; +} + +.toc li a:hover { + color: var(--text); + text-decoration: none; +} + +.toc li a.is-active { + color: var(--accent-strong); + border-left-color: var(--accent); +} + +/* ---------- responsive ---------- */ + +@media (max-width: 1100px) { + .toc { + display: none; + } + + .doc-layout { + grid-template-columns: 15rem minmax(0, 1fr); + } +} + +@media (max-width: 960px) { + .hero { + grid-template-columns: 1fr; + padding-top: 2.5rem; + } + + .steps { + grid-template-columns: 1fr; + } + + .step p { + min-height: 0; + } +} + +@media (max-width: 860px) { + .doc-layout { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar-toggle { + display: inline-flex; + } + + .sidebar { + position: fixed; + inset: var(--topbar-h) auto 0 0; + z-index: 50; + width: min(18rem, 85vw); + background: var(--bg); + border-right: 1px solid var(--border); + padding: 1.25rem 1rem 2rem; + transform: translateX(-105%); + transition: transform 0.2s ease; + } + + .sidebar.is-open { + transform: translateX(0); + box-shadow: var(--shadow); + } + + .sidebar-backdrop { + display: block; + position: fixed; + inset: var(--topbar-h) 0 0 0; + z-index: 45; + background: rgb(0 0 0 / 0.4); + } + + .sidebar-backdrop[hidden] { + display: none; + } +} + +@media (max-width: 700px) { + .ide-panes { + grid-template-columns: 1fr; + } + + .ide-trace { + display: none; + } + + .ide-pane + .ide-pane .pane-tab { + border-top: 1px solid #33302c; + } + + .topbar-nav .topbar-link[href='/'] { + display: none; + } +} From 0781bf4019636a99c79bd5e5dbd563a13dd10be1 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 11:13:41 +0200 Subject: [PATCH 02/22] fix: vendored-install link downloads the raw script at the release ref The blob URL opened GitHub's HTML viewer instead of the file, and was the only tool-repo link hardcoded to main while the rest of the site pins to the release ref. Co-Authored-By: Claude Fable 5 --- src/landing.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/landing.mjs b/src/landing.mjs index 12afb9a..d343414 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -202,6 +202,8 @@ function examplesSection() { // --- install ----------------------------------------------------------------- function installSection(version) { + // raw file at the release ref, so a vendored download matches the docs shown + const rawScriptUrl = `${GITHUB_URL}/raw/${esc(version || 'main')}/dist/flashtrace.mjs`; return `

Install in seconds

Two ways in, both ending at the same single file. Current release: ${esc(version)}.

@@ -214,7 +216,7 @@ npx flashtrace" aria-label="Copy install commands">Copy

Vendored, zero install

-

Download dist/flashtrace.mjs into your repository and run it directly.

+

Download dist/flashtrace.mjs into your repository and run it directly.

node flashtrace.mjs
From fb227345d6a2fcba2d032f186335f19d26cb308d Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 11:15:54 +0200 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20replace=20=E2=80=94=20with=20-?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- build.mjs | 6 +++--- serve.mjs | 4 ++-- src/examples.mjs | 4 ++-- src/landing.mjs | 18 +++++++++--------- src/scripts/site.js | 4 ++-- src/styles/site.css | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 92e9067..ce13719 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # flashtrace.github.io -The website of [flashtrace](https://github.com/flashtrace/flashtrace) — a landing page plus +The website of [flashtrace](https://github.com/flashtrace/flashtrace) - a landing page plus documentation rendered from the tool repository's `docs/`, served by GitHub Pages at [flashtrace.github.io](https://flashtrace.github.io/). @@ -18,7 +18,7 @@ build-time dependency ([marked](https://github.com/markedjs/marked)). - `.github/workflows/deploy.yml` builds and deploys on every push to `main`, on manual `workflow_dispatch`, and on a `repository_dispatch` of type `flashtrace-release` that the tool repository fires after each release. Docs are always checked out at the release tag - (from the dispatch payload, or the latest release otherwise) — the site documents released + (from the dispatch payload, or the latest release otherwise) - the site documents released behavior, not `main`. ## Local development diff --git a/build.mjs b/build.mjs index 2b2635f..2f12d37 100644 --- a/build.mjs +++ b/build.mjs @@ -55,7 +55,7 @@ const specPages = [...indexMd.matchAll(/\[([^\]]+)\]\(docs\/([A-Za-z0-9_-]+)\.md (m) => ({ title: m[1], slug: m[2], file: `${m[2]}.md` }), ); if (specPages.length === 0) { - console.error('error: no doc links found in docs/index.md — nav derivation failed.'); + console.error('error: no doc links found in docs/index.md - nav derivation failed.'); process.exit(1); } @@ -79,7 +79,7 @@ function slugify(html) { } // Rewrite the tool repo's relative links to the site's clean URLs. index.md -// links as docs/.md; USAGE.md and the spec pages link bare .md — +// links as docs/.md; USAGE.md and the spec pages link bare .md - // both forms are handled. Other repo-relative paths go to github.com. function rewriteHref(href) { if (/^(https?:|mailto:|#)/.test(href)) return href; @@ -150,7 +150,7 @@ function renderDoc(page) { ]; return docShell({ title: `${page.title} · flashtrace`, - description: `flashtrace documentation — ${page.title}.`, + description: `flashtrace documentation - ${page.title}.`, version, navGroups, toc: state.toc, diff --git a/serve.mjs b/serve.mjs index ef70d40..55fccd7 100644 --- a/serve.mjs +++ b/serve.mjs @@ -1,5 +1,5 @@ // Tiny dev server: builds once, rebuilds on changes to build inputs, serves -// dist/ with clean URLs. Node stdlib only — not used in CI or production. +// dist/ with clean URLs. Node stdlib only - not used in CI or production. import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, watch } from 'node:fs'; import http from 'node:http'; @@ -23,7 +23,7 @@ const types = { function build() { const res = spawnSync(process.execPath, [path.join(root, 'build.mjs')], { stdio: 'inherit' }); - if (res.status !== 0) console.error('build failed — still serving the previous output'); + if (res.status !== 0) console.error('build failed - still serving the previous output'); } build(); diff --git a/src/examples.mjs b/src/examples.mjs index 9024ac2..ab0e590 100644 --- a/src/examples.mjs +++ b/src/examples.mjs @@ -1,5 +1,5 @@ // Curated interactive examples: real inputs and terminal output captured from -// actual `flashtrace` runs (v0.7.0). No in-browser execution — the CLI needs +// actual `flashtrace` runs (v0.7.0). No in-browser execution - the CLI needs // node:fs and git, so v1 ships pre-computed, trustworthy captures. import { esc } from './layout.mjs'; @@ -88,7 +88,7 @@ ok`, id: 'uncovered', title: 'An uncovered defect', blurb: - 'The spec needs test:auth/login#2, but only revision 1 exists — flashtrace flags the revision mismatch, and the outdated test as unwanted.', + 'The spec needs test:auth/login#2, but only revision 1 exists - flashtrace flags the revision mismatch, and the outdated test as unwanted.', command: 'flashtrace', files: [ { diff --git a/src/landing.mjs b/src/landing.mjs index d343414..b8072e9 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -28,7 +28,7 @@ function windowDots() { } function ideMock() { - return `
`; } -// Token coloring for landing snippets — same rules as the docs build. +// Token coloring for landing snippets - same rules as the docs build. const highlightSnippet = (text) => highlightTokens(esc(text)); // --- features ----------------------------------------------------------------- const features = [ - ['Zero runtime dependencies', 'One self-contained script. Vendor it or install it — nothing else comes along.', null], + ['Zero runtime dependencies', 'One self-contained script. Vendor it or install it - nothing else comes along.', null], ['Runs anywhere', 'Everything Node.js ≥ 18 runs on: your laptop, your CI, your air-gapped build box.', null], - ['Many languages', 'Tags live in ordinary comments — C-family, Python, Ruby, shell, SQL, Lua, HTML, Vue and more.', '/docs/code-tags/'], + ['Many languages', 'Tags live in ordinary comments - C-family, Python, Ruby, shell, SQL, Lua, HTML, Vue and more.', '/docs/code-tags/'], ['Deep, transitive coverage', 'An item is only deep-covered when its whole tracing chain is. Broken links show up wherever they hide.', '/docs/coverage-rules/'], ['Wildcards & forwarding', 'Accept any 2.x revision, or delegate a requirement’s obligation to another item with a forwarding tag.', '/docs/forwarding/'], - ['Git-aware scanning', 'Files ignored by git are excluded automatically — no config to keep in sync.', '/docs/usage/'], + ['Git-aware scanning', 'Files ignored by git are excluded automatically - no config to keep in sync.', '/docs/usage/'], ['CI-friendly', 'Plain-text report, meaningful exit codes: 0 clean, 1 defects found, 2 usage error.', '/docs/command-line/'], ]; @@ -231,7 +231,7 @@ export function renderLanding({ version }) {

Lightning-fast, reference-based requirement tracing

-

flashtrace verifies that every requirement in your Markdown specs is covered by the code and tests it demands — with zero runtime dependencies, anywhere Node.js runs.

+

flashtrace verifies that every requirement in your Markdown specs is covered by the code and tests it demands - with zero runtime dependencies, anywhere Node.js runs.

Get started View on GitHub @@ -245,7 +245,7 @@ ${examplesSection()} ${installSection(version)} `; return pageShell({ - title: 'flashtrace — lightning-fast, reference-based requirement tracing', + title: 'flashtrace - lightning-fast, reference-based requirement tracing', description: 'flashtrace traces requirement coverage between Markdown specs and code comments. Zero runtime dependencies, runs anywhere Node.js ≥ 18 does.', version, diff --git a/src/scripts/site.js b/src/scripts/site.js index b2f98cf..ea20860 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -1,4 +1,4 @@ -// flashtrace website — vanilla JS enhancements. Everything degrades gracefully. +// flashtrace website - vanilla JS enhancements. Everything degrades gracefully. (function () { 'use strict'; @@ -10,7 +10,7 @@ try { localStorage.setItem('ft-theme', next); } catch (e) { - /* private mode etc. — theme just won't persist */ + /* private mode etc. - theme just won't persist */ } }); }); diff --git a/src/styles/site.css b/src/styles/site.css index dfb3231..b0c1cc6 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1,4 +1,4 @@ -/* flashtrace website — single stylesheet, light + dark via [data-theme]. */ +/* flashtrace website - single stylesheet, light + dark via [data-theme]. */ /* ---------- tokens ---------- */ From 473b5bd60c2532a534e2e98dcf98047db0fca58a Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 11:27:48 +0200 Subject: [PATCH 04/22] fix: remove window action dots from the hero terminal bar The terminal is a docked panel inside the IDE mock, not its own window, so it should not carry close/minimize/zoom buttons. Co-Authored-By: Claude Fable 5 --- src/landing.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/landing.mjs b/src/landing.mjs index b8072e9..ef47273 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -45,7 +45,7 @@ function ideMock() {
-
${windowDots()}terminal
+
terminal
$ ${esc(heroTerminal.command)}
 ${colorizeReport(heroTerminal.output)}
From 97a0e205bb7d10778d02a412400a8660fc32b1dd Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:35:07 +0200 Subject: [PATCH 05/22] agents: add CLAUDE.md --- CLAUDE.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..967f23e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# Agent Guide + +This repository contains the representative and documentation website for [flashtrace](https://github.com/flashtrace/flashtrace), a "lightning-fast, reference-based requirement tracing" suite. +In the following you will be introduced to some helpful structural guidance as well as hard constraints. + +First of all: +Work professionally, remember to use modern day best practices and stay focused. +Feel free to tell a user when their tasks seems unscoped or ambiguous. +Ask refining questions before you start writing. + +## The Repository Structure + +| Folder | Purpose | +|---|---| +| src/ | Source code, used from 'build.mjs' to create 'dist/' | +| public/ | Assets that should be included exactly as they are in the final 'dist/' | +| dist/ | Uncommitted, generated build output | +| flashtrace/ | Uncommitted, gitignored clone of [flashtrace/flashtrace](https://github.com/flashtrace/flashtrace); its 'docs/' are a required build input | +| .github/ | Continuous integration/deployment workflows | + +'build.mjs' renders the tool repo's 'docs/' into the site, so a build needs access to them. +It looks for the docs at `$FLASHTRACE_DOCS`, then './flashtrace/docs', then '../flashtrace/docs' - if none exist, clone the tool repo first: `git clone https://github.com/flashtrace/flashtrace`. + +Keep dev dependencies to a minimum. +Keep (runtime) dependencies to zero. + +## The Commands + +| Cmd | Purpose | +|---|---| +| `pnpm build` | Build the website with 'build.mjs' (which is using 'src/' and 'marked' being the sole dependency) into 'dist/'. | +| `pnpm dev` | Live-rebuilds on changes and serves the website from 'dist/' for local testing. | +| `pnpm clean` | Wipes 'dist/' gracefully. | + +## Your workflow + +Work in small chunks. +Commit regularly on proper (preliminary) results. +Follow conventional commits, that means use the format `: ` for every commit. +You can add a descriptive body too. +Adapt a similar pattern for branch naming. + +We are using merging over Pull Requests from feature-branches. +Every PR is being merged in as a commit; we do not squash the commits nor do we rebase anything directly on top of main without a merge commit. +Remember to have one branch focused on one change. +Suggest to split into multiple if applicable. + +`dist/` stays uncommitted, it should however always be buildable without issues before committing any source. +Note that the output can change without any local changes, as this also depends on `flashtrace/flashtrace`'s version. +On production of this website, this is automatically updated through the `deploy.yml` workflow being dispatched from the release workflow of `flashtrace/flashtrace`. + +We do NOT maintain a `package.json` version here, as this repository is not published as a package anywhere; deployments happen on either a merge into main or automatically on a new `flashtrace/flashtrace` release (to update the docs and versioning). + +## Parallel work with git worktrees + +One task = one branch = one worktree = one session. +All rules apply unchanged inside every worktree. + +- Create worktrees as siblings of the main checkout: `git worktree add ..\flashtrace.github.io-wt\ -b / origin/main` +- Run `pnpm install` in a fresh worktree before building or testing; node_modules is per-worktree (pnpm's store makes this fast). +- The flashtrace clone is not shared into worktrees ('../flashtrace' resolves to the worktree's parent, not the main checkout). Point `FLASHTRACE_DOCS` at the main checkout's clone (e.g. `$env:FLASHTRACE_DOCS = "C:\your\path\to\flashtrace.github.io\flashtrace\docs"`) or clone it next to the worktree before building. +- Branch only from up-to-date `origin/main`. Never commit to `main`, and never check out or modify a branch owned by another worktree. +- Before opening a PR: Verify `pnpm build` passes. +- After your PR merges: `git worktree remove ` and delete the branch. From 0e121273f30c283c4be50ef156152893f3db40dd Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:36:32 +0200 Subject: [PATCH 06/22] fix: replace the hero trace connector with a plain vertical split The dashed accent line between the spec.md and login.ts panes read as noise rather than a link - the shared highlight on req:auth/login#1 and [impl:auth/login#1] already conveys the trace. The panes now sit flush with a gray divider, which also removes the misaligned border seam the connector column created next to the tabs. Also highlight the Needs reference impl:auth/login#1 in the spec pane the same way as the other traced IDs. Co-Authored-By: Claude Fable 5 --- src/landing.mjs | 5 ++--- src/styles/site.css | 34 +++++++--------------------------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/src/landing.mjs b/src/landing.mjs index ef47273..fc0399a 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -12,7 +12,7 @@ const heroSpecPane = `
## Login requir
 Users must be able to log in
 with email and password.
 
-Needs: impl:auth/login#1
`; +Needs: impl:auth/login#1`; const heroCodePane = `
// [impl:auth/login#1]
 export function login(
@@ -28,7 +28,7 @@ function windowDots() {
 }
 
 function ideMock() {
-  return `
const steps = [ { title: 'Write specs in Markdown', - text: 'An item is a line holding only its ID in backticks. The heading above becomes its title; Needs lists the IDs that must cover it.', + text: 'An item is a line holding only its ID in backticks. The heading above becomes its title, the paragraph below its description and Needs lists the IDs that must cover it.', snippet: `## Login requirement \`req:auth/login#1\` From f2849d631762dd0be812e46100b498b0f71fba62 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:38:00 +0200 Subject: [PATCH 08/22] fix: hide copy buttons when the Clipboard API is unavailable The static [data-copy] buttons called navigator.clipboard.writeText unguarded, throwing on click in non-secure contexts. Guard once in wireCopy and hide the control, covering both button paths. Co-Authored-By: Claude Fable 5 --- src/scripts/site.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/scripts/site.js b/src/scripts/site.js index ea20860..52f91cb 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -70,6 +70,11 @@ // --- copy buttons --- function wireCopy(btn, getText) { + if (!navigator.clipboard) { + // non-secure context: no Clipboard API, so hide the control entirely + btn.hidden = true; + return; + } btn.addEventListener('click', function () { navigator.clipboard.writeText(getText()).then(function () { btn.classList.add('is-copied'); From 416127b0ae195bee6e425e76f816df1076e4c731 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:39:55 +0200 Subject: [PATCH 09/22] ci: build PRs with the same steps as the deploy workflow Mirrors deploy.yml's build job (release-tag resolution, pinned action versions, Pages artifact packaging) so a green PR check means the subsequent deploy build runs identically on the same inputs. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d8a93cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # mirrors the build job of deploy.yml (minus the dispatch payload, which + # only exists on releases) so a green check means the deploy build passes + # on the same inputs - keep the two in sync + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Resolve flashtrace release ref + id: ref + run: | + tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) + echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: flashtrace/flashtrace + ref: ${{ steps.ref.outputs.tag }} + path: flashtrace + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + env: + FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + + # also exercised here so artifact packaging failures surface in CI, + # not first during a deploy + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + retention-days: 1 From 95eec150438db82490418071ffec105dc9f95d90 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:42:10 +0200 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20guard=20the=20global=20=E2=9C=94?= =?UTF-8?q?=20pass=20in=20colorizeReport=20against=20double-wrapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-line pass already wraps a leading ✔; the global pass then nested a second identical span around it. Mirror the lookbehind the ✘ pass already uses. Co-Authored-By: Claude Fable 5 --- src/examples.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples.mjs b/src/examples.mjs index ab0e590..d6b1548 100644 --- a/src/examples.mjs +++ b/src/examples.mjs @@ -14,7 +14,7 @@ export function colorizeReport(text) { }); s = s.replace(/✘ missing/g, '✘ missing'); s = s.replace(/\((→ [^)]*)\)/g, '($1)'); - s = s.replace(/✔/g, ''); + s = s.replace(/(?)✔/g, ''); s = s.replace(/(?)✘/g, ''); s = s.replace(/→/g, ''); s = s.replace(/⚠/g, ''); From 17791961f5f2d0365cc82d02fdcf172d46a1d4e6 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:43:14 +0200 Subject: [PATCH 11/22] fix: fail the deploy when no flashtrace release tag can be resolved An empty tag made the tool-repo checkout silently fall back to its default branch - the opposite of building from released docs. The resolve step now errors out instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7510849..6c8cd3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,13 +25,19 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # docs are built from the released tool, not its main branch: take the - # tag from the dispatch payload, or fall back to the latest release + # tag from the dispatch payload, or fall back to the latest release. + # An empty tag would make the checkout below silently use the default + # branch, so fail loudly instead. - name: Resolve flashtrace release ref id: ref run: | tag="${{ github.event.client_payload.tag }}" if [ -z "$tag" ]; then - tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) + tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) || true + fi + if [ -z "$tag" ]; then + echo "::error::no release tag: dispatch payload was empty and flashtrace/flashtrace has no published release" + exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" env: From e5ca22f53940dea044659a3a84e9004d503237ba Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:43:58 +0200 Subject: [PATCH 12/22] docs: note that highlightTokens is deliberately language-agnostic Co-Authored-By: Claude Fable 5 --- src/layout.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/layout.mjs b/src/layout.mjs index 5ef0b4d..1da77af 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -13,6 +13,13 @@ export function esc(s) { // Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be // HTML-escaped already. Generic code stays uncolored. +// +// Deliberately language-agnostic: it runs on every fenced block and codespan +// regardless of its info string, because flashtrace tokens appear inside +// blocks of any language (md, ts, sql, plain trace output, ...). The cost is +// that an unrelated string shaped like an ID (foo:bar#1) in, say, a bash or +// json block also gets colored - acceptable for these docs, where anything +// ID-shaped in a code block is in practice a flashtrace reference. export function highlightTokens(escaped) { return escaped.replace( /(-->)|(>>)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm, From 3be86eaf3be06d640e04c7171578cffe7fa38fec Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:49:39 +0200 Subject: [PATCH 13/22] feat: emit a canonical link tag on every page pageShell takes the page's site-absolute path and renders against https://flashtrace.github.io, so trailing-slash and index.html variants resolve to one URL. Co-Authored-By: Claude Fable 5 --- build.mjs | 1 + src/landing.mjs | 1 + src/layout.mjs | 7 +++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/build.mjs b/build.mjs index 2f12d37..b4792fb 100644 --- a/build.mjs +++ b/build.mjs @@ -151,6 +151,7 @@ function renderDoc(page) { return docShell({ title: `${page.title} · flashtrace`, description: `flashtrace documentation - ${page.title}.`, + path: page.slug ? `/docs/${page.slug}/` : '/docs/', version, navGroups, toc: state.toc, diff --git a/src/landing.mjs b/src/landing.mjs index b447592..ae1a8ba 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -247,6 +247,7 @@ ${installSection(version)} title: 'flashtrace - lightning-fast, reference-based requirement tracing', description: 'flashtrace traces requirement coverage between Markdown specs and code comments. Zero runtime dependencies, runs anywhere Node.js ≥ 18 does.', + path: '/', version, active: 'home', body, diff --git a/src/layout.mjs b/src/layout.mjs index 1da77af..598646b 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -1,5 +1,6 @@ // Shared page shells: plain template-literal functions, no template engine. +export const SITE_URL = 'https://flashtrace.github.io'; export const GITHUB_URL = 'https://github.com/flashtrace/flashtrace'; export const DISCUSSIONS_URL = 'https://github.com/flashtrace/flashtrace/discussions'; @@ -82,7 +83,7 @@ function footer({ version }) { } // Full HTML document. `body` is everything between top bar and footer. -export function pageShell({ title, description, version, active, body, bodyClass = '', withSidebar = false }) { +export function pageShell({ title, description, path, version, active, body, bodyClass = '', withSidebar = false }) { return ` @@ -90,6 +91,7 @@ export function pageShell({ title, description, version, active, body, bodyClass ${esc(title)} + @@ -144,7 +146,7 @@ function tocRail(toc) { } // Docs shell: top bar, left sidebar, centered article, right TOC rail. -export function docShell({ title, description, version, navGroups, toc, content }) { +export function docShell({ title, description, path, version, navGroups, toc, content }) { const body = `
${sidebar(navGroups)}
@@ -157,6 +159,7 @@ ${tocRail(toc)} return pageShell({ title, description, + path, version, active: 'docs', body, From 96f4271da54cf75c765c631d5a670121c52055f7 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:50:51 +0200 Subject: [PATCH 14/22] feat: generate sitemap.xml from the page list Plain entries for the landing page and every docs page, using the same canonical URLs pageShell emits. Co-Authored-By: Claude Fable 5 --- build.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/build.mjs b/build.mjs index b4792fb..a05f13d 100644 --- a/build.mjs +++ b/build.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { Marked } from 'marked'; -import { docShell, esc, GITHUB_URL, highlightTokens } from './src/layout.mjs'; +import { docShell, esc, GITHUB_URL, highlightTokens, SITE_URL } from './src/layout.mjs'; import { renderLanding } from './src/landing.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); @@ -172,6 +172,16 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } +const sitePaths = ['/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/'))]; +writeFileSync( + path.join(dist, 'sitemap.xml'), + ` + +${sitePaths.map((p) => ` ${SITE_URL}${p}`).join('\n')} + +`, +); + cpSync(path.join(root, 'public'), dist, { recursive: true }); cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css')); cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js')); From 0b7893b55e6a48fce81f6bda32a2fa2ca6fcb534 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 13:51:30 +0200 Subject: [PATCH 15/22] feat: add robots.txt pointing at the sitemap Co-Authored-By: Claude Fable 5 --- public/robots.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 public/robots.txt diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..96b9e41 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://flashtrace.github.io/sitemap.xml From 87fb6ab454c1c905abb1d821e667709b8992f31c Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:07:40 +0200 Subject: [PATCH 16/22] fix: pass dispatch payload tag to the shell via env, not interpolation Inline ${{ github.event.client_payload.tag }} inside a run: block is template-substituted before the shell parses it, so a crafted tag could execute as script. Exposing it as an env var makes it plain data. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c8cd3b..f82b503 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,7 @@ jobs: - name: Resolve flashtrace release ref id: ref run: | - tag="${{ github.event.client_payload.tag }}" + tag="$RAW_TAG" if [ -z "$tag" ]; then tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) || true fi @@ -41,6 +41,9 @@ jobs: fi echo "tag=$tag" >> "$GITHUB_OUTPUT" env: + # env, not inline ${{ }}: the dispatch payload is untrusted input and + # must reach the shell as data, never as script text + RAW_TAG: ${{ github.event.client_payload.tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 From 6fc39112604c9169e3c2cae6a8ac4d859e04acd0 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:08:50 +0200 Subject: [PATCH 17/22] fix: mirror deploy.yml empty-tag guard in ci.yml ci.yml promises to mirror the deploy build job, but missed the guard added in 1779196: with an empty resolved tag the flashtrace checkout silently falls back to the default branch instead of a release, making a green CI check meaningless for the deploy. Fail loudly instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8a93cc..3c745cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,16 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # An empty tag would make the checkout below silently use the default + # branch, so fail loudly instead - same guard as in deploy.yml. - name: Resolve flashtrace release ref id: ref run: | - tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) + tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) || true + if [ -z "$tag" ]; then + echo "::error::no release tag: flashtrace/flashtrace has no published release" + exit 1 + fi echo "tag=$tag" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7001b21cadf8fe3b67cfdb98aa77d014d09d4ace Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:10:44 +0200 Subject: [PATCH 18/22] fix: keep docs copy buttons visible on touch devices The reveal-on-hover treatment left the button at opacity 0 with no way to discover it on devices without a hover state. Gate it behind @media (hover: hover) so touch users always see it. Co-Authored-By: Claude Fable 5 --- src/styles/site.css | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/styles/site.css b/src/styles/site.css index 1d02da6..9c843eb 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -924,13 +924,17 @@ h3:hover .heading-anchor, color: var(--muted); } -.doc-content pre .copy-btn { - opacity: 0; -} +/* reveal-on-hover only where hover exists; touch devices get a + permanently visible button, as there is no way to discover it otherwise */ +@media (hover: hover) { + .doc-content pre .copy-btn { + opacity: 0; + } -.doc-content pre:hover .copy-btn, -.doc-content pre .copy-btn:focus-visible { - opacity: 1; + .doc-content pre:hover .copy-btn, + .doc-content pre .copy-btn:focus-visible { + opacity: 1; + } } /* right rail */ From 9aaf8d911521d07c077e4381a5de6e33db69daa5 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:17:19 +0200 Subject: [PATCH 19/22] fix: fall back to a 'dev' version label when none can be resolved Without FLASHTRACE_REF and without a version in the tool clone's package.json, the empty string rendered an empty header badge (a link with no accessible name) and 'Docs built from flashtrace .' in the footer. Label that case 'dev'; repo links keep their separate 'main' fallback since 'dev' is not a real ref. Co-Authored-By: Claude Fable 5 --- build.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build.mjs b/build.mjs index a05f13d..8924b14 100644 --- a/build.mjs +++ b/build.mjs @@ -45,8 +45,9 @@ function readVersion() { return ''; } -const version = readVersion(); -const gitRef = version || 'main'; // for links into the tool repo on github.com +const resolvedVersion = readVersion(); +const gitRef = resolvedVersion || 'main'; // for links into the tool repo on github.com +const version = resolvedVersion || 'dev'; // display label (header badge, footer note) // --- nav order: derived from docs/index.md, the single source of truth ----- From d2c16d2309d9d40c061be5d0deb809df7291a544 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:20:58 +0200 Subject: [PATCH 20/22] fix: use gitRef, not the display version, for the vendored install link 9aaf8d9 made the display version always truthy ('dev' fallback), which silently defeated the 'version || main' ref fallback in the landing page's raw-script URL - in the no-ref case the download link pointed at the nonexistent 'dev' ref. Thread gitRef (release tag or 'main') through renderLanding to installSection, keeping the display label and the git ref as the separate concepts they already are in build.mjs. Co-Authored-By: Claude Fable 5 --- build.mjs | 2 +- src/landing.mjs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.mjs b/build.mjs index 8924b14..2693c99 100644 --- a/build.mjs +++ b/build.mjs @@ -165,7 +165,7 @@ function renderDoc(page) { rmSync(dist, { recursive: true, force: true }); mkdirSync(dist, { recursive: true }); -writeFileSync(path.join(dist, 'index.html'), renderLanding({ version })); +writeFileSync(path.join(dist, 'index.html'), renderLanding({ version, gitRef })); for (const page of pages) { const dir = page.slug ? path.join(dist, 'docs', page.slug) : path.join(dist, 'docs'); diff --git a/src/landing.mjs b/src/landing.mjs index ae1a8ba..12ccb6f 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -200,9 +200,9 @@ function examplesSection() { // --- install ----------------------------------------------------------------- -function installSection(version) { +function installSection(version, gitRef) { // raw file at the release ref, so a vendored download matches the docs shown - const rawScriptUrl = `${GITHUB_URL}/raw/${esc(version || 'main')}/dist/flashtrace.mjs`; + const rawScriptUrl = `${GITHUB_URL}/raw/${esc(gitRef)}/dist/flashtrace.mjs`; return `

Install in seconds

Two ways in, both ending at the same single file. Current release: ${esc(version)}.

@@ -225,7 +225,7 @@ npx flashtrace" aria-label="Copy install commands">Copy
// --- page --------------------------------------------------------------------- -export function renderLanding({ version }) { +export function renderLanding({ version, gitRef }) { const body = `
@@ -241,7 +241,7 @@ export function renderLanding({ version }) { ${howItWorks()} ${featureGrid()} ${examplesSection()} -${installSection(version)} +${installSection(version, gitRef)}
`; return pageShell({ title: 'flashtrace - lightning-fast, reference-based requirement tracing', From 1418dc9daca0cb1862af3d60b5756576eca30fab Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:27:03 +0200 Subject: [PATCH 21/22] docs: marked is unsanitized but input is trusted --- build.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.mjs b/build.mjs index 2693c99..9f1efbd 100644 --- a/build.mjs +++ b/build.mjs @@ -98,6 +98,9 @@ function rewriteHref(href) { // Per-page render state (marked renderer hooks close over this). const state = { toc: [], slugCounts: new Map() }; +// Docs are trusted first-party input, so we don't sanitize marked's output +// (raw HTML passes through). Revisit before rendering any untrusted markdown here. + const marked = new Marked({ gfm: true, renderer: { From 6193032688c5bc62690d888b56068bf114ad5277 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 12 Jul 2026 14:37:14 +0200 Subject: [PATCH 22/22] feat: add static legal notice (impressum) page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fully static /impressum/ page rendered through the shared page shell, a footer link so it is reachable from every page (required under §5 DDG), and its sitemap entry. Co-Authored-By: Claude Opus 4.8 --- build.mjs | 8 ++++++-- src/impressum.mjs | 38 ++++++++++++++++++++++++++++++++++++++ src/layout.mjs | 1 + src/styles/site.css | 12 ++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/impressum.mjs diff --git a/build.mjs b/build.mjs index 9f1efbd..af8e426 100644 --- a/build.mjs +++ b/build.mjs @@ -9,6 +9,7 @@ import { Marked } from 'marked'; import { docShell, esc, GITHUB_URL, highlightTokens, SITE_URL } from './src/layout.mjs'; import { renderLanding } from './src/landing.mjs'; +import { renderImpressum } from './src/impressum.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); @@ -170,13 +171,16 @@ mkdirSync(dist, { recursive: true }); writeFileSync(path.join(dist, 'index.html'), renderLanding({ version, gitRef })); +mkdirSync(path.join(dist, 'impressum'), { recursive: true }); +writeFileSync(path.join(dist, 'impressum', 'index.html'), renderImpressum({ version })); + for (const page of pages) { const dir = page.slug ? path.join(dist, 'docs', page.slug) : path.join(dist, 'docs'); mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } -const sitePaths = ['/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/'))]; +const sitePaths = ['/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), '/impressum/']; writeFileSync( path.join(dist, 'sitemap.xml'), ` @@ -190,4 +194,4 @@ cpSync(path.join(root, 'public'), dist, { recursive: true }); cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css')); cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js')); -console.log(`built ${pages.length + 1} pages into dist/ (flashtrace ${version || 'unknown version'})`); +console.log(`built ${pages.length + 2} pages into dist/ (flashtrace ${version || 'unknown version'})`); diff --git a/src/impressum.mjs b/src/impressum.mjs new file mode 100644 index 0000000..d40d5ee --- /dev/null +++ b/src/impressum.mjs @@ -0,0 +1,38 @@ +// Legal notice (Impressum) - a fully static, hand-written page. Required to be +// reachable from every page under German law (§5 DDG); linked from the footer. +import { esc, pageShell } from './layout.mjs'; + +export function renderImpressum({ version }) { + const body = `
+ +
`; + return pageShell({ + title: 'Legal Notice · flashtrace', + description: 'Legal notice (Impressum) for the flashtrace website.', + path: '/impressum/', + version, + active: '', + body, + bodyClass: 'page-legal', + }); +} diff --git a/src/layout.mjs b/src/layout.mjs index 598646b..b97e930 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -76,6 +76,7 @@ function footer({ version }) { GitHub Discussions License + Legal Notice diff --git a/src/styles/site.css b/src/styles/site.css index 9c843eb..dfc08cf 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -987,6 +987,18 @@ h3:hover .heading-anchor, border-left-color: var(--accent); } +/* ---------- legal notice ---------- */ + +.page-legal .section { + padding-bottom: 3.5rem; +} + +.legal address { + font-style: normal; + line-height: 1.7; + color: var(--text); +} + /* ---------- responsive ---------- */ @media (max-width: 1100px) {