diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index e8e3ece..1885bf0 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -42,8 +42,17 @@ jobs:
- name: Fetch Substack posts
run: node scripts/fetch-substack.js
+ # One env block feeding both the guard and the build: a var added to
+ # only one of them would either let a real gap through the guard or
+ # make the guard block a build that would have had everything it needs.
- name: Build Next.js
- run: npm run build
+ env:
+ NEXT_PUBLIC_EMAILJS_SERVICE_ID: ${{ secrets.NEXT_PUBLIC_EMAILJS_SERVICE_ID }}
+ NEXT_PUBLIC_EMAILJS_TEMPLATE_ID: ${{ secrets.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID }}
+ NEXT_PUBLIC_EMAILJS_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY }}
+ NEXT_PUBLIC_RECAPTCHA_SITE_KEY: ${{ secrets.NEXT_PUBLIC_RECAPTCHA_SITE_KEY }}
+ NEXT_PUBLIC_GOATCOUNTER_SITE: ${{ secrets.GOATCOUNTER_SITE }}
+ run: node scripts/check-build-env.js && npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
diff --git a/CLAUDE.md b/CLAUDE.md
index 6a9471a..a7d5d88 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,7 +33,7 @@ scroll-spy state and composing every section.
```
app/
-├── layout.tsx # Root layout, fonts, metadata/JSON-LD, ChatBot
+├── layout.tsx # Root layout, fonts, metadata/JSON-LD, ChatBot (only if NEXT_PUBLIC_CHAT_API_URL is set)
├── page.tsx # Loads projects/writing/travel data -> BrutalistLanding
├── globals.css # All styles (see Design System below)
└── dashboard-m7x9k2/ # Private-ish analytics dashboard (obscure URL, not linked)
diff --git a/__tests__/not-found-redirects.test.ts b/__tests__/not-found-redirects.test.ts
index 86a05f3..a0cf668 100644
--- a/__tests__/not-found-redirects.test.ts
+++ b/__tests__/not-found-redirects.test.ts
@@ -37,4 +37,11 @@ describe('not-found redirect table', () => {
expect(REDIRECTS.find((r) => r.test.test('/blog'))).toBeUndefined()
}
})
+
+ it('offers and redirects to Writing once a post exists', () => {
+ if (hasPosts()) {
+ expect(SECTIONS.some(([label]) => label === 'Writing')).toBe(true)
+ expect(REDIRECTS.find((r) => r.test.test('/blog'))?.to).toBe('/#writing')
+ }
+ })
})
diff --git a/__tests__/parse-substack-feed.test.ts b/__tests__/parse-substack-feed.test.ts
index 8aa9d30..a9acfd6 100644
--- a/__tests__/parse-substack-feed.test.ts
+++ b/__tests__/parse-substack-feed.test.ts
@@ -140,6 +140,25 @@ describe('parseSubstackFeed', () => {
expect(posts?.[0].subtitle).toBe('when a < b holds')
})
+ // Substack's CDATA description carries numeric entities (an em dash comes
+ // through as —), and the fetch-substack pipeline JSON-stringifies the
+ // result, which would double-escape an undecoded "—" into visible
+ // tag soup on the card.
+ it('decodes numeric and named HTML entities in the subtitle', () => {
+ const posts = parseSubstackFeed(
+ feed(
+ item(
+ 'Post',
+ 'https://x.substack.com/p/a',
+ 'Mon, 06 Jul 2026 12:00:00 GMT',
+ 'Before — after & done'
+ )
+ )
+ )
+
+ expect(posts?.[0].subtitle).toBe('Before — after & done')
+ })
+
it('omits the subtitle entirely when the description is only markup', () => {
const posts = parseSubstackFeed(
feed(item('Post', 'https://x.substack.com/p/a', 'Mon, 06 Jul 2026 12:00:00 GMT', '
'))
diff --git a/__tests__/parse-substack-json.test.ts b/__tests__/parse-substack-json.test.ts
new file mode 100644
index 0000000..31f3a46
--- /dev/null
+++ b/__tests__/parse-substack-json.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest'
+import { parseSubstackJson } from '../scripts/lib/parse-substack-json'
+
+const post = (overrides: Partial> = {}) => ({
+ title: 'Shipping with Claude',
+ canonical_url: 'https://x.substack.com/p/a',
+ post_date: '2026-07-06T12:00:00.000Z',
+ subtitle: 'How it went',
+ ...overrides,
+})
+
+describe('parseSubstackJson', () => {
+ it('extracts posts from a real posts list', () => {
+ const posts = parseSubstackJson(JSON.stringify([post()]))
+
+ expect(posts).toEqual([
+ {
+ title: 'Shipping with Claude',
+ url: 'https://x.substack.com/p/a',
+ date: '2026-07-06T12:00:00.000Z',
+ subtitle: 'How it went',
+ },
+ ])
+ })
+
+ // Same rule as the RSS path: this placeholder must never light up the
+ // dormant Writing section.
+ it('filters out the "Coming soon" placeholder', () => {
+ const posts = parseSubstackJson(JSON.stringify([post({ title: 'Coming soon' })]))
+
+ expect(posts).toEqual([])
+ })
+
+ it('returns newest first', () => {
+ const posts = parseSubstackJson(
+ JSON.stringify([
+ post({ title: 'Older', canonical_url: 'https://x.substack.com/p/1', post_date: '2026-06-01T12:00:00.000Z' }),
+ post({ title: 'Newer', canonical_url: 'https://x.substack.com/p/2', post_date: '2026-07-06T12:00:00.000Z' }),
+ ])
+ )
+
+ expect(posts?.map((p) => p.title)).toEqual(['Newer', 'Older'])
+ })
+
+ it('drops items missing a required field', () => {
+ const posts = parseSubstackJson(JSON.stringify([post({ title: '' }), post({ canonical_url: '' })]))
+
+ expect(posts).toEqual([])
+ })
+
+ it('drops items with an unparseable post_date', () => {
+ const posts = parseSubstackJson(JSON.stringify([post({ post_date: 'not-a-date' })]))
+
+ expect(posts).toEqual([])
+ })
+
+ it('omits the subtitle entirely when absent', () => {
+ const posts = parseSubstackJson(JSON.stringify([post({ subtitle: undefined })]))
+
+ expect(posts?.[0]).not.toHaveProperty('subtitle')
+ })
+
+ // Unlike the RSS parser, this path has no way to tell "the publication has
+ // zero posts" from "the endpoint drifted into some other empty shape" — it
+ // only ever runs after RSS already failed. So a bare [] must not be treated
+ // as authoritative enough to overwrite the committed POSTS.
+ it('returns null for a bare empty array, not []', () => {
+ expect(parseSubstackJson('[]')).toBeNull()
+ })
+
+ it('returns null when the body is not a posts list at all', () => {
+ expect(parseSubstackJson('not json')).toBeNull()
+ expect(parseSubstackJson('{"error": "not found"}')).toBeNull()
+ expect(parseSubstackJson('')).toBeNull()
+ })
+
+ it('does not throw on malformed JSON', () => {
+ expect(() => parseSubstackJson('{broken')).not.toThrow()
+ })
+})
diff --git a/app/layout.tsx b/app/layout.tsx
index 48c5c3a..0f4999f 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -7,10 +7,11 @@ import {
Space_Grotesk,
JetBrains_Mono,
} from "next/font/google";
-import { ChatBot } from "@/components/chat/ChatBot";
+import { ChatBotGate } from "@/components/chat/ChatBotGate";
import "./globals.css";
const GOATCOUNTER_SITE = process.env.NEXT_PUBLIC_GOATCOUNTER_SITE;
+const CHAT_API_URL = process.env.NEXT_PUBLIC_CHAT_API_URL;
const SITE_URL = "https://dommango.github.io";
const geistSans = Geist({
@@ -99,7 +100,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${archivoBlack.variable} ${spaceGrotesk.variable} ${jetbrainsMono.variable} antialiased`}
>
{children}
-
+
{GOATCOUNTER_SITE && (
, which `enabled` gates.
+const ChatBot = dynamic(() => import('./ChatBot').then((m) => m.ChatBot), { ssr: false })
+
+export function ChatBotGate({ enabled }: { enabled: boolean }) {
+ if (!enabled) return null
+ return
+}
diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts
index 43570bd..424d96a 100644
--- a/e2e/not-found.spec.ts
+++ b/e2e/not-found.spec.ts
@@ -16,15 +16,14 @@ test.describe("Custom 404", () => {
await expect(page.locator("#resume")).toBeInViewport();
});
- test("old /blog URL gets a plain 404 when there are no posts yet", async ({ page }) => {
- // There's no Writing section to rescue this to until a post ships
- // (see lib/content/writing.ts POSTS), so it must not redirect to a
- // dangling #writing anchor.
+ test("old /blog URL redirects to the writing section now that a post exists", async ({ page }) => {
+ // lib/content/writing.ts POSTS is non-empty as of plan 01, so /blog is a
+ // real redirect target, not the dangling-anchor case this used to guard.
await page.goto("/blog");
- await expect(page.getByRole("heading", { name: /nothing here/i })).toBeVisible();
- await expect(page.getByRole("link", { name: "Writing →" })).toHaveCount(0);
- await page.waitForTimeout(1500);
- expect(page.url()).toContain("/blog");
+ await expect(page.getByRole("heading", { name: /that page moved/i })).toBeVisible();
+ await expect(page.getByRole("status")).toContainText(/redirecting to writing/i);
+ await page.waitForURL(/\/#writing$/, { timeout: 10000 });
+ await expect(page.locator("#writing")).toBeInViewport();
});
test("unknown paths get a plain 404 with no redirect", async ({ page }) => {
diff --git a/lib/content/writing.ts b/lib/content/writing.ts
index ab954d6..b37e8f9 100644
--- a/lib/content/writing.ts
+++ b/lib/content/writing.ts
@@ -18,7 +18,14 @@ export interface WritingPost {
export const SUBSTACK_URL = 'https://dommangonon.substack.com'
// GENERATED — do not edit by hand. See scripts/fetch-substack.js.
-export const POSTS: WritingPost[] = []
+export const POSTS: WritingPost[] = [
+ {
+ title: "The game had already started",
+ url: "https://dommangonon.substack.com/p/the-game-had-already-started",
+ date: "2026-08-04T15:49:40.000Z",
+ subtitle: "What I learned shipping a bracket-pool app for my friends' World Cup pool — while the World Cup was being played.",
+ },
+]
// END GENERATED
export const hasPosts = (): boolean => POSTS.length > 0
diff --git a/lib/services/emailjs.ts b/lib/services/emailjs.ts
index 6e552a5..cdc048c 100644
--- a/lib/services/emailjs.ts
+++ b/lib/services/emailjs.ts
@@ -55,8 +55,6 @@ export async function sendContactEmail({
{
from_name: fromName,
from_email: fromEmail,
- to_name: fromName,
- to_email: fromEmail,
reply_to: fromEmail,
message
}
diff --git a/scripts/check-build-env.js b/scripts/check-build-env.js
new file mode 100644
index 0000000..da6478a
--- /dev/null
+++ b/scripts/check-build-env.js
@@ -0,0 +1,17 @@
+// Fails the deploy build when a NEXT_PUBLIC_* value the site needs is missing.
+// Local builds don't run this (see deploy.yml), so `npm run build` still works
+// without a .env.local.
+const REQUIRED = [
+ 'NEXT_PUBLIC_EMAILJS_SERVICE_ID',
+ 'NEXT_PUBLIC_EMAILJS_TEMPLATE_ID',
+ 'NEXT_PUBLIC_EMAILJS_PUBLIC_KEY',
+ 'NEXT_PUBLIC_GOATCOUNTER_SITE',
+]
+
+const missing = REQUIRED.filter((k) => !process.env[k])
+if (missing.length > 0) {
+ console.error(`[env] missing required build env: ${missing.join(', ')}`)
+ console.error('[env] set them with `gh secret set ` — see docs/plans/01-reconnect-live-plumbing.md')
+ process.exit(1)
+}
+console.log('[env] all required build env present')
diff --git a/scripts/fetch-substack.js b/scripts/fetch-substack.js
index 1e6d15a..00b1baf 100644
--- a/scripts/fetch-substack.js
+++ b/scripts/fetch-substack.js
@@ -9,8 +9,20 @@
const fs = require('fs')
const path = require('path')
const { parseSubstackFeed } = require('./lib/parse-substack-feed')
-
-const FEED_URL = 'https://dommangonon.substack.com/feed'
+const { parseSubstackJson } = require('./lib/parse-substack-json')
+
+// Each source pairs its URL with the parser that understands its body, so
+// the two can never drift apart the way a separate `url.includes(...)`
+// dispatch could.
+const SOURCES = [
+ { url: 'https://dommangonon.substack.com/feed', parse: parseSubstackFeed },
+ // Substack's JSON API sometimes answers when the RSS route is challenged.
+ { url: 'https://dommangonon.substack.com/api/v1/posts?limit=6', parse: parseSubstackJson },
+]
+const HEADERS = {
+ 'user-agent': 'Mozilla/5.0 (compatible; dommango.github.io build; +https://dommango.github.io)',
+ accept: 'application/rss+xml, application/xml, application/json;q=0.9, */*;q=0.8',
+}
const TARGET = path.join(__dirname, '../lib/content/writing.ts')
const MAX_POSTS = 6
const MARKER = '// GENERATED — do not edit by hand. See scripts/fetch-substack.js.'
@@ -37,36 +49,51 @@ const serialize = (posts) => {
return `export const POSTS: WritingPost[] = [\n${entries}\n]`
}
-async function main() {
- let posts = []
+// Fetches and parses a single source. Returns null (try the next source) on
+// any failure — non-2xx, unparseable body, or a body that isn't actually a
+// posts list (see parseSubstackFeed/parseSubstackJson doc comments).
+async function fetchPosts(url, parse) {
+ const response = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15000) })
- try {
- const response = await fetch(FEED_URL, {
- headers: { 'user-agent': 'dommango.github.io build' },
- signal: AbortSignal.timeout(15000),
- })
+ if (!response.ok) {
+ console.warn(`[substack] ${url} returned ${response.status}`)
+ return null
+ }
- if (!response.ok) {
- console.warn(`[substack] feed returned ${response.status}; keeping committed posts`)
- return
- }
+ const parsed = parse(await response.text())
- const parsed = parseSubstackFeed(await response.text())
+ if (parsed === null) {
+ console.warn(`[substack] ${url} response was not a posts feed`)
+ return null
+ }
+
+ return parsed
+}
- // null means the body wasn't a feed — a 200 carrying an interstitial or a
- // login page. Writing [] there would silently empty the Writing section on
- // a cron build nobody is watching, so treat it like any other outage.
- if (parsed === null) {
- console.warn('[substack] response was not an RSS feed; keeping committed posts')
- return
+async function main() {
+ let posts = null
+
+ for (const { url, parse } of SOURCES) {
+ try {
+ posts = await fetchPosts(url, parse)
+ } catch (error) {
+ console.warn(`[substack] fetch failed for ${url} (${error.message})`)
+ posts = null
}
+ if (posts !== null) break
+ }
- posts = parsed.slice(0, MAX_POSTS)
- } catch (error) {
- console.warn(`[substack] fetch failed (${error.message}); keeping committed posts`)
+ // null after every source means an outage — a 200 carrying an interstitial
+ // or a login page counts too. Writing [] there would silently empty the
+ // Writing section on a cron build nobody is watching, so keep committed
+ // posts instead, same as any other failure.
+ if (posts === null) {
+ console.warn('[substack] all sources failed; keeping committed posts')
return
}
+ posts = posts.slice(0, MAX_POSTS)
+
const source = fs.readFileSync(TARGET, 'utf8')
const markerIndex = source.indexOf(MARKER)
const endIndex = source.indexOf(END_MARKER)
diff --git a/scripts/lib/parse-substack-feed.js b/scripts/lib/parse-substack-feed.js
index 50d096b..fd89a93 100644
--- a/scripts/lib/parse-substack-feed.js
+++ b/scripts/lib/parse-substack-feed.js
@@ -36,10 +36,25 @@ const text = (value) => {
// it, so the risk isn't XSS — it's a card rendering "Hello world
"
// as visible tag soup. Only strip things shaped like a tag, so prose such as
// "a < b" survives.
-const stripHtml = (value) =>
+const stripTags = (value) => value.replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
+
+// Runs after stripTags, on purpose: a literal "<" that survived tag
+// stripping (it never had a raw "<") should decode to display text, not be
+// mistaken for a tag boundary. Decode & last so "—" doesn't
+// double-decode into a literal "—".
+const decodeEntities = (value) =>
value
- .replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
+ .replace(/(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
+ .replace(/([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/ /g, ' ')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+
+const stripHtml = (value) =>
+ decodeEntities(stripTags(value))
.replace(/\s+/g, ' ')
.trim()
@@ -85,4 +100,4 @@ function parseSubstackFeed(xml) {
.sort((a, b) => b.date.localeCompare(a.date))
}
-module.exports = { parseSubstackFeed }
+module.exports = { parseSubstackFeed, isPlaceholder }
diff --git a/scripts/lib/parse-substack-json.js b/scripts/lib/parse-substack-json.js
new file mode 100644
index 0000000..6e3f2fd
--- /dev/null
+++ b/scripts/lib/parse-substack-json.js
@@ -0,0 +1,51 @@
+// Pure JSON -> posts transform for Substack's `/api/v1/posts` endpoint, used
+// as a fallback when the RSS route is challenged. Mirrors parse-substack-feed.js:
+// null means the body wasn't a posts list at all, [] means a real list with
+// nothing publishable.
+
+const { isPlaceholder } = require('./parse-substack-feed')
+
+const toIsoDate = (value) => {
+ if (!value) return null
+ const parsed = new Date(value)
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
+}
+
+/**
+ * @param {string} body Raw JSON response body.
+ * @returns {Array<{title: string, url: string, date: string, subtitle?: string}>|null}
+ * Real posts newest first, [] for a list with none, or null if the body
+ * isn't a posts list at all.
+ */
+function parseSubstackJson(body) {
+ let parsed
+ try {
+ parsed = JSON.parse(body)
+ } catch {
+ return null
+ }
+
+ // This path only runs after RSS already failed, so there's no corroborating
+ // signal for "the publication genuinely has zero posts" the way the RSS
+ // parser gets from a present-but-empty . Treat a bare [] as
+ // inconclusive (like any other shape this endpoint might drift into)
+ // rather than let it overwrite the committed POSTS.
+ if (!Array.isArray(parsed) || parsed.length === 0) return null
+
+ return parsed
+ .map((item) => {
+ const title = typeof item?.title === 'string' ? item.title.trim() : ''
+ const url = typeof item?.canonical_url === 'string' ? item.canonical_url.trim() : ''
+ const date = toIsoDate(item?.post_date)
+ const subtitle = typeof item?.subtitle === 'string' ? item.subtitle.trim() : ''
+
+ if (!title || !url || !date) return null
+ if (isPlaceholder(title)) return null
+
+ return subtitle ? { title, url, date, subtitle } : { title, url, date }
+ })
+ .filter(Boolean)
+ .sort((a, b) => b.date.localeCompare(a.date))
+}
+
+module.exports = { parseSubstackJson }