diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ae4ad6..f9284aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ All notable changes to this project will be documented in this file. - `stacker.my/blog` and its article URLs serve the blog pages again: the website image no longer runs `serve` in SPA mode, which rewrote every blog path to the landing page. +### Fixed — Website blog links and asset caching + +- The blog index's article and Blog links work from `stacker.my/blog`: they are root-absolute, so they no longer resolve against `/` when the page is served without a trailing slash. +- Built HTML references `styles.css` and `main.js` with a content-hashed `?v=`; CDN and browser caches can no longer keep serving the previous stylesheet across deploys. + ## [0.3.5] — 2026-10-19 ### Added — Managed server billing diff --git a/website/dist/blog/ai-startup-hosting.html b/website/dist/blog/ai-startup-hosting.html index 05ebb633..35d02898 100644 --- a/website/dist/blog/ai-startup-hosting.html +++ b/website/dist/blog/ai-startup-hosting.html @@ -10,7 +10,7 @@ - + @@ -351,6 +351,6 @@

Ready to deploy?

- + diff --git a/website/dist/blog/index.html b/website/dist/blog/index.html index d21d32d9..473c1411 100644 --- a/website/dist/blog/index.html +++ b/website/dist/blog/index.html @@ -7,7 +7,7 @@ - + @@ -26,7 +26,7 @@ Examples Commands Providers - Blog + Blog

- How to Deploy an AI-Built Startup Without Burning Cash on Hosting + How to Deploy an AI-Built Startup Without Burning Cash on Hosting

AI tools like Cursor, v0, and ChatGPT let anyone build a working app in hours. But hosting costs can eat your runway before you get your first customer. Here's how to deploy on your own server for a fraction of the price.

- Read article → + Read article → @@ -76,12 +76,12 @@

- + diff --git a/website/dist/index.html b/website/dist/index.html index f173f5ca..1ce77805 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -7,7 +7,7 @@ - + @@ -800,6 +800,6 @@

Community

- + diff --git a/website/package.json b/website/package.json index ffaa2a22..41dabffe 100644 --- a/website/package.json +++ b/website/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "Stacker CLI - Modern landing page", "scripts": { - "build": "tsc && cp src/index.html dist/ && cp src/styles.css dist/ && cp -r assets dist/ && cp -r src/blog dist/blog", + "build": "tsc && cp src/index.html dist/ && cp src/styles.css dist/ && cp -r assets dist/ && cp -r src/blog dist/blog && node scripts/bust-assets.mjs", "dev": "tsc --watch", "serve": "npx serve dist -p 3456", "test": "node tests/serving.mjs" diff --git a/website/scripts/bust-assets.mjs b/website/scripts/bust-assets.mjs new file mode 100644 index 00000000..a384784a --- /dev/null +++ b/website/scripts/bust-assets.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// Append a content hash to styles.css and main.js references in the built +// HTML so CDN and browser caches can never mask a deploy: the URL changes +// whenever the asset content changes. Runs at the end of `npm run build`. + +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const dist = join(root, "dist"); + +function htmlFiles(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) out.push(...htmlFiles(p)); + else if (p.endsWith(".html")) out.push(p); + } + return out; +} + +for (const asset of ["styles.css", "main.js"]) { + let bytes; + try { + bytes = readFileSync(join(dist, asset)); + } catch { + continue; + } + const hash = createHash("md5").update(bytes).digest("hex").slice(0, 8); + const escaped = asset.replace(".", "\\."); + const pattern = new RegExp( + `(?Examples Commands Providers - Blog + Blog

- How to Deploy an AI-Built Startup Without Burning Cash on Hosting + How to Deploy an AI-Built Startup Without Burning Cash on Hosting

AI tools like Cursor, v0, and ChatGPT let anyone build a working app in hours. But hosting costs can eat your runway before you get your first customer. Here's how to deploy on your own server for a fraction of the price.

- Read article → + Read article → @@ -76,7 +76,7 @@

diff --git a/website/tests/serving.mjs b/website/tests/serving.mjs index 41dc752f..0c30bb02 100644 --- a/website/tests/serving.mjs +++ b/website/tests/serving.mjs @@ -125,6 +125,97 @@ try { console.log(`PASS: GET ${c.path} serves the ${c.label}`); } } + + // The canonical blog URL is /blog (serve's clean-URL redirects drop the + // trailing slash), so relative links on that page resolve against /. Every + // internal link must still reach a served page, and the blog's own links + // must reach the blog pages. + const blogRes = await fetch(BASE + "/blog", { redirect: "follow" }); + const blogBody = await blogRes.text(); + const refs = [...blogBody.matchAll(/(?:href|src)="([^"]+)"/g)] + .map((m) => m[1]) + .filter((h) => !/^(?:https?:|#|mailto:)/.test(h)); + let brokenRefs = 0; + for (const ref of refs) { + const url = new URL(ref, blogRes.url); + const res = await fetch(url, { redirect: "follow" }); + if (res.status !== 200) { + brokenRefs++; + console.error( + `FAIL: blog link ${ref} resolves to ${url.pathname} -> HTTP ${res.status}` + ); + } + } + if (brokenRefs === 0) { + console.log(`PASS: all ${refs.length} internal links on /blog fetch a served page`); + } else { + failed++; + } + + const articleHref = blogBody.match( + /How to Deploy an AI-Built Startup/ + ); + if (!articleHref) { + failed++; + console.error("FAIL: blog index has no article title link"); + } else { + const res = await fetch(new URL(articleHref[1], blogRes.url), { + redirect: "follow", + }); + const body = await res.text(); + if (!/How to Deploy an AI-Built Startup/.test(body)) { + failed++; + console.error( + `FAIL: article title link ${articleHref[1]} does not land on the article` + ); + } else { + console.log("PASS: article title link lands on the article"); + } + } + + const activeNav = blogBody.match( + /<a ([^>]*class="[^"]*nav__link--active[^"]*"[^>]*)>Blog<\/a>/ + ); + const navHref = activeNav && activeNav[1].match(/href="([^"]+)"/); + if (!navHref) { + failed++; + console.error("FAIL: blog index has no active Blog nav link"); + } else { + const res = await fetch(new URL(navHref[1], blogRes.url), { + redirect: "follow", + }); + const body = await res.text(); + if (!/<title>Blog - Stacker<\/title>/.test(body)) { + failed++; + console.error( + `FAIL: Blog nav link ${navHref[1]} does not land on the blog index` + ); + } else { + console.log("PASS: Blog nav link lands on the blog index"); + } + } + + const cssHref = blogBody.match(/<link rel="stylesheet" href="([^"]+)"/); + if (!cssHref) { + failed++; + console.error("FAIL: blog index references no stylesheet"); + } else if (!/\?v=[0-9a-f]+/.test(cssHref[1])) { + failed++; + console.error( + `FAIL: stylesheet ${cssHref[1]} is not cache-busted; CDN/browser caches would keep serving it across deploys` + ); + } else { + const res = await fetch(new URL(cssHref[1], blogRes.url), { + redirect: "follow", + }); + const css = await res.text(); + if (res.status !== 200 || !css.includes(".blog-card")) { + failed++; + console.error(`FAIL: stylesheet ${cssHref[1]} lacks the blog styles`); + } else { + console.log("PASS: stylesheet is cache-busted and defines the blog styles"); + } + } } finally { server.kill(); }