diff --git a/.gitignore b/.gitignore index dfe73a5..481c192 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,6 @@ pnpm-debug.log* *.tar.gz # Security-sensitive local files -.env -.env.* -!.env.example -!.env.sample *.pem *.key *.p12 diff --git a/AGENTS.md b/AGENTS.md index 279fd3a..b056328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index 4cb9516..25937b9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. @@ -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 diff --git a/README.md b/README.md index 134e729..a69da24 100644 --- a/README.md +++ b/README.md @@ -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] @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index 3c33a8c..f45e1ac 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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', @@ -13,6 +33,7 @@ export default defineConfig({ preact({ compat: true }), ], vite: { + plugins: [woff2Only()], css: { transformer: 'lightningcss', lightningcss: { diff --git a/package.json b/package.json index fd52245..22cec7c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/grain.png b/public/grain.png index 66246df..c8d88e0 100644 Binary files a/public/grain.png and b/public/grain.png differ diff --git a/public/pulse.json b/public/pulse.json index e1cbef3..9ccaf32 100644 --- a/public/pulse.json +++ b/public/pulse.json @@ -1,7 +1,6 @@ { "hashRate": 873326117924410800000, "mempoolCount": 83914, - "nodeCount": 26415, "fetchedAt": "2026-08-25T19:27:46.418Z", "source": "snapshot", "height": 964047 diff --git a/scripts-build/audit-site.mjs b/scripts-build/audit-site.mjs index 1761ddc..8b722fe 100644 --- a/scripts-build/audit-site.mjs +++ b/scripts-build/audit-site.mjs @@ -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 = []; @@ -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}`); @@ -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(/]*>([\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)); @@ -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)); @@ -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) { diff --git a/scripts-build/csp-hashes.mjs b/scripts-build/csp-hashes.mjs new file mode 100644 index 0000000..f37a516 --- /dev/null +++ b/scripts-build/csp-hashes.mjs @@ -0,0 +1,51 @@ +// Collects the sha256 of every inline diff --git a/src/components/StudyLoop.astro b/src/components/StudyLoop.astro index a56748d..7ef9c53 100644 --- a/src/components/StudyLoop.astro +++ b/src/components/StudyLoop.astro @@ -46,7 +46,7 @@ const { active, compact = false } = Astro.props; .study-loop-eye, .study-loop-number { font-family: var(--font-mono); - font-size: var(--fz-3xs); + font-size: var(--fz-xs); letter-spacing: var(--ls-7); text-transform: uppercase; } @@ -69,7 +69,7 @@ const { active, compact = false } = Astro.props; .study-loop-number { color: var(--gold); } .study-loop-copy { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; } .study-loop-copy strong { font-size: var(--fz-sm); color: var(--tx); } - .study-loop-copy span { font-size: var(--fz-3xs); line-height: var(--lh-card); color: var(--tx-3); } + .study-loop-copy span { font-size: var(--fz-xs); line-height: var(--lh-card); color: var(--tx-3); } .study-loop--compact .study-loop-head { margin-bottom: var(--sp-3); } @media (--viewport-layout) { .study-loop-list { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } diff --git a/src/components/StudyNext.astro b/src/components/StudyNext.astro index e992911..aa776e6 100644 --- a/src/components/StudyNext.astro +++ b/src/components/StudyNext.astro @@ -28,8 +28,8 @@ const { previous, next } = Astro.props; .study-next-link { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-4); border: 1px solid var(--bdr); border-radius: var(--radius); } .study-next-link:hover { border-color: var(--border-accent); background: var(--surf); } .study-next-link--next { text-align: right; } - .study-next-link span { font-family: var(--font-mono); font-size: var(--fz-3xs); color: var(--gold); letter-spacing: var(--ls-7); text-transform: uppercase; } - .study-next-link strong { font-size: var(--fz-md); color: var(--tx); } + .study-next-link span { font-family: var(--font-mono); font-size: var(--fz-xs); color: var(--gold); letter-spacing: var(--ls-7); text-transform: uppercase; } + .study-next-link strong { font-size: var(--fz-base); color: var(--tx); } .study-next-link small { font-size: var(--fz-xs); color: var(--tx-3); line-height: var(--lh-card); } @media (--viewport-sm) { .study-next { grid-template-columns: 1fr; } .study-next-link--next { text-align: left; } } diff --git a/src/data/rabbit-holes.ts b/src/data/rabbit-holes.ts index c479653..cecff2e 100644 --- a/src/data/rabbit-holes.ts +++ b/src/data/rabbit-holes.ts @@ -18,6 +18,8 @@ export interface RabbitHole { id: string; emoji: string; title: string; + /** The question this route answers, shown on the /paths route cards. */ + question: string; subtitle: string; desc: string; difficulty: 'Beginner' | 'Intermediate' | 'Advanced'; @@ -34,6 +36,7 @@ export const RABBIT_HOLES: RabbitHole[] = [ id: 'rh-monetary', emoji: '🏛', title: 'The Monetary Path', + question: 'What is money, and why does scarcity matter?', subtitle: 'Why societies converge on money', desc: 'Begin before Bitcoin. Study how monetary goods emerge, how settlement and credit differ, and why scarcity matters only when people demand the other properties of money.', difficulty: 'Beginner', @@ -54,6 +57,7 @@ export const RABBIT_HOLES: RabbitHole[] = [ id: 'rh-technical', emoji: '🔧', title: 'The Technical Path', + question: 'How does Bitcoin actually work?', subtitle: 'How the rules produce a shared ledger', desc: 'Move from the original proposal to transactions, keys, proof of work, difficulty, and validation. The aim is not programming fluency; it is knowing which mechanism supports each claim.', difficulty: 'Intermediate', @@ -74,6 +78,7 @@ export const RABBIT_HOLES: RabbitHole[] = [ id: 'rh-verification', emoji: '◉', title: 'The Verification Path', + question: 'Which claims can you verify for yourself?', subtitle: 'Observe less. Verify more.', desc: 'Learn the difference between reading a dashboard, querying someone else’s node, and applying Bitcoin’s rules yourself. Verification is a spectrum of dependence, not a badge.', difficulty: 'Intermediate', @@ -94,6 +99,7 @@ export const RABBIT_HOLES: RabbitHole[] = [ id: 'rh-custody', emoji: '🛡', title: 'The Custody Path', + question: 'Which risks become yours when you hold the keys?', subtitle: 'Control, recovery, and human failure', desc: 'Learn custody as risk transfer. Removing an intermediary reduces some failures while making key security, recovery, privacy, and inheritance your responsibility.', difficulty: 'Intermediate', @@ -114,6 +120,7 @@ export const RABBIT_HOLES: RabbitHole[] = [ id: 'rh-philosophy', emoji: '📜', title: 'The Reflection Path', + question: 'Which conclusions follow—and which remain arguments?', subtitle: 'Claims, objections, and institutional meaning', desc: 'Separate protocol facts from claims about freedom, human rights, institutions, and the future. Read sympathetic arguments beside the strongest objections and keep the unresolved parts visible.', difficulty: 'Intermediate', diff --git a/src/data/site.json b/src/data/site.json index 939386e..6466c4d 100644 --- a/src/data/site.json +++ b/src/data/site.json @@ -4,8 +4,7 @@ "themeColor": "#0d0b08", "tagline": "A curated, first-principles guide to Bitcoin: money, protocol, verification, self-custody, history, and the strongest objections.", "author": "Hiei", - "contactEmail": "hiei1988@gmail.com", + "contactEmail": "hi@bitcoinmind.com", "foundedYear": 2017, - "lastUpdated": "August 2026", - "lastModified": "2026-08-31" + "firstContact": "2011-04" } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index f0b74a6..563b351 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -180,83 +180,6 @@ const legacyHashRedirects = LEGACY_HASH_REDIRECTS; import '@/scripts/mobile-menu'; import '@/scripts/explore-menu'; - diff --git a/src/lib/numbers.ts b/src/lib/numbers.ts new file mode 100644 index 0000000..fb63ca8 --- /dev/null +++ b/src/lib/numbers.ts @@ -0,0 +1,30 @@ +const ONES = [ + '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', + 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', + 'seventeen', 'eighteen', 'nineteen', +]; +const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; + +/** + * Spells 0–999 in words. The site writes small counts as words in prose + * (page ages, elapsed years) so the copy never has to be edited by hand + * when the underlying date moves. + */ +export function spellNumber(n: number): string { + if (n === 0) return 'zero'; + if (n < 20) return ONES[n]!; + if (n < 100) { + const tens = TENS[Math.floor(n / 10)]!; + const ones = n % 10; + return ones ? `${tens}-${ONES[ones]}` : tens; + } + const hundreds = ONES[Math.floor(n / 100)]!; + const remainder = n % 100; + return `${hundreds} hundred${remainder ? ` ${spellNumber(remainder)}` : ''}`; +} + +/** Same as `spellNumber`, capitalised for the start of a sentence. */ +export function spellNumberSentence(n: number): string { + const word = spellNumber(n); + return word.charAt(0).toUpperCase() + word.slice(1); +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 1d65b56..bd4e406 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -5,14 +5,44 @@ export interface PageSeo { } import siteData from '@/data/site.json'; +import { PUBLIC_ROUTES } from '@/lib/routes'; + +// Freshness has one source of truth: the reviewed `lastModified` dates in +// routes.json. Deriving both values here keeps the About page, the sitemap, +// and the JSON-LD from drifting apart the way a hand-kept string does. +const latestReviewDate = PUBLIC_ROUTES + .map((route) => route.lastModified) + .reduce((latest, value) => (value > latest ? value : latest)); + +function monthYear(isoDate: string): string { + return new Date(`${isoDate.slice(0, 7)}-01T00:00:00Z`).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }); +} export const SITE = { ...siteData, host: new URL(siteData.url).hostname, ga4MeasurementId: import.meta.env.PUBLIC_GA4_MEASUREMENT_ID?.trim() || 'G-EW3470R00V', socialImage: `${siteData.url}/og/bitcoinmind.png`, + /** Newest reviewed route date, e.g. "2026-09-01". */ + lastModified: latestReviewDate, + /** The same date rendered for readers, e.g. "September 2026". */ + lastUpdated: monthYear(latestReviewDate), + /** Readable form of the first-contact month, e.g. "April 2011". */ + firstContactLabel: monthYear(`${siteData.firstContact}-01`), }; +/** Whole years between the first Bitcoin note and the newest reviewed page. */ +export function yearsSinceFirstContact(): number { + const [fromYear, fromMonth] = siteData.firstContact.split('-').map(Number); + const [toYear, toMonth] = latestReviewDate.slice(0, 7).split('-').map(Number); + const months = (toYear! - fromYear!) * 12 + (toMonth! - fromMonth!); + return Math.floor(months / 12); +} + export function fullTitle(title: string): string { return title === SITE.name ? `${SITE.name} — Learn Bitcoin from First Principles` diff --git a/src/lib/todays-pick.ts b/src/lib/todays-pick.ts index 8ded72d..a27f7cc 100644 --- a/src/lib/todays-pick.ts +++ b/src/lib/todays-pick.ts @@ -15,9 +15,3 @@ export function getTodaysPick(now: Date = new Date()): Pick { const seed = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate(); return ALL[seed % ALL.length]!; } - -export function formatLongDate(now: Date = new Date()): string { - return now.toLocaleDateString('en-US', { - month: 'long', day: 'numeric', year: 'numeric', - }); -} diff --git a/src/pages/about.astro b/src/pages/about.astro index e6fb4e9..952a35c 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -1,6 +1,7 @@ --- import Base from '@/layouts/Base.astro'; -import { SITE } from '@/lib/seo'; +import { SITE, yearsSinceFirstContact } from '@/lib/seo'; +import { spellNumberSentence } from '@/lib/numbers'; import { publicRoute } from '@/lib/routes'; import { LIBRARY } from '@/data/library'; import { TEXTS } from '@/data/texts'; @@ -9,8 +10,9 @@ import { TOOLKIT } from '@/data/toolkit'; const route = publicRoute('/about'); const stats: [string, string][] = [ ['Library items', String(LIBRARY.length)], ['Primary texts', String(TEXTS.length)], ['Toolkit items', String(TOOLKIT.length)], - ['First contact', 'April 2011'], ['Last updated', SITE.lastUpdated], + ['First contact', SITE.firstContactLabel], ['Last updated', SITE.lastUpdated], ]; +const yearsSinceNote = spellNumberSentence(yearsSinceFirstContact()); const principles = [ 'Time-tested only', 'First principles only', 'No price predictions', 'No altcoin content', 'Personally vetted', @@ -20,9 +22,8 @@ const personLd = { '@context': 'https://schema.org', '@type': 'Person', name: SITE.author, - description: 'First wrote about Bitcoin in April 2011 when the price was $1.', + description: `First wrote about Bitcoin in ${SITE.firstContactLabel} when the price was $1.`, url: `${SITE.url}/about`, - email: SITE.contactEmail, }; const aboutPageLd = { @@ -52,7 +53,7 @@ const aboutPageLd = {
About

