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
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@ pnpm-debug.log*
*.tar.gz

# Security-sensitive local files
.env
.env.*
!.env.example
!.env.sample
*.pem
*.key
*.p12
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,18 @@ Build scripts live in:
```text
scripts-build/fetch-pulse.mjs
scripts-build/generate-grain.mjs
scripts-build/csp-hashes.mjs
```

`csp-hashes.mjs` runs as part of `npm run build` and rewrites
`worker/script-hashes.json`. The Worker's `script-src` names those hashes, so
changing any inline script without rebuilding would make the deployed CSP
block it. The audit fails on a stale list.

`generate-grain.mjs` is seeded: repeated runs must stay byte-identical.
`public/pulse.json` is read at build time only — the Network Clock renders it
as a snapshot and does not poll it at runtime.

Rules:

- Do not hand-edit generated files unless the task explicitly requires it.
Expand Down
19 changes: 14 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,19 @@ Do not reintroduce older font systems such as Playfair Display, Lora, or DM Mono

Use the existing type tokens in `design-system.css`:

- display sizes: `--fz-hero`, `--fz-page`, `--fz-section`, `--fz-step`, `--fz-note`, `--fz-quote`
- fixed scale: `--text-xs` through `--text-6xl`
- line heights: `--lh-title`, `--lh-card`, `--lh-base`, `--lh-loose`
- tracking: `--ls-tighter`, `--ls-snug`, `--ls-normal`, `--ls-3`, `--ls-5`, `--ls-7`, `--ls-9`, `--ls-11`
- display sizes: `--fz-page`, `--fz-section`, `--fz-step`, `--fz-note`, `--fz-quote`
- role aliases: `--fz-xs`, `--fz-sm`, `--fz-base`, `--fz-prose`, `--fz-lg`, `--fz-xl`, `--fz-3xl`
- fixed scale: `--text-xs` through `--text-3xl`
- line heights: `--lh-title`, `--lh-snug`, `--lh-card`, `--lh-normal`, `--lh-relaxed`, `--lh-base`, `--lh-loose`
- tracking: `--ls-tighter`, `--ls-snug`, `--ls-normal`, `--ls-3`, `--ls-5`, `--ls-7`
- text colour: `--tx` (headings), `--tx-2` (body), `--tx-3` (muted)

Every token above resolves to a distinct value. Aliases that duplicated an
existing size or tracking value (`--fz-hero`, `--fz-3xs`, `--fz-md`,
`--fz-2xl`, `--ls-9`, `--ls-11`, `--text-4xl`/`5xl`/`6xl`, `--tx-2b`) were
removed: they implied a granularity the design did not have. If a new step is
genuinely needed, give it a real value rather than a second name for an
existing one.

Avoid oversized hero typography on inner pages. The site should feel archival and editorial, not like a launch-page template.

Expand All @@ -271,7 +280,7 @@ Use existing spacing and layout tokens:
```css
--max-w
--pad
--sp-1 through --sp-13
--sp-1 through --sp-12
--radius
--radius-sm
--radius-lg
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ flowchart TD
H[Preact islands] --> B
I[Browser scripts] --> B
B --> J[Static HTML output]
J --> S[Inline script hashes]
S --> K
J --> K[Cloudflare Worker routing script]
K --> L[Cloudflare Workers static assets]
M[GitHub Actions] --> N[npm ci / npm run validate]
Expand All @@ -62,7 +64,7 @@ flowchart TD

The repository currently uses:

- Astro 7
- Astro 7 (a small Vite plugin drops Fontsource's legacy `.woff` fallbacks)
- `@astrojs/preact` 6
- Preact 10 islands
- TypeScript 5
Expand All @@ -73,7 +75,7 @@ The repository currently uses:

The dependency source of truth is `package.json`. Deployment details are in `astro.config.mjs`, `wrangler.jsonc`, `worker/index.js`, and `public/_headers`.

Local and CI builds require Node.js 22.12 or newer. Astro 7 uses Vite 8 and its Rust-based compiler/bundling pipeline; the project does not depend on custom Vite plugins or legacy Markdown processors.
Local and CI builds require Node.js 22.12 or newer. Astro 7 uses Vite 8 and its Rust-based compiler/bundling pipeline. The only custom Vite plugin is the woff2-only transform in `astro.config.mjs`; there are no legacy Markdown processors.

## Project Structure

Expand Down Expand Up @@ -106,7 +108,8 @@ src/data/site.json Site identity and origin configuration
src/layouts/Base.astro Shared page shell and metadata
src/components/Nav.astro Main navigation
src/lib/seo.ts SEO metadata helpers
worker/index.js Canonical host and legacy sitemap redirects before static assets
worker/index.js Canonical host redirects, security headers, cache policy
worker/script-hashes.json Generated CSP hashes for the build's inline scripts
```

## Content Model
Expand Down Expand Up @@ -188,10 +191,12 @@ Time-sensitive Toolkit entries include a review date and source label. Objection

- latest block-height fallback
- `public/pulse.json` network snapshot
- `public/grain.png` texture asset
- `public/grain.png` texture asset (seeded, so repeated runs are byte-identical)

The refresh path should be defensive. If a live source fails, the site should preserve a cached or fallback value rather than fail unnecessarily.

`public/pulse.json` is a build-time artifact, and the Network Clock renders it as one. The clock does not poll: the file cannot change between deploys, so a poll could only ever re-fetch an identical body. Run `refresh-data` and redeploy to move the numbers.

## Development

Install dependencies:
Expand Down Expand Up @@ -288,9 +293,11 @@ npm ci
npm run validate
```

The audit runs against the generated `dist` output. It checks the sitemap route set, document metadata, one-H1 structure, internal routes and fragments, duplicate IDs, selected interactive/accessibility contracts, generated-data freshness, and static asset budgets. Playwright then checks the mobile menu, resource filtering, and the Frame 2 no-JavaScript experience in Chrome. Freshness fallback states are reported as warnings so a temporary upstream outage does not make a static build unavailable.
The audit runs against the generated `dist` output. It checks the sitemap route set, document metadata, one-H1 structure, internal routes and fragments, duplicate IDs, selected interactive/accessibility contracts, generated-data freshness, static asset budgets, CSP script-hash freshness, that no legacy `.woff` is shipped, that inline prose links keep their underline affordance, and that the resource-filter script only reaches pages that render filters. Playwright then checks the mobile menu, resource filtering, and the Frame 2 no-JavaScript experience in Chrome. Freshness fallback states are reported as warnings so a temporary upstream outage does not make a static build unavailable.

The production build outputs static assets from Astro. Cloudflare deployment is configured through Wrangler using the `dist` directory as the static assets source. `worker/index.js` runs before assets to redirect `www.bitcoinmind.com` to the apex domain and route legacy sitemap asset paths to the canonical sitemap. It also normalizes security headers and cache lifetimes; error responses never inherit an immutable cache rule from their path.

The production build outputs static assets from Astro. Cloudflare deployment is configured through Wrangler using the `dist` directory as the static assets source. `worker/index.js` runs before assets to redirect `www.bitcoinmind.com` to the apex domain and route legacy sitemap asset paths to the canonical sitemap.
`npm run build` regenerates `worker/script-hashes.json`, the sha256 of every inline script in `dist`. The Worker names those hashes in `script-src`, so `'unsafe-inline'` survives only as a fallback for browsers that predate hash support. The audit re-derives the hashes and fails if the committed list is stale — if you change an inline script, rebuild before deploying.

## Maintenance Principles

Expand Down
21 changes: 21 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import siteData from './src/data/site.json' with { type: 'json' };

/**
* Fontsource ships every face as woff2 *and* legacy woff. Vite emits both
* because both are referenced, so the build carried ~260KB of .woff that no
* browser capable of running this site would ever request — woff2 has been
* universal since 2016, and the site already depends on `inert`, `dvh`, and
* ResizeObserver. Stripping the fallback before Vite reads the CSS means the
* files are never emitted at all, rather than deleted afterwards.
*/
function woff2Only() {
return {
name: 'bitcoinmind:woff2-only',
enforce: 'pre',
transform(code, id) {
if (!id.includes('@fontsource') || !id.includes('.css')) return null;
const stripped = code.replace(/,\s*url\([^)]+\.woff\)\s*format\((['"])woff\1\)/g, '');
return stripped === code ? null : { code: stripped, map: null };
},
};
}

export default defineConfig({
site: siteData.url,
trailingSlash: 'never',
Expand All @@ -13,6 +33,7 @@ export default defineConfig({
preact({ compat: true }),
],
vite: {
plugins: [woff2Only()],
css: {
transformer: 'lightningcss',
lightningcss: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build": "astro build && node scripts-build/csp-hashes.mjs",
"check": "astro check",
"audit": "node scripts-build/audit-site.mjs",
"test:worker": "node --test tests/worker.test.mjs",
Expand Down
Binary file modified public/grain.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion public/pulse.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"hashRate": 873326117924410800000,
"mempoolCount": 83914,
"nodeCount": 26415,
"fetchedAt": "2026-08-25T19:27:46.418Z",
"source": "snapshot",
"height": 964047
Expand Down
55 changes: 49 additions & 6 deletions scripts-build/audit-site.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { collectScriptHashes } from "./csp-hashes.mjs";

const ROOT = new URL("../", import.meta.url).pathname;
// fileURLToPath, not .pathname: the latter stays percent-encoded, so any
// checkout under a directory with a space in its name resolved to a path
// that does not exist.
const ROOT = fileURLToPath(new URL("../", import.meta.url));
const DIST = join(ROOT, "dist");
const assetDirectory = join(DIST, "_astro");
const siteConfig = JSON.parse(await readFile(join(ROOT, "src/data/site.json"), "utf8"));
const failures = [];
const warnings = [];
Expand Down Expand Up @@ -271,7 +277,7 @@ const contracts = [
["/texts", /data-filter-scope/, "text filters"],
["/toolkit", /data-filter-scope/, "toolkit filters"],
["/frames/2", /aria-labelledby="f2-chart-title f2-chart-desc"/, "accessible chart fallback"],
["/", /aria-modal="true"/, "welcome dialog semantics"],
["/", /id="site-menu"[\s\S]{0,240}?aria-modal="true"/, "modal mobile-menu semantics"],
];
for (const [route, pattern, label] of contracts) {
if (!pattern.test(pages.get(route) ?? "")) fail(`${route}: missing ${label}`);
Expand All @@ -281,6 +287,44 @@ if (!/name="robots"\s+content="noindex, (?:no)?follow"/.test(pages.get("/404") ?
fail("/404: missing noindex directive");
}

const builtCss = (await Promise.all(
(await readdir(assetDirectory)).filter((name) => name.endsWith(".css"))
.map((name) => read(join(assetDirectory, name))),
)).join("\n");

// Inline prose links inherit the global anchor reset, so without an explicit
// rule they render identically to the paragraph around them.
if (!/\.a-body a[^{]*\{[^}]*text-decoration:\s*underline/.test(builtCss)) {
fail("styles: inline prose links are missing their underline affordance");
}

// Fontsource emits a legacy .woff beside every .woff2; the build strips the
// fallback so those files are never referenced or shipped.
const legacyFonts = (await readdir(assetDirectory)).filter((name) => name.endsWith(".woff"));
if (legacyFonts.length) fail(`performance: ${legacyFonts.length} legacy .woff font(s) shipped`);
if (/format\((["'])woff\1\)/.test(builtCss)) fail("performance: built CSS still references legacy .woff");

// The resource filter belongs only to the pages that render a filter scope.
// It used to be an inline block in the shared layout, so every page paid for
// it. The marker is the code's own attribute lookup, not the card markup.
for (const [route, html] of pages) {
if (/data-filter-card/.test(html)) continue;
const shipsFilterCode = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)]
.some(([, body]) => body.includes("filter-values"));
if (shipsFilterCode) fail(`${route}: ships resource-filter code but renders no filters`);
}

// worker/index.js names every inline script by hash. If the committed list
// does not match what this build emitted, the deployed CSP would block them.
const builtHashes = await collectScriptHashes(DIST);
const declaredHashes = JSON.parse(await read(join(ROOT, "worker/script-hashes.json")));
for (const hash of builtHashes) {
if (!declaredHashes.includes(hash)) fail(`csp: worker/script-hashes.json is missing ${hash}`);
}
for (const hash of declaredHashes) {
if (!builtHashes.includes(hash)) fail(`csp: worker/script-hashes.json has a stale entry ${hash}`);
}

for (const filename of await readdir(join(ROOT, "src/data"))) {
if (!filename.endsWith(".ts")) continue;
const source = await read(join(ROOT, "src/data", filename));
Expand All @@ -295,9 +339,8 @@ if (!Number.isFinite(pulseAgeDays)) fail("public/pulse.json: invalid fetchedAt")
else if (pulseAgeDays > 14) warn(`public/pulse.json: snapshot is ${Math.floor(pulseAgeDays)} days old`);
if (pulse.source !== "snapshot") warn(`public/pulse.json: source is ${pulse.source}; one or more feeds used a fallback`);

const assetDir = join(DIST, "_astro");
const assets = await readdir(assetDir);
const sizes = await Promise.all(assets.map(async (name) => ({ name, bytes: (await stat(join(assetDir, name))).size })));
const assets = await readdir(assetDirectory);
const sizes = await Promise.all(assets.map(async (name) => ({ name, bytes: (await stat(join(assetDirectory, name))).size })));
const css = sizes.filter(({ name }) => name.endsWith(".css"));
const js = sizes.filter(({ name }) => name.endsWith(".js"));
const fonts = sizes.filter(({ name }) => /\.(woff2?|ttf)$/.test(name));
Expand All @@ -309,7 +352,7 @@ const totalFonts = fonts.reduce((sum, { bytes }) => sum + bytes, 0);
if (largestCss > 65_536) fail(`performance: largest CSS asset is ${largestCss} bytes (budget 65,536)`);
if (largestJs > 32_768) fail(`performance: largest JS asset is ${largestJs} bytes (budget 32,768)`);
if (totalJs > 98_304) fail(`performance: total JS is ${totalJs} bytes (budget 98,304)`);
if (totalFonts > 550_000) fail(`performance: total fonts are ${totalFonts} bytes (budget 550,000)`);
if (totalFonts > 260_000) fail(`performance: total fonts are ${totalFonts} bytes (budget 260,000)`);

for (const message of warnings) console.warn(`WARN ${message}`);
if (failures.length) {
Expand Down
51 changes: 51 additions & 0 deletions scripts-build/csp-hashes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Collects the sha256 of every inline <script> in the built site so the
// Worker can name them explicitly in script-src.
//
// The policy still lists 'unsafe-inline' as a fallback for CSP1-era
// browsers; any browser that understands hashes ignores it, which is the
// point — an injected inline script no longer executes just because the
// site happens to need a handful of its own.
//
// Runs as part of `npm run build`. The audit re-derives the hashes from
// dist and fails if this file is stale, so the two cannot drift.
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

const ROOT = fileURLToPath(new URL('../', import.meta.url));
const DIST = join(ROOT, 'dist');
const TARGET = join(ROOT, 'worker', 'script-hashes.json');

// `type="application/ld+json"` blocks are data, not script: the HTML parser
// never prepares them for execution, so script-src never applies to them.
const INLINE_SCRIPT = /<script\b(?![^>]*\ssrc=)([^>]*)>([\s\S]*?)<\/script>/g;
const NON_EXECUTABLE_TYPE = /type=(['"])(?!module\1|text\/javascript\1|application\/javascript\1)[^'"]*\1/;

async function htmlFiles(directory) {
const found = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) found.push(...await htmlFiles(path));
else if (entry.name.endsWith('.html')) found.push(path);
}
return found;
}

export async function collectScriptHashes(distDir = DIST) {
const hashes = new Set();
for (const file of await htmlFiles(distDir)) {
const html = await readFile(file, 'utf8');
for (const [, attrs, body] of html.matchAll(INLINE_SCRIPT)) {
if (NON_EXECUTABLE_TYPE.test(attrs)) continue;
hashes.add(`sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`);
}
}
return [...hashes].sort();
}

if (import.meta.url === `file://${process.argv[1]}`) {
const hashes = await collectScriptHashes();
await writeFile(TARGET, `${JSON.stringify(hashes, null, 2)}\n`, 'utf8');
console.log(`[csp-hashes] wrote ${hashes.length} inline script hashes to ${TARGET}`);
}
19 changes: 6 additions & 13 deletions scripts-build/fetch-pulse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const MEMPOOL_API = 'https://mempool.space/api';
const FALLBACK = {
hashRate: null,
mempoolCount: null,
nodeCount: null,
fetchedAt: null,
source: 'fallback',
};
Expand Down Expand Up @@ -58,7 +57,11 @@ async function fetchText(url) {
}

const existing = readExisting();
const next = { ...existing, source: 'cached' };
const { nodeCount: _retiredNodeCount, ...carried } = existing;
const next = { ...carried, source: 'cached' };
// One per successful upstream call below. Kept next to the fetches so the
// snapshot/partial-snapshot distinction cannot silently drift out of step.
const EXPECTED_FIELDS = 3;
let refreshedFields = 0;

try {
Expand Down Expand Up @@ -92,19 +95,9 @@ try {
console.warn('[fetch-pulse] mempool fetch failed:', e.message);
}

try {
const data = await fetchJson('https://bitnodes.io/api/v1/snapshots/latest/');
if (Number.isFinite(data?.total_nodes) && data.total_nodes > 0) {
next.nodeCount = data.total_nodes;
refreshedFields += 1;
}
} catch (e) {
console.warn('[fetch-pulse] node count fetch failed:', e.message);
}

if (refreshedFields > 0) {
next.fetchedAt = new Date().toISOString();
next.source = refreshedFields === 4 ? 'snapshot' : 'partial-snapshot';
next.source = refreshedFields === EXPECTED_FIELDS ? 'snapshot' : 'partial-snapshot';
}

writeFileSync(target, JSON.stringify(next, null, 2) + '\n', 'utf8');
Expand Down
19 changes: 18 additions & 1 deletion scripts-build/generate-grain.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,30 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const target = join(__dirname, '..', 'public', 'grain.png');

const SIZE = 256;
// Seeded so the texture is byte-identical on every run. With Math.random()
// each `npm run refresh-data` rewrote the whole PNG, putting a fresh ~64KB
// binary blob in the diff for a texture nobody could tell apart.
const SEED = 0x9e3779b9;

function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

const random = mulberry32(SEED);

// Build raw scanlines: 1 filter byte + SIZE pixels per row, 8-bit grayscale.
const raw = Buffer.alloc(SIZE * (SIZE + 1));
for (let y = 0; y < SIZE; y++) {
raw[y * (SIZE + 1)] = 0; // filter: None
for (let x = 0; x < SIZE; x++) {
raw[y * (SIZE + 1) + 1 + x] = Math.floor(Math.random() * 256);
raw[y * (SIZE + 1) + 1 + x] = Math.floor(random() * 256);
}
}

Expand Down
Loading
Loading