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
11 changes: 10 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions __tests__/not-found-redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})
})
19 changes: 19 additions & 0 deletions __tests__/parse-substack-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &#8212;), and the fetch-substack pipeline JSON-stringifies the
// result, which would double-escape an undecoded "&#8212;" 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 &#8212; after &amp; 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', '<p></p>'))
Expand Down
80 changes: 80 additions & 0 deletions __tests__/parse-substack-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { parseSubstackJson } from '../scripts/lib/parse-substack-json'

const post = (overrides: Partial<Record<string, unknown>> = {}) => ({
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('<html><body>not json</body></html>')).toBeNull()
expect(parseSubstackJson('{"error": "not found"}')).toBeNull()
expect(parseSubstackJson('')).toBeNull()
})

it('does not throw on malformed JSON', () => {
expect(() => parseSubstackJson('{broken')).not.toThrow()
})
})
5 changes: 3 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -99,7 +100,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${archivoBlack.variable} ${spaceGrotesk.variable} ${jetbrainsMono.variable} antialiased`}
>
{children}
<ChatBot />
<ChatBotGate enabled={Boolean(CHAT_API_URL)} />
{GOATCOUNTER_SITE && (
<Script
data-goatcounter={`https://${GOATCOUNTER_SITE}.goatcounter.com/count`}
Expand Down
14 changes: 14 additions & 0 deletions components/chat/ChatBotGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client'

import dynamic from 'next/dynamic'

// Lazy so layout.tsx (a server component) never pulls ChatBot — and the
// stale offline answers baked into lib/services/chat.ts — into the shipped
// bundle at all while chat is unconfigured. The dynamic import only fires
// once this component actually renders <ChatBot/>, 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 <ChatBot />
}
15 changes: 7 additions & 8 deletions e2e/not-found.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
9 changes: 8 additions & 1 deletion lib/content/writing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions lib/services/emailjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ export async function sendContactEmail({
{
from_name: fromName,
from_email: fromEmail,
to_name: fromName,
to_email: fromEmail,
reply_to: fromEmail,
message
}
Expand Down
17 changes: 17 additions & 0 deletions scripts/check-build-env.js
Original file line number Diff line number Diff line change
@@ -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 <NAME>` — see docs/plans/01-reconnect-live-plumbing.md')
process.exit(1)
}
console.log('[env] all required build env present')
73 changes: 50 additions & 23 deletions scripts/fetch-substack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions scripts/lib/parse-substack-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,25 @@ const text = (value) => {
// it, so the risk isn't XSS — it's a card rendering "<p>Hello <em>world</em></p>"
// 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 "&lt;" that survived tag
// stripping (it never had a raw "<") should decode to display text, not be
// mistaken for a tag boundary. Decode &amp; last so "&amp;#8212;" doesn't
// double-decode into a literal "&#8212;".
const decodeEntities = (value) =>
value
.replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')

const stripHtml = (value) =>
decodeEntities(stripTags(value))
.replace(/\s+/g, ' ')
.trim()

Expand Down Expand Up @@ -85,4 +100,4 @@ function parseSubstackFeed(xml) {
.sort((a, b) => b.date.localeCompare(a.date))
}

module.exports = { parseSubstackFeed }
module.exports = { parseSubstackFeed, isPlaceholder }
Loading
Loading