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 && (