From 98c0c75e348024ac35086c681b150ab0089afbfb Mon Sep 17 00:00:00 2001 From: dommango-sys <251805093+dommango@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:20:41 -0400 Subject: [PATCH 1/4] fix: wire build secrets into the Pages deploy, hide unconfigured chat The deploy build never received EMAILJS/reCAPTCHA/GoatCounter env vars, so the live contact form and analytics have been dead since deploy. Passes the existing GitHub secrets into the build step and fails loudly via a new check-build-env.js guard if any required one is missing. Also removes the misleading to_name/to_email EmailJS params (the template controls the real recipient) and stops rendering the chat widget when no chat API is configured, so it can't keep giving wrong offline answers until plan 06 replaces it. fetch-substack.js gets a browser-like User-Agent and a JSON API fallback for when the RSS route is challenged. --- .github/workflows/deploy.yml | 13 +++++ __tests__/parse-substack-json.test.ts | 78 +++++++++++++++++++++++++++ app/layout.tsx | 3 +- lib/services/emailjs.ts | 2 - scripts/check-build-env.js | 16 ++++++ scripts/fetch-substack.js | 71 ++++++++++++++++-------- scripts/lib/parse-substack-feed.js | 2 +- scripts/lib/parse-substack-json.js | 46 ++++++++++++++++ 8 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 __tests__/parse-substack-json.test.ts create mode 100644 scripts/check-build-env.js create mode 100644 scripts/lib/parse-substack-json.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e8e3ece..ac75e5d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,7 +42,20 @@ jobs: - name: Fetch Substack posts run: node scripts/fetch-substack.js + - name: Check required build env + 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 }} + run: node scripts/check-build-env.js + - name: Build Next.js + 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: npm run build - name: Upload artifact diff --git a/__tests__/parse-substack-json.test.ts b/__tests__/parse-substack-json.test.ts new file mode 100644 index 0000000..a4cbab8 --- /dev/null +++ b/__tests__/parse-substack-json.test.ts @@ -0,0 +1,78 @@ +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') + }) + + // The distinction the caller depends on: [] may overwrite the committed + // POSTS, null may not. + it('returns [] for a real empty list', () => { + expect(parseSubstackJson('[]')).toEqual([]) + }) + + 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..d616f9b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,6 +11,7 @@ import { ChatBot } from "@/components/chat/ChatBot"; 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} - + {CHAT_API_URL && } {GOATCOUNTER_SITE && (