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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions website/dist/blog/ai-startup-hosting.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<meta name="published" content="2026-11-04">
<meta name="theme-color" content="#7e57c2">
<link rel="icon" type="image/png" href="../assets/stacker-logo.png">
<link rel="stylesheet" href="../styles.css">
<link rel="stylesheet" href="../styles.css?v=a27cd226">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -351,6 +351,6 @@ <h3>Ready to deploy?</h3>
</div>
</footer>

<script src="../main.js"></script>
<script src="../main.js?v=18ed0f5d"></script>
</body>
</html>
12 changes: 6 additions & 6 deletions website/dist/blog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="description" content="Guides, tutorials, and insights on deploying applications with Stacker. Learn how to self-host AI-generated apps and save on hosting costs.">
<meta name="theme-color" content="#7e57c2">
<link rel="icon" type="image/png" href="../assets/stacker-logo.png">
<link rel="stylesheet" href="../styles.css">
<link rel="stylesheet" href="../styles.css?v=a27cd226">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand All @@ -26,7 +26,7 @@
<a href="../index.html#examples" class="nav__link">Examples</a>
<a href="../index.html#commands" class="nav__link">Commands</a>
<a href="../index.html#providers" class="nav__link">Providers</a>
<a href="index.html" class="nav__link nav__link--active">Blog</a>
<a href="/blog/index.html" class="nav__link nav__link--active">Blog</a>
</div>
<div class="nav__actions">
<a href="https://github.com/trydirect/stacker" target="_blank" rel="noopener" class="nav__github">
Expand Down Expand Up @@ -55,12 +55,12 @@ <h1 class="blog-header__title">Blog</h1>
<time class="blog-card__date" datetime="2026-11-04">November 4, 2026</time>
</div>
<h2 class="blog-card__title">
<a href="ai-startup-hosting.html">How to Deploy an AI-Built Startup Without Burning Cash on Hosting</a>
<a href="/blog/ai-startup-hosting.html">How to Deploy an AI-Built Startup Without Burning Cash on Hosting</a>
</h2>
<p class="blog-card__excerpt">
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.
</p>
<a href="ai-startup-hosting.html" class="blog-card__link">Read article &rarr;</a>
<a href="/blog/ai-startup-hosting.html" class="blog-card__link">Read article &rarr;</a>
</article>
</div>
</div>
Expand All @@ -76,12 +76,12 @@ <h2 class="blog-card__title">
<div class="footer__links">
<a href="https://github.com/trydirect/stacker" target="_blank" rel="noopener">GitHub</a>
<a href="https://try.direct" target="_blank" rel="noopener">Platform</a>
<a href="index.html">Blog</a>
<a href="/blog/index.html">Blog</a>
</div>
</div>
</div>
</footer>

<script src="../main.js"></script>
<script src="../main.js?v=18ed0f5d"></script>
</body>
</html>
4 changes: 2 additions & 2 deletions website/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="description" content="Stacker v0.3.5 scans your project, deploys to cloud or bare metal, manages remote service secrets, cloud firewalls, agents, and app pipes - all from your terminal.">
<meta name="theme-color" content="#7e57c2">
<link rel="icon" type="image/png" href="assets/stacker-logo.png">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="styles.css?v=a27cd226">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -800,6 +800,6 @@ <h4>Community</h4>
</div>
</footer>

<script type="module" src="main.js"></script>
<script type="module" src="main.js?v=18ed0f5d"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions website/scripts/bust-assets.mjs
Original file line number Diff line number Diff line change
@@ -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(
`(?<![.\\w-])${escaped}(\\?v=[0-9a-f]+)?(?![\\w.-])`,
"g"
);
for (const file of htmlFiles(dist)) {
const html = readFileSync(file, "utf8");
const updated = html.replace(pattern, `${asset}?v=${hash}`);
if (updated !== html) writeFileSync(file, updated);
}
}
8 changes: 4 additions & 4 deletions website/src/blog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<a href="../index.html#examples" class="nav__link">Examples</a>
<a href="../index.html#commands" class="nav__link">Commands</a>
<a href="../index.html#providers" class="nav__link">Providers</a>
<a href="index.html" class="nav__link nav__link--active">Blog</a>
<a href="/blog/index.html" class="nav__link nav__link--active">Blog</a>
</div>
<div class="nav__actions">
<a href="https://github.com/trydirect/stacker" target="_blank" rel="noopener" class="nav__github">
Expand Down Expand Up @@ -55,12 +55,12 @@ <h1 class="blog-header__title">Blog</h1>
<time class="blog-card__date" datetime="2026-11-04">November 4, 2026</time>
</div>
<h2 class="blog-card__title">
<a href="ai-startup-hosting.html">How to Deploy an AI-Built Startup Without Burning Cash on Hosting</a>
<a href="/blog/ai-startup-hosting.html">How to Deploy an AI-Built Startup Without Burning Cash on Hosting</a>
</h2>
<p class="blog-card__excerpt">
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.
</p>
<a href="ai-startup-hosting.html" class="blog-card__link">Read article &rarr;</a>
<a href="/blog/ai-startup-hosting.html" class="blog-card__link">Read article &rarr;</a>
</article>
</div>
</div>
Expand All @@ -76,7 +76,7 @@ <h2 class="blog-card__title">
<div class="footer__links">
<a href="https://github.com/trydirect/stacker" target="_blank" rel="noopener">GitHub</a>
<a href="https://try.direct" target="_blank" rel="noopener">Platform</a>
<a href="index.html">Blog</a>
<a href="/blog/index.html">Blog</a>
</div>
</div>
</div>
Expand Down
91 changes: 91 additions & 0 deletions website/tests/serving.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
/<a href="([^"]+)">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 (!/<title>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();
}
Expand Down
Loading