About BitcoinMind

-

First wrote about Bitcoin in April 2011, at roughly one dollar. The questions survived better than the conclusion.

+

First wrote about Bitcoin in {SITE.firstContactLabel}, at roughly one dollar. The questions survived better than the conclusion.

@@ -61,8 +62,8 @@ const aboutPageLd = {
Origin

I was right about the risk. I was wrong to stay on the sidelines.

-

In April 2011, I wrote a blog post about Bitcoin. The price was around $1. I called it devil-like — something that could be either temptation or the mouth of hell, depending on the person who touched it. I questioned whether its cryptographic guarantees were real, whether its creators might secretly inflate the supply, whether value produced without labor could ever earn genuine exchange value from the public. I ended the post with a single sentence: I will stay on the sidelines for now.

-

That post remains useful not because its conclusion was correct, but because its questions were concrete: can the rules be verified, can insiders alter supply, and how can a digital object acquire exchange value? Fifteen years later, I no longer treat reliability as one yes-or-no question. Protocol validation, software maintenance, mining, custody, markets, and law each fail differently.

+

In {SITE.firstContactLabel}, I wrote a blog post about Bitcoin. The price was around $1. I called it devil-like — something that could be either temptation or the mouth of hell, depending on the person who touched it. I questioned whether its cryptographic guarantees were real, whether its creators might secretly inflate the supply, whether value produced without labor could ever earn genuine exchange value from the public. I ended the post with a single sentence: I will stay on the sidelines for now.

+

That post remains useful not because its conclusion was correct, but because its questions were concrete: can the rules be verified, can insiders alter supply, and how can a digital object acquire exchange value? {yearsSinceNote} years later, I no longer treat reliability as one yes-or-no question. Protocol validation, software maintenance, mining, custody, markets, and law each fail differently.

The question that occupies me now is narrower and harder: what can a monetary network remove from institutional discretion, which powers merely move elsewhere, and what responsibilities return to the individual?

BitcoinMind is my attempt to think that question through in public.

@@ -115,7 +116,7 @@ const aboutPageLd = {

If you think something belongs here — a library item I missed, a text that changed your mind, an instrument you use and trust — tell me.

- + Email {SITE.contactEmail} diff --git a/src/pages/frames/1.astro b/src/pages/frames/1.astro index bcc49a0..dd5f77b 100644 --- a/src/pages/frames/1.astro +++ b/src/pages/frames/1.astro @@ -126,6 +126,6 @@ const articleLd = { diff --git a/src/pages/frames/2.astro b/src/pages/frames/2.astro index 767a277..c65ac2c 100644 --- a/src/pages/frames/2.astro +++ b/src/pages/frames/2.astro @@ -42,7 +42,8 @@ const articleLd = {