From 96be50d8d04b4d775ccb00dfb6054dbd4172f9ed Mon Sep 17 00:00:00 2001 From: Jinho Jang <41753422+jinhojang6@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:09:02 +0900 Subject: [PATCH] refactor: make @repo/content the single source for home, get-started & movement page copy (#62) * docs: add page-copy single-source spec and implementation plan * feat(content): add typed home section schemas and migrate home copy into content Add 7 bespoke home* section schemas (+ techStack ctas) to the page section union and populate content/pages/en/home.json with home copy moved verbatim from messages. * refactor(web): source home page copy from @repo/content; drop home namespace from messages Feed every home section from content via findSection/data props (replacing next-intl getTranslations), remove the home namespace from messages/en.json, and add a regression guard plus content-based tests. Render output unchanged. * docs: add get-started/movement page-copy dedup spec and plan * feat(content): add get-started & movement section schemas and content docs Move get-started/movement page copy verbatim into content/pages so it is single-sourced in @repo/content. * refactor(web): source get-started/movement copy from content; drop those namespaces from messages Feed both pages from content via findSection/data props (replacing next-intl), switch them to content-based metadata, delete pages.getStarted/movement from messages, and add content-based tests + ownership guard. Render unchanged. * docs: add navbar-pages CMS copy spec and plan * feat(content): add navbar page section schemas + Pages docs (book, brand-kit, research, node-programme, lambda-prize, manifesto, media, podcast, broadcast, tech-stack explorer) * refactor(web): source navbar pages copy from content; drop those namespaces from messages Migrate book, brand-kit, research (incl. rich-text via a tagged-text renderer), node-programme, lambda-prize, manifesto, media, podcast, logos-broadcast-network, and the tech-stack explorer to read copy from content via findSection/data props and content-based metadata; remove the migrated pages.* namespaces from messages; update guards + contracts. Render output unchanged. * feat(web): source activist/coalition pages copy from content (CMS title/description) Migrate activistBuilder, activistLeaderSteward, and coalitionPartner page metadata from next-intl messages.pages.* into @repo/content PageCopy JSON, following the established bookCopy pattern. - Add activistBuilderCopySectionSchema, activistLeaderStewardCopySectionSchema, coalitionPartnerCopySectionSchema to packages/content/src/schemas/pages.ts and wire them into the pageSectionSchema discriminated union - Create content/pages/en/activist-builder.json, activist-leader-steward.json, coalition-partner.json with verbatim title/description/heading values from the old messages namespace - Switch all three route pages from createTranslatedPageMetadata to createPageMetadata(ROUTE), removing the NAMESPACE constant - Remove activistBuilder, activistLeaderSteward, coalitionPartner from apps/web/messages/en.json pages object - Add schema parse tests, renderToStaticMarkup page tests, messages ownership guards, and content-route contract entries for all three pages * chore(web): remove dead faq (pages.faq + unused FaqSection components) * fix: remove stale faq links * fix: pass payload env through turbo build * fix: preserve lambda prize support labels * fix: preserve live copy after master rebase * fix: address page copy review feedback * refactor: move design guide copy to content * feat: add schemas for home and page copy sections - Introduced home section schemas including homeSocialProof, homeChoosePath, homeDecide, homeStartBuilding, homeAbout, homeUseCases, and homeBuilderPortal. - Added page copy section schemas such as getStartedCopy, movementCopy, bookCopy, designGuideCopy, activistBuilderCopy, activistLeaderStewardCopy, coalitionPartnerCopy, researchCopy, nodeProgrammeCopy, lambdaPrizeCopy, mediaCopy, podcastCopy, broadcastCopy, and manifestoCopy. - Created shared schema for section keys to maintain consistency across sections. * chore(web): remove dead pages.faq from messages * fix: resolve page copy migration conflicts * fix: source connect form page copy from content * fix: remove duplicated afform page copy exports * fix: stop generating afform page copy exports * fix(cms): require only server actions key * fix(cms): load env files before build validation * fix(cms): make env loader available during build * fix(cms): load next env via require * test(cms): isolate missing env validation * feat: add homepage testnet highlight * fix: restore lambda prize support links --- Jenkinsfile | 1 - apps/cms/.env.docker.example | 4 +- apps/cms/Dockerfile | 3 - apps/cms/README.md | 6 +- apps/cms/next.config.mjs | 6 - apps/cms/package.json | 1 + .../scripts/validate-production-build-env.ts | 23 +- .../config/__tests__/next-deployment.test.ts | 135 ++- .../src/config/__tests__/payload-env.test.ts | 21 + .../__tests__/activist-builder-page.test.tsx | 37 + .../app/[locale]/activist-builder/page.tsx | 44 +- .../activist-leader-steward-page.test.tsx | 37 + .../[locale]/activist-leader-steward/page.tsx | 46 +- .../book/_sections/__tests__/hero.test.tsx | 34 + .../app/[locale]/book/_sections/book-page.tsx | 10 +- apps/web/app/[locale]/book/_sections/hero.tsx | 8 +- apps/web/app/[locale]/book/page.tsx | 37 +- .../__tests__/coalition-partner-page.test.tsx | 37 + .../app/[locale]/coalition-partner/page.tsx | 44 +- apps/web/app/[locale]/design-guide/page.tsx | 40 +- .../__tests__/get-started-copy.test.tsx | 169 +++ .../[locale]/get-started/_sections/build.tsx | 16 +- .../get-started/_sections/community.tsx | 18 +- .../[locale]/get-started/_sections/docs.tsx | 35 +- .../_sections/get-started-page.tsx | 17 +- .../get-started/_sections/install.tsx | 12 +- .../[locale]/get-started/_sections/types.ts | 12 +- apps/web/app/[locale]/get-started/page.tsx | 27 +- .../__tests__/lambda-prize-page.test.tsx | 206 ++++ .../[locale]/lambda-prize/_sections/atoms.tsx | 3 + .../lambda-prize/_sections/support.tsx | 27 +- .../[locale]/lambda-prize/_sections/types.ts | 2 + apps/web/app/[locale]/lambda-prize/page.tsx | 177 +--- .../__tests__/broadcast-page.test.tsx | 112 ++ .../[locale]/logos-broadcast-network/page.tsx | 65 +- apps/web/app/[locale]/manifesto/page.tsx | 69 +- .../media/__tests__/media-page.test.tsx | 169 +++ apps/web/app/[locale]/media/page.tsx | 88 +- apps/web/app/[locale]/movement/page.tsx | 27 +- .../__tests__/node-programme-page.test.tsx | 211 ++++ .../node-programme-signup-form.tsx | 46 +- apps/web/app/[locale]/node-programme/page.tsx | 78 +- apps/web/app/[locale]/page.tsx | 86 +- .../podcast/__tests__/podcast-page.test.tsx | 105 ++ apps/web/app/[locale]/podcast/page.tsx | 60 +- apps/web/app/[locale]/research/page.tsx | 40 +- .../node-programme-page-contract.test.ts | 34 +- .../components/sections/faq/faq-accordion.tsx | 81 -- .../components/sections/faq/faq-section.tsx | 30 - apps/web/components/sections/faq/index.ts | 1 - .../home/__tests__/about-section.test.tsx | 131 +++ .../__tests__/builder-portal-section.test.tsx | 64 ++ .../home/__tests__/decide-section.test.tsx | 33 + .../__tests__/feature-cards-section.test.tsx | 65 ++ .../__tests__/social-proof-section.test.tsx | 56 + .../__tests__/start-building-section.test.tsx | 53 + .../home/__tests__/use-cases-section.test.tsx | 90 ++ .../sections/home/about-section.tsx | 74 +- .../sections/home/builder-portal-section.tsx | 23 +- .../sections/home/decide-section.tsx | 20 +- .../sections/home/feature-cards-section.tsx | 33 +- .../sections/home/mountain-section.tsx | 69 -- .../home/parallel-society-section.tsx | 182 ---- .../sections/home/social-proof-section.tsx | 13 +- .../sections/home/start-building-section.tsx | 27 +- .../sections/home/use-cases-section.tsx | 67 +- .../movement/__tests__/movement-copy.test.tsx | 272 +++++ .../movement/_sections/action-cards.tsx | 20 +- .../sections/movement/_sections/activism.tsx | 10 +- .../sections/movement/_sections/builder.tsx | 28 +- .../sections/movement/_sections/campaign.tsx | 18 +- .../movement/_sections/get-involved.tsx | 18 +- .../sections/movement/_sections/hero.tsx | 36 +- .../sections/movement/_sections/types.ts | 3 +- .../sections/movement/movement-page.tsx | 27 +- .../research/__tests__/research-copy.test.tsx | 173 +++ .../sections/research/research-page.tsx | 78 +- .../__tests__/tech-stack-explorer.test.tsx | 86 ++ .../sections/shared/tech-stack-explorer.tsx | 19 +- apps/web/constants/routes.ts | 1 - .../__tests__/content-route-contracts.test.ts | 132 ++- .../lib/__tests__/home-about-content.test.ts | 21 + .../web/lib/__tests__/home-about-copy.test.ts | 19 - .../homepage-review-contracts.test.ts | 14 +- apps/web/lib/__tests__/link-policy.test.ts | 54 +- .../__tests__/messages-no-home-copy.test.ts | 52 + .../lib/__tests__/render-tagged-text.test.tsx | 179 ++++ .../lib/__tests__/rfps-github-links.test.ts | 60 +- .../lib/civicrm/afform-activist-builder.ts | 5 - .../civicrm/afform-activist-leader-steward.ts | 5 - .../lib/civicrm/afform-circle-contact-form.ts | 5 - .../lib/civicrm/afform-coalition-partner.ts | 5 - apps/web/lib/homepage-section-data.ts | 25 +- apps/web/lib/render-tagged-text.tsx | 53 + apps/web/lib/rfps-github.ts | 88 +- apps/web/lib/translated-page-metadata.ts | 49 - apps/web/messages/en.json | 998 ------------------ .../web/scripts/fetch-afform-contact-form.mjs | 5 - apps/web/vitest.config.ts | 7 + content/builders-hub/settings/en.json | 2 +- content/pages/en/activist-builder.json | 17 + content/pages/en/activist-leader-steward.json | 17 + content/pages/en/book.json | 14 + content/pages/en/coalition-partner.json | 17 + content/pages/en/design-guide.json | 26 + content/pages/en/get-started.json | 101 ++ content/pages/en/home.json | 328 +++--- content/pages/en/lambda-prize.json | 155 +++ content/pages/en/logos-broadcast-network.json | 31 + content/pages/en/manifesto.json | 63 ++ content/pages/en/media.json | 48 + content/pages/en/movement.json | 145 +++ content/pages/en/node-programme.json | 97 ++ content/pages/en/podcast.json | 27 + content/pages/en/research.json | 104 ++ content/pages/en/technology-stack.json | 7 +- docker-compose.prod.yml | 1 - packages/content/scripts/validate.ts | 11 +- .../__tests__/blog-family-sections.test.ts | 155 +++ .../schemas/__tests__/home-sections.test.ts | 166 +++ .../__tests__/manifesto-section.test.ts | 53 + .../__tests__/page-dedup-sections.test.ts | 187 ++++ packages/content/src/schemas/pages.ts | 129 ++- .../src/schemas/pages/home-sections.ts | 133 +++ .../src/schemas/pages/page-copy-sections.ts | 515 +++++++++ packages/content/src/schemas/pages/shared.ts | 8 + pnpm-lock.yaml | 3 + turbo.json | 3 - 128 files changed, 6098 insertions(+), 2627 deletions(-) create mode 100644 apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx create mode 100644 apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx create mode 100644 apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx create mode 100644 apps/web/app/[locale]/coalition-partner/__tests__/coalition-partner-page.test.tsx create mode 100644 apps/web/app/[locale]/get-started/_sections/__tests__/get-started-copy.test.tsx create mode 100644 apps/web/app/[locale]/lambda-prize/__tests__/lambda-prize-page.test.tsx create mode 100644 apps/web/app/[locale]/logos-broadcast-network/__tests__/broadcast-page.test.tsx create mode 100644 apps/web/app/[locale]/media/__tests__/media-page.test.tsx create mode 100644 apps/web/app/[locale]/node-programme/__tests__/node-programme-page.test.tsx create mode 100644 apps/web/app/[locale]/podcast/__tests__/podcast-page.test.tsx delete mode 100644 apps/web/components/sections/faq/faq-accordion.tsx delete mode 100644 apps/web/components/sections/faq/faq-section.tsx delete mode 100644 apps/web/components/sections/faq/index.ts create mode 100644 apps/web/components/sections/home/__tests__/about-section.test.tsx create mode 100644 apps/web/components/sections/home/__tests__/builder-portal-section.test.tsx create mode 100644 apps/web/components/sections/home/__tests__/decide-section.test.tsx create mode 100644 apps/web/components/sections/home/__tests__/feature-cards-section.test.tsx create mode 100644 apps/web/components/sections/home/__tests__/social-proof-section.test.tsx create mode 100644 apps/web/components/sections/home/__tests__/start-building-section.test.tsx create mode 100644 apps/web/components/sections/home/__tests__/use-cases-section.test.tsx delete mode 100644 apps/web/components/sections/home/mountain-section.tsx delete mode 100644 apps/web/components/sections/home/parallel-society-section.tsx create mode 100644 apps/web/components/sections/movement/__tests__/movement-copy.test.tsx create mode 100644 apps/web/components/sections/research/__tests__/research-copy.test.tsx create mode 100644 apps/web/components/sections/shared/__tests__/tech-stack-explorer.test.tsx create mode 100644 apps/web/lib/__tests__/home-about-content.test.ts delete mode 100644 apps/web/lib/__tests__/home-about-copy.test.ts create mode 100644 apps/web/lib/__tests__/messages-no-home-copy.test.ts create mode 100644 apps/web/lib/__tests__/render-tagged-text.test.tsx create mode 100644 apps/web/lib/render-tagged-text.tsx delete mode 100644 apps/web/lib/translated-page-metadata.ts create mode 100644 content/pages/en/activist-builder.json create mode 100644 content/pages/en/activist-leader-steward.json create mode 100644 content/pages/en/book.json create mode 100644 content/pages/en/coalition-partner.json create mode 100644 content/pages/en/design-guide.json create mode 100644 content/pages/en/get-started.json create mode 100644 content/pages/en/lambda-prize.json create mode 100644 content/pages/en/logos-broadcast-network.json create mode 100644 content/pages/en/manifesto.json create mode 100644 content/pages/en/media.json create mode 100644 content/pages/en/movement.json create mode 100644 content/pages/en/node-programme.json create mode 100644 content/pages/en/podcast.json create mode 100644 content/pages/en/research.json create mode 100644 packages/content/src/schemas/__tests__/blog-family-sections.test.ts create mode 100644 packages/content/src/schemas/__tests__/home-sections.test.ts create mode 100644 packages/content/src/schemas/__tests__/manifesto-section.test.ts create mode 100644 packages/content/src/schemas/__tests__/page-dedup-sections.test.ts create mode 100644 packages/content/src/schemas/pages/home-sections.ts create mode 100644 packages/content/src/schemas/pages/page-copy-sections.ts create mode 100644 packages/content/src/schemas/pages/shared.ts diff --git a/Jenkinsfile b/Jenkinsfile index 981e1f943c..e2764e69d8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -104,7 +104,6 @@ pipeline { "--build-arg NEXT_PUBLIC_SERVER_URL=https://${cmsDomain()} " + "--build-arg NEXT_PUBLIC_WEB_URL=https://${deployDomain()} " + "--build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY} " + - "--build-arg DEPLOYMENT_VERSION=${env.GIT_COMMIT} " + "-f ./apps/cms/Dockerfile ." ) } diff --git a/apps/cms/.env.docker.example b/apps/cms/.env.docker.example index 2ad24c05d9..dfc5a4e9d0 100644 --- a/apps/cms/.env.docker.example +++ b/apps/cms/.env.docker.example @@ -15,11 +15,9 @@ POSTGRES_DB=logos_cms # Generate with: openssl rand -hex 32 PAYLOAD_SECRET=change-me-openssl-rand-hex-32 -# --- Production build identity ---------------------------------------------- +# --- Production build key ---------------------------------------------------- # Generate with: openssl rand -base64 32 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=change-me-openssl-rand-base64-32 -# Prefer the deployed git SHA or release id. Changing this requires a rebuild. -DEPLOYMENT_VERSION=self-host # --- Public URLs (BAKED INTO THE CLIENT BUNDLE AT BUILD TIME) ----------------- # Must be the real public origins. Changing these requires a rebuild. diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile index ce66955dfa..6d023727ab 100644 --- a/apps/cms/Dockerfile +++ b/apps/cms/Dockerfile @@ -6,7 +6,6 @@ # # docker build -f apps/cms/Dockerfile \ # --build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$NEXT_SERVER_ACTIONS_ENCRYPTION_KEY" \ -# --build-arg DEPLOYMENT_VERSION="$(git rev-parse HEAD)" \ # --build-arg NEXT_PUBLIC_SERVER_URL=https://cms.example.com \ # --build-arg NEXT_PUBLIC_WEB_URL=https://www.example.com \ # -t logos-cms . @@ -41,7 +40,6 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ # builder — copy sources and build the Next.js production output. # ----------------------------------------------------------------------------- FROM base AS builder -ARG DEPLOYMENT_VERSION ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ARG NEXT_PUBLIC_SERVER_URL ARG NEXT_PUBLIC_WEB_URL @@ -49,7 +47,6 @@ ARG NEXT_PUBLIC_WEB_URL # load (during `next build`). DATABASE_URL/PAYLOAD_SECRET are NOT inlined and # are overridden with real values at runtime; the build never connects to the DB. ENV NODE_ENV=production \ - DEPLOYMENT_VERSION=${DEPLOYMENT_VERSION} \ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY} \ NEXT_TELEMETRY_DISABLED=1 \ NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_SERVER_URL} \ diff --git a/apps/cms/README.md b/apps/cms/README.md index dbdd39d604..31ca649e8c 100644 --- a/apps/cms/README.md +++ b/apps/cms/README.md @@ -87,7 +87,6 @@ Required values in `.env.docker`: | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Credentials for the bundled Postgres. | | `PAYLOAD_SECRET` | `openssl rand -hex 32`. | | `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` | **Build time** stable Server Actions key. Generate with `openssl rand -base64 32`. Use the same value across CMS deployments. | -| `DEPLOYMENT_VERSION` | **Build time** deployment id, ideally the git SHA or release id. | | `NEXT_PUBLIC_SERVER_URL` | **Baked into the client bundle at build time** — must be the real public CMS origin. Changing it requires a rebuild. | | `NEXT_PUBLIC_WEB_URL` | **Build-time** public web origin (same caveat). | | `CMS_PORT` | Host port mapped to the container's `:3000` (default `3001`). | @@ -112,7 +111,6 @@ export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=' unknown +} + +const require = createRequire(import.meta.url) +const { loadEnvConfig } = require('@next/env') as NextEnv + +loadEnvConfig(process.cwd()) + const missing: string[] = [] if (!process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY) { missing.push('NEXT_SERVER_ACTIONS_ENCRYPTION_KEY') } -if ( - !process.env.DEPLOYMENT_VERSION && - !process.env.NEXT_DEPLOYMENT_ID && - !process.env.VERCEL_GIT_COMMIT_SHA -) { - missing.push( - 'DEPLOYMENT_VERSION, NEXT_DEPLOYMENT_ID, or VERCEL_GIT_COMMIT_SHA' - ) -} - if (missing.length > 0) { console.error( `CMS production builds require ${missing.join( ', ' - )}. These values keep Next Server Actions stable across deployments.` + )}. This value keeps Next Server Actions stable across deployments.` ) process.exit(1) } diff --git a/apps/cms/src/config/__tests__/next-deployment.test.ts b/apps/cms/src/config/__tests__/next-deployment.test.ts index 4dde4d3ec6..1a0589b920 100644 --- a/apps/cms/src/config/__tests__/next-deployment.test.ts +++ b/apps/cms/src/config/__tests__/next-deployment.test.ts @@ -1,10 +1,18 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, it } from 'node:test' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) +const cmsRoot = process.cwd() +const buildEnvValidationScript = join( + cmsRoot, + 'scripts/validate-production-build-env.ts' +) +const tsxLoader = join(cmsRoot, 'node_modules/tsx/dist/esm/index.mjs') const importNextConfig = async ( overrides: Record @@ -52,7 +60,8 @@ const importNextConfig = async ( } const runBuildEnvValidation = async ( - overrides: Record + overrides: Record, + cwd = cmsRoot ): Promise<{ stderr: string; stdout: string }> => { const env = { ...process.env } for (const [key, value] of Object.entries(overrides)) { @@ -66,9 +75,9 @@ const runBuildEnvValidation = async ( try { const { stderr, stdout } = await execFileAsync( process.execPath, - ['--import', 'tsx', 'scripts/validate-production-build-env.ts'], + ['--import', tsxLoader, buildEnvValidationScript], { - cwd: process.cwd(), + cwd, env, } ) @@ -91,18 +100,27 @@ const runBuildEnvValidation = async ( describe('Next deployment configuration', () => { it('requires a stable Server Actions key for production builds', async () => { - const result = await runBuildEnvValidation({ - DEPLOYMENT_VERSION: 'ci', - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: undefined, - }) + const fixtureDir = await mkdtemp(join(tmpdir(), 'logos-cms-env-missing-')) - assert.match( - result.stderr, - /CMS production builds require NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/ - ) + try { + const result = await runBuildEnvValidation( + { + DEPLOYMENT_VERSION: 'ci', + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: undefined, + }, + fixtureDir + ) + + assert.match( + result.stderr, + /CMS production builds require NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/ + ) + } finally { + await rm(fixtureDir, { force: true, recursive: true }) + } }) - it('requires a deployment identifier for production builds', async () => { + it('accepts a stable Server Actions key without a deployment identifier', async () => { const result = await runBuildEnvValidation({ DEPLOYMENT_VERSION: undefined, NEXT_DEPLOYMENT_ID: undefined, @@ -110,104 +128,83 @@ describe('Next deployment configuration', () => { VERCEL_GIT_COMMIT_SHA: undefined, }) - assert.match( - result.stderr, - /CMS production builds require DEPLOYMENT_VERSION, NEXT_DEPLOYMENT_ID, or VERCEL_GIT_COMMIT_SHA/ - ) - }) - - it('accepts Vercel deployment ids as production build identifiers', async () => { - const result = await runBuildEnvValidation({ - DEPLOYMENT_VERSION: undefined, - NEXT_DEPLOYMENT_ID: 'dpl_C8QVLeEbYXbaNhbJjNG1Zw2J3Y33', - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', - VERCEL_GIT_COMMIT_SHA: undefined, - }) - assert.equal(result.stderr, '') }) - it('uses the Vercel commit SHA as the deployment id', async () => { - const result = await importNextConfig({ - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', - NODE_ENV: 'production', - NEXT_DEPLOYMENT_ID: undefined, - VERCEL_GIT_COMMIT_SHA: 'abc123', - }) + it('loads the stable Server Actions key from local env files', async () => { + const fixtureDir = await mkdtemp(join(tmpdir(), 'logos-cms-env-')) - assert.deepEqual(JSON.parse(result.stdout), { deploymentId: 'abc123' }) + try { + await writeFile( + join(fixtureDir, '.env'), + 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="n7o/dx+local-test-key="\n' + ) + + const result = await runBuildEnvValidation( + { + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: undefined, + }, + fixtureDir + ) + + assert.equal(result.stderr, '') + } finally { + await rm(fixtureDir, { force: true, recursive: true }) + } }) - it('uses the Vercel deployment id when Vercel provides one', async () => { + it('does not configure a deployment id from environment variables', async () => { const result = await importNextConfig({ - NEXT_DEPLOYMENT_ID: 'dpl_4qcQ6gQnY9YpyAFSWnXy9jVJsUy1', + DEPLOYMENT_VERSION: 'self-host-20260702', NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', NODE_ENV: 'production', + NEXT_DEPLOYMENT_ID: 'dpl_4qcQ6gQnY9YpyAFSWnXy9jVJsUy1', VERCEL_GIT_COMMIT_SHA: '5789cddea9cce53b639a79dfd5ccbc0eb19be56e', }) - assert.deepEqual(JSON.parse(result.stdout), { - deploymentId: 'dpl_4qcQ6gQnY9YpyAFSWnXy9jVJsUy1', - }) - }) - - it('uses the self-host deployment version as the deployment id', async () => { - const result = await importNextConfig({ - DEPLOYMENT_VERSION: 'self-host-20260702', - NEXT_DEPLOYMENT_ID: undefined, - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: 'test-key', - NODE_ENV: 'production', - VERCEL_GIT_COMMIT_SHA: undefined, - }) - - assert.deepEqual(JSON.parse(result.stdout), { - deploymentId: 'self-host-20260702', - }) + assert.deepEqual(JSON.parse(result.stdout), { deploymentId: null }) }) }) describe('self-host deployment environment wiring', () => { - it('passes Server Actions and deployment identifiers into the Docker build', async () => { - const [composeFile, dockerfile, envExample] = await Promise.all([ + it('passes only the Server Actions key into the Docker build', async () => { + const [composeFile, dockerfile, envExample, jenkinsfile] = await Promise.all([ readFile('../../docker-compose.prod.yml', 'utf8'), readFile('Dockerfile', 'utf8'), readFile('.env.docker.example', 'utf8'), + readFile('../../Jenkinsfile', 'utf8'), ]) assert.match( composeFile, /NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: \$\{NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\}/ ) - assert.match(composeFile, /DEPLOYMENT_VERSION: \$\{DEPLOYMENT_VERSION/) + assert.doesNotMatch(composeFile, /DEPLOYMENT_VERSION/) assert.match(dockerfile, /ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/) - assert.match(dockerfile, /ARG DEPLOYMENT_VERSION/) + assert.doesNotMatch(dockerfile, /ARG DEPLOYMENT_VERSION/) assert.match( dockerfile, /NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=\$\{NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\}/ ) + assert.doesNotMatch(jenkinsfile, /DEPLOYMENT_VERSION/) assert.match(envExample, /NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=/) - assert.match(envExample, /DEPLOYMENT_VERSION=/) + assert.doesNotMatch(envExample, /DEPLOYMENT_VERSION/) }) - it('declares CMS deployment env vars for Turbo build tasks', async () => { + it('declares only the CMS Server Actions key for Turbo build tasks', async () => { const turboConfig = JSON.parse( await readFile('../../turbo.json', 'utf8') ) as { tasks: { build: { env: string[] } } } + assert.ok( + turboConfig.tasks.build.env.includes('NEXT_SERVER_ACTIONS_ENCRYPTION_KEY') + ) assert.deepEqual( [ 'DEPLOYMENT_VERSION', - 'VERCEL_BRANCH_URL', 'NEXT_DEPLOYMENT_ID', - 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY', - 'PAYLOAD_DB_CONNECTION_TIMEOUT_MS', - 'PAYLOAD_DB_IDLE_TIMEOUT_MS', - 'PAYLOAD_DB_POOL_MAX', - 'PAYLOAD_DB_QUERY_TIMEOUT_MS', - 'PAYLOAD_HEALTH_TIMEOUT_MS', - 'VERCEL_ENV', 'VERCEL_GIT_COMMIT_SHA', - ].filter((name) => !turboConfig.tasks.build.env.includes(name)), + ].filter((name) => turboConfig.tasks.build.env.includes(name)), [] ) }) diff --git a/apps/cms/src/config/__tests__/payload-env.test.ts b/apps/cms/src/config/__tests__/payload-env.test.ts index 4414bff0c9..aefc534b6d 100644 --- a/apps/cms/src/config/__tests__/payload-env.test.ts +++ b/apps/cms/src/config/__tests__/payload-env.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' import { describe, it } from 'node:test' import { promisify } from 'node:util' @@ -198,6 +199,26 @@ describe('Payload production env guard', () => { }) }) +describe('Payload Vercel env propagation', () => { + it('exposes Payload runtime tuning env vars to the Turborepo build task', async () => { + const turboConfig = JSON.parse( + await readFile(new URL('../../../../../turbo.json', import.meta.url), 'utf8') + ) as { tasks?: { build?: { env?: string[] } } } + + const buildEnv = new Set(turboConfig.tasks?.build?.env ?? []) + + for (const name of [ + 'PAYLOAD_DB_POOL_MAX', + 'PAYLOAD_DB_CONNECTION_TIMEOUT_MS', + 'PAYLOAD_DB_QUERY_TIMEOUT_MS', + 'PAYLOAD_DB_IDLE_TIMEOUT_MS', + 'PAYLOAD_HEALTH_TIMEOUT_MS', + ]) { + assert.equal(buildEnv.has(name), true, `${name} must be listed in turbo.json build.env`) + } + }) +}) + describe('Payload admin document locking', () => { it('keeps the editable CMS collection list covered by the lock guard', async () => { const collections = await readPayloadCollections() diff --git a/apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx b/apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx new file mode 100644 index 0000000000..f6b5611ed0 --- /dev/null +++ b/apps/web/app/[locale]/activist-builder/__tests__/activist-builder-page.test.tsx @@ -0,0 +1,37 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { ActivistBuilderCopySection } from '@repo/content/schemas' + +const sectionData: ActivistBuilderCopySection = { + componentType: 'activistBuilderCopy', + key: 'activistBuilder.copy', + heading: 'Activist Builder', + intro: 'Activist Builder intro.', + privacy: 'Activist Builder privacy.', + privacyLink: 'https://logos.co/privacy-policy', +} + +describe('ActivistBuilderPage – content-driven metadata', () => { + it('section data has the correct componentType', () => { + expect(sectionData.componentType).toBe('activistBuilderCopy') + }) + + it('section data has the correct heading', () => { + expect(sectionData.heading).toBe('Activist Builder') + }) + + it('renders heading in a simple element', () => { + const html = renderToStaticMarkup( + createElement('h1', null, sectionData.heading) + ) + expect(html).toContain('Activist Builder') + expect(html).toContain(' { + expect(sectionData.intro).toBe('Activist Builder intro.') + expect(sectionData.privacy).toBe('Activist Builder privacy.') + expect(sectionData.privacyLink).toBe('https://logos.co/privacy-policy') + }) +}) diff --git a/apps/web/app/[locale]/activist-builder/page.tsx b/apps/web/app/[locale]/activist-builder/page.tsx index d84b82413e..a937c57344 100644 --- a/apps/web/app/[locale]/activist-builder/page.tsx +++ b/apps/web/app/[locale]/activist-builder/page.tsx @@ -1,3 +1,7 @@ +import { getPageCopy } from '@repo/content/loaders' +import { isActiveLocale } from '@repo/content/locales' +import type { ActivistBuilderCopySection } from '@repo/content/schemas' + import { AfformPageIntro, ConnectFormSection, @@ -9,30 +13,42 @@ import { AFFORM, AFFORM_NAME, AFFORM_OPTIONS, - AFFORM_PAGE_INTRO, - AFFORM_PAGE_PRIVACY, - AFFORM_PAGE_PRIVACY_LINK, } from '@/lib/civicrm/afform-activist-builder' -import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata' +import { createPageMetadata } from '@/lib/page-metadata' +import { createSectionFinder } from '@/lib/page-sections' -const NAMESPACE = 'pages.activistBuilder' +const ROUTE = ROUTES.activistBuilder +const findSection = createSectionFinder('activist-builder') -export const generateMetadata = createTranslatedPageMetadata({ - namespace: NAMESPACE, - path: ROUTES.activistBuilder, -}) +export const generateMetadata = createPageMetadata(ROUTE) function getAfformSubmitApiUrl() { const base = env.NEXT_PUBLIC_CIVI_CRM_URL return base ? `${base.replace(/\/+$/, '')}/api/public/afform-submit` : '' } -export default function ActivistBuilderPage() { +export default async function ActivistBuilderPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + if (!isActiveLocale(locale)) { + throw new Error(`ActivistBuilderPage received non-active locale "${locale}"`) + } + + const page = await getPageCopy(ROUTE, locale) + const copy = findSection( + page.sections, + 'activistBuilderCopy', + 'activistBuilder.copy' + ) + return ( + copy.intro ? ( + ) : undefined } > @@ -40,8 +56,8 @@ export default function ActivistBuilderPage() { afform={AFFORM} afformOptions={AFFORM_OPTIONS} apiEndpoint={getAfformSubmitApiUrl()} - pagePrivacy={AFFORM_PAGE_PRIVACY} - pagePrivacyLink={AFFORM_PAGE_PRIVACY_LINK} + pagePrivacy={copy.privacy} + pagePrivacyLink={copy.privacyLink} extraPayload={{ formName: AFFORM_NAME }} /> diff --git a/apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx b/apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx new file mode 100644 index 0000000000..a28fe7a8f9 --- /dev/null +++ b/apps/web/app/[locale]/activist-leader-steward/__tests__/activist-leader-steward-page.test.tsx @@ -0,0 +1,37 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { ActivistLeaderStewardCopySection } from '@repo/content/schemas' + +const sectionData: ActivistLeaderStewardCopySection = { + componentType: 'activistLeaderStewardCopy', + key: 'activistLeaderSteward.copy', + heading: 'Activist Leader / Steward', + intro: 'Activist Leader / Steward intro.', + privacy: 'Activist Leader / Steward privacy.', + privacyLink: 'https://logos.co/privacy-policy', +} + +describe('ActivistLeaderStewardPage – content-driven metadata', () => { + it('section data has the correct componentType', () => { + expect(sectionData.componentType).toBe('activistLeaderStewardCopy') + }) + + it('section data has the correct heading', () => { + expect(sectionData.heading).toBe('Activist Leader / Steward') + }) + + it('renders heading in a simple element', () => { + const html = renderToStaticMarkup( + createElement('h1', null, sectionData.heading) + ) + expect(html).toContain('Activist Leader / Steward') + expect(html).toContain(' { + expect(sectionData.intro).toBe('Activist Leader / Steward intro.') + expect(sectionData.privacy).toBe('Activist Leader / Steward privacy.') + expect(sectionData.privacyLink).toBe('https://logos.co/privacy-policy') + }) +}) diff --git a/apps/web/app/[locale]/activist-leader-steward/page.tsx b/apps/web/app/[locale]/activist-leader-steward/page.tsx index 49cbadd073..63a6976722 100644 --- a/apps/web/app/[locale]/activist-leader-steward/page.tsx +++ b/apps/web/app/[locale]/activist-leader-steward/page.tsx @@ -1,3 +1,7 @@ +import { getPageCopy } from '@repo/content/loaders' +import { isActiveLocale } from '@repo/content/locales' +import type { ActivistLeaderStewardCopySection } from '@repo/content/schemas' + import { AfformPageIntro, ConnectFormSection, @@ -9,30 +13,44 @@ import { AFFORM, AFFORM_NAME, AFFORM_OPTIONS, - AFFORM_PAGE_INTRO, - AFFORM_PAGE_PRIVACY, - AFFORM_PAGE_PRIVACY_LINK, } from '@/lib/civicrm/afform-activist-leader-steward' -import { createTranslatedPageMetadata } from '@/lib/translated-page-metadata' +import { createPageMetadata } from '@/lib/page-metadata' +import { createSectionFinder } from '@/lib/page-sections' -const NAMESPACE = 'pages.activistLeaderSteward' +const ROUTE = ROUTES.activistLeaderSteward +const findSection = createSectionFinder('activist-leader-steward') -export const generateMetadata = createTranslatedPageMetadata({ - namespace: NAMESPACE, - path: ROUTES.activistLeaderSteward, -}) +export const generateMetadata = createPageMetadata(ROUTE) function getAfformSubmitApiUrl() { const base = env.NEXT_PUBLIC_CIVI_CRM_URL return base ? `${base.replace(/\/+$/, '')}/api/public/afform-submit` : '' } -export default function ActivistLeaderStewardPage() { +export default async function ActivistLeaderStewardPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + if (!isActiveLocale(locale)) { + throw new Error( + `ActivistLeaderStewardPage received non-active locale "${locale}"` + ) + } + + const page = await getPageCopy(ROUTE, locale) + const copy = findSection( + page.sections, + 'activistLeaderStewardCopy', + 'activistLeaderSteward.copy' + ) + return ( + copy.intro ? ( + ) : undefined } > @@ -40,8 +58,8 @@ export default function ActivistLeaderStewardPage() { afform={AFFORM} afformOptions={AFFORM_OPTIONS} apiEndpoint={getAfformSubmitApiUrl()} - pagePrivacy={AFFORM_PAGE_PRIVACY} - pagePrivacyLink={AFFORM_PAGE_PRIVACY_LINK} + pagePrivacy={copy.privacy} + pagePrivacyLink={copy.privacyLink} extraPayload={{ formName: AFFORM_NAME }} /> diff --git a/apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx b/apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx new file mode 100644 index 0000000000..bd535819a1 --- /dev/null +++ b/apps/web/app/[locale]/book/_sections/__tests__/hero.test.tsx @@ -0,0 +1,34 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +// Stub next/image to avoid Next.js server-only dependencies +vi.mock('next/image', () => ({ + default: ({ alt, src }: { alt: string; src: string }) => + createElement('img', { alt, src }), +})) + +// Stub ActionGroup to avoid icon/constant dependencies +vi.mock('../atoms', () => ({ + ActionGroup: ({ className }: { className?: string }) => + createElement('div', { className }, 'actions'), +})) + +import { Hero } from '../hero' + +describe('Hero – heading driven from data', () => { + it('renders the heading prop in the h1', () => { + const html = renderToStaticMarkup( + createElement(Hero, { heading: 'Farewell to Westphalia' }) + ) + expect(html).toContain('Farewell to Westphalia') + expect(html).toContain(' { + const html = renderToStaticMarkup( + createElement(Hero, { heading: 'Custom Heading' }) + ) + expect(html).toContain('Custom Heading') + }) +}) diff --git a/apps/web/app/[locale]/book/_sections/book-page.tsx b/apps/web/app/[locale]/book/_sections/book-page.tsx index 39415e23da..67045fae23 100644 --- a/apps/web/app/[locale]/book/_sections/book-page.tsx +++ b/apps/web/app/[locale]/book/_sections/book-page.tsx @@ -1,3 +1,5 @@ +import type { BookCopySection } from '@repo/content/schemas' + import { Authors } from './authors' import { Hero } from './hero' import { Overview } from './overview' @@ -5,10 +7,14 @@ import { ReadTheBook } from './read-the-book' import { Translations } from './translations' import { Video } from './video' -export function BookPage() { +interface BookPageProps { + data: BookCopySection +} + +export function BookPage({ data }: BookPageProps) { return (
- +