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
2 changes: 1 addition & 1 deletion .design-sync/previews/GalleryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GalleryCard, Section } from 'epyc-website'

// GalleryCard takes a single `item` matching the GalleryItem shape. Real
// gallery items come from Strapi at runtime, so these are literals in that
// gallery items come from the CMS at runtime, so these are literals in that
// shape pointing at production imagery. The card sizes itself from
// width/height, which is what makes the masonry layout stable.
const items = [
Expand Down
15 changes: 1 addition & 14 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
# Provider switch retained during the rollback window. Unknown values use Strapi.
CMS_PROVIDER=strapi

# Only this exact pair permits draft reads. All other values are published-only.
CMS_MODE=published
DEPLOYMENT_ROLE=production

# Payload API base URL and server-only read credentials.
PAYLOAD_URL=https://cms.epyc.in
PAYLOAD_URL=https://epyc-payload-cms.epyc.workers.dev
PAYLOAD_READ_TOKEN=
PAYLOAD_PREVIEW_TOKEN=

# HMAC secret shared with Payload's revalidation webhook. The webhook sends a
# hex SHA-256 signature in the x-epyc-signature header.
CMS_REVALIDATION_SECRET=

# Strapi v5 server base URL retained for migration and rollback.
STRAPI_URL=https://your-strapi-server.com

# Read-only API token from Strapi Admin → Settings → API Tokens
# Leave blank if the collections are publicly readable
STRAPI_API_TOKEN=

# Legacy fallback. Drafts still require DEPLOYMENT_ROLE=preview.
STRAPI_PREVIEW=

# Public base URL of the R2 media bucket.
NEXT_PUBLIC_MEDIA_BASE_URL=https://media.epyc.in

Expand Down
13 changes: 0 additions & 13 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,9 @@ jobs:
env:
NEXT_PUBLIC_DEPLOY_ENV: production
NEXT_PUBLIC_MEDIA_BASE_URL: ${{ secrets.PRODUCTION_NEXT_PUBLIC_MEDIA_BASE_URL }}
STRAPI_URL: ${{ secrets.PRODUCTION_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.PRODUCTION_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.PRODUCTION_STRAPI_PREVIEW }}
# Published-only reads. robots.ts and the root layout's metadata are
# evaluated at build time, so these must be present here as well as in
# the Worker's vars.
CMS_PROVIDER: payload
CMS_MODE: published
DEPLOYMENT_ROLE: production
PAYLOAD_URL: https://epyc-payload-cms.epyc.workers.dev
Expand All @@ -59,22 +55,13 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
STRAPI_URL: ${{ secrets.PRODUCTION_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.PRODUCTION_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.PRODUCTION_STRAPI_PREVIEW }}
PAYLOAD_READ_TOKEN: ${{ secrets.PRODUCTION_PAYLOAD_READ_TOKEN }}
CMS_REVALIDATION_SECRET: ${{ secrets.CMS_REVALIDATION_SECRET }}
run: |
jq -n \
--arg STRAPI_URL "$STRAPI_URL" \
--arg STRAPI_API_TOKEN "$STRAPI_API_TOKEN" \
--arg STRAPI_PREVIEW "$STRAPI_PREVIEW" \
--arg PAYLOAD_READ_TOKEN "$PAYLOAD_READ_TOKEN" \
--arg CMS_REVALIDATION_SECRET "$CMS_REVALIDATION_SECRET" \
'{
STRAPI_URL: $STRAPI_URL,
STRAPI_API_TOKEN: $STRAPI_API_TOKEN,
STRAPI_PREVIEW: $STRAPI_PREVIEW,
PAYLOAD_READ_TOKEN: $PAYLOAD_READ_TOKEN,
CMS_REVALIDATION_SECRET: $CMS_REVALIDATION_SECRET
}' \
Expand Down
13 changes: 0 additions & 13 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,9 @@ jobs:
env:
NEXT_PUBLIC_DEPLOY_ENV: staging
NEXT_PUBLIC_MEDIA_BASE_URL: ${{ secrets.STAGING_NEXT_PUBLIC_MEDIA_BASE_URL }}
STRAPI_URL: ${{ secrets.STAGING_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.STAGING_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.STAGING_STRAPI_PREVIEW }}
# Staging is the content-preview site: draft reads, noindex, no sitemap.
# robots.ts and the root layout's metadata are evaluated at build time,
# so these must be present here as well as in the Worker's vars.
CMS_PROVIDER: payload
CMS_MODE: draft
DEPLOYMENT_ROLE: preview
PAYLOAD_URL: https://epyc-payload-cms.epyc.workers.dev
Expand All @@ -59,22 +55,13 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
STRAPI_URL: ${{ secrets.STAGING_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.STAGING_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.STAGING_STRAPI_PREVIEW }}
PAYLOAD_PREVIEW_TOKEN: ${{ secrets.STAGING_PAYLOAD_PREVIEW_TOKEN }}
CMS_REVALIDATION_SECRET: ${{ secrets.CMS_REVALIDATION_SECRET }}
run: |
jq -n \
--arg STRAPI_URL "$STRAPI_URL" \
--arg STRAPI_API_TOKEN "$STRAPI_API_TOKEN" \
--arg STRAPI_PREVIEW "$STRAPI_PREVIEW" \
--arg PAYLOAD_PREVIEW_TOKEN "$PAYLOAD_PREVIEW_TOKEN" \
--arg CMS_REVALIDATION_SECRET "$CMS_REVALIDATION_SECRET" \
'{
STRAPI_URL: $STRAPI_URL,
STRAPI_API_TOKEN: $STRAPI_API_TOKEN,
STRAPI_PREVIEW: $STRAPI_PREVIEW,
PAYLOAD_PREVIEW_TOKEN: $PAYLOAD_PREVIEW_TOKEN,
CMS_REVALIDATION_SECRET: $CMS_REVALIDATION_SECRET
}' \
Expand Down
43 changes: 21 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ Marketing strategy, copy assets, and campaign briefs live in a separate repo (`e
| `AGENTS.md` | Next.js version caveats — read before touching routing or API conventions |
| `app/(my-app)/` | All user-facing routes |
| `app/(my-app)/page.tsx` | Homepage — assembled from `components/sections/*` |
| `app/(my-app)/projects/` | Projects index page (Strapi-driven) |
| `app/(my-app)/case-study/` | Static, hand-authored case study pages (not Strapi) |
| `app/(my-app)/blog/` | Blog index + post pages (Strapi-driven) |
| `app/(my-app)/gallery/` | Gallery index + detail pages (Strapi-driven) |
| `app/(my-app)/projects/` | Projects index page (CMS-driven) |
| `app/(my-app)/case-study/` | Static, hand-authored case study pages (not CMS-driven) |
| `app/(my-app)/blog/` | Blog index + post pages (CMS-driven) |
| `app/(my-app)/gallery/` | Gallery index + detail pages (CMS-driven) |
| `app/(my-app)/contact/` | Contact page with enquiry form |
| `components/ui/` | Primitive components — `Section`, `Container`, `Button`, `Pill`, `Badge`, `SectionHeading`, `ProjectCard`, `Reveal`, etc. |
| `components/ui/case-study-shell.tsx` | Shell + TL;DR toggle for case study pages — read this before building a new case study. Reference impl: `app/(my-app)/case-study/gokwik/page.tsx` |
| `components/sections/` | Full page sections — `Hero`, `FeaturedProjects`, `CTAFooter`, `Voices`, `FAQs`, etc. |
| `components/site-nav.tsx` | Global nav — adapts colour by pathname |
| `data/` | Typed const arrays for static content (projects, brands, testimonials, FAQs, nav) |
| `lib/strapi/` | Strapi CMS client (`fetchStrapi`) + TypeScript types |
| `lib/projects/` | Normalisation helpers for Strapi project data |
| `lib/cms/` | CMS client — `getCMS()`, the `PayloadProvider`, shared content types |
| `lib/projects/` | Normalisation helpers for CMS project data |
| `lib/cn.ts` | `cn()` helper — `clsx` + `tailwind-merge` |
| `middleware.ts` | Content negotiation — rewrites `Accept: text/markdown` requests to the markdown renderer. Must NOT be renamed to `proxy.ts`: proxy files are pinned to the Node runtime, which the Cloudflare adapter rejects at build time |
| `app/md/[[...path]]/route.ts` | Markdown renderer for every page route |
Expand All @@ -51,7 +51,7 @@ Marketing strategy, copy assets, and campaign briefs live in a separate repo (`e
| `wrangler.jsonc` | Cloudflare Workers deployment config |
| `open-next.config.ts` | OpenNext Cloudflare adapter config |

**Static vs CMS data split for projects**: The homepage `FeaturedProjects` section is driven by `data/projects.ts` (static). The `/projects` index page is driven by Strapi. Case study pages under `app/(my-app)/case-study/` are fully static — they do not use Strapi. The `caseStudyPath` field on a Strapi project entry controls the link from the `/projects` page to the case study.
**Static vs CMS data split for projects**: The homepage `FeaturedProjects` section is driven by `data/projects.ts` (static). The `/projects` index page is driven by the CMS. Case study pages under `app/(my-app)/case-study/` are fully static — they do not use the CMS. The `caseStudyPath` field on a CMS project entry controls the link from the `/projects` page to the case study.

---

Expand All @@ -68,7 +68,7 @@ curl -s https://epyc.in/md/website-redesign # same body

1. `middleware.ts` parses the `Accept` header (`lib/markdown/negotiate.ts`). Markdown wins only when it is named **and** ranked at least as high as HTML — `text/html,...,*/*;q=0.8` from a browser does not qualify. Matching requests are rewritten to `/md/<path>`.
2. `app/md/[[...path]]/route.ts` builds the markdown:
- **CMS routes** (`/blog`, `/blog/[slug]`, `/projects`, `/gallery`, `/gallery/[slug]`) are built from Strapi fields in `lib/markdown/sources.ts` — the author, date, and tags come through as data, not layout.
- **CMS routes** (`/blog`, `/blog/[slug]`, `/projects`, `/gallery`, `/gallery/[slug]`) are built from CMS fields in `lib/markdown/sources.ts` — the author, date, and tags come through as data, not layout.
- **Every other route** is rendered by fetching that page's own HTML and converting it (`lib/markdown/from-html.ts`). New pages are covered the day they ship with no extra work.
3. Responses carry `Content-Type: text/markdown; charset=utf-8`, `Vary: Accept`, `x-markdown-tokens`, `x-robots-tag: noindex, follow`, and a `Link: rel="canonical"` back to the HTML page. Markdown served under a page's own URL is marked `private` — Cloudflare keys its cache on the URL and ignores `Vary`, so a shared-cached copy would otherwise reach a browser.

Expand All @@ -79,7 +79,7 @@ curl -s https://epyc.in/md/website-redesign # same body
1. Emit the content as structured data on the page — a `FAQPage` JSON-LD block is harvested automatically by `faqSection()` (this is how `/website-redesign` gets its answers) and it earns rich results at the same time.
2. Add the route to a supplement or an explicit builder in `lib/markdown/sources.ts` (this is how the shared `data/faqs.ts` set reaches `/`, `/contact`, and `/gallery`).
- Images need real `alt` text or the converter drops them as decorative; runs of 3+ images collapse to an `Images: alt, alt, …` line.
- A new Strapi-driven route should get a builder in `sources.ts` rather than relying on HTML conversion — the fields are cleaner than the layout.
- A new CMS-driven route should get a builder in `sources.ts` rather than relying on HTML conversion — the fields are cleaner than the layout.

**Known gaps** — the `Voices` testimonial slider only server-renders its first slide, so the rest are missing from markdown (quotes are `ReactNode` in `data/testimonials.tsx`, not strings). Cloudflare's zone-level [Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) does the same job with no code, but needs a Pro+ plan on `epyc.in`; this implementation is plan-independent and gives us control over what the markdown contains. Validate the live site with `POST https://isitagentready.com/api/scan` and check `checks.contentAccessibility.markdownNegotiation.status`.

Expand All @@ -100,23 +100,22 @@ Key conventions:

---

## CMS — Strapi
## CMS — Payload

Dynamic content (projects, blog posts, gallery) is fetched from Strapi via `lib/strapi/client.ts`.
Dynamic content (projects, blog posts, gallery, authors) is fetched from Payload
through `getCMS()` in `lib/cms/index.ts`. Strapi was removed in full after the
2026 cutover — there is no provider switch and no rollback path in the code.

- `STRAPI_URL` and `STRAPI_API_TOKEN` must be set in the environment. In dev, 401s from Strapi are expected and non-fatal — pages gracefully return empty lists and re-hydrate on first real request via ISR (`revalidate: 60`).
- Types are in `lib/strapi/types.ts`.
- Static case study pages (e.g. `app/(my-app)/case-study/gokwik/`) do **not** use Strapi — they are fully static, hand-authored pages.
- **Separate Strapi instances per environment**: staging and production each have their own Strapi. Updating content in one does not affect the other. If a CMS change needs to appear on epyc.in, it must be made against the production Strapi (`cms.epyc.in`). Required env vars differ per environment: staging uses `STRAPI_URL` / `STRAPI_API_TOKEN`; production uses `PRODUCTION_STRAPI_URL` / `PRODUCTION_STRAPI_API_TOKEN` (plus `PRODUCTION_STRAPI_PREVIEW`, `PRODUCTION_NEXT_PUBLIC_MEDIA_BASE_URL`).
- **`mcp__strapi-epyc` connects to production** (`cms.epyc.in`). MCP edits go live on epyc.in, not staging.
- **Strapi updates — always read before write**: before calling any `update_*` MCP tool, call `get_*` first to fetch the full current document. Carry every field forward in the update payload, changing only the target field(s). Omitting a required field (e.g. `thumbnail`, `slug`, `type`) silently clears it on save.

---
- `PAYLOAD_URL` points at the Payload Worker (`https://epyc-payload-cms.epyc.workers.dev`). `PAYLOAD_READ_TOKEN` (published reads) or `PAYLOAD_PREVIEW_TOKEN` (draft reads) supplies the credential.
- One Payload instance serves both environments. Staging and production read the *same* content; they differ only in which revision they read.
- **Draft reads need `CMS_MODE=draft` AND `DEPLOYMENT_ROLE=preview` together** (`lib/cms/config.ts`). Any other combination fails closed to published content, so a single mistyped variable cannot leak drafts onto epyc.in.
- Shared content types are in `lib/cms/types.ts`; the Payload-specific mapping lives in `lib/cms/payload-provider.ts`.
- Static case study pages (e.g. `app/(my-app)/case-study/gokwik/`) do **not** use the CMS — they are fully static, hand-authored pages.
- Content edits reach the site two ways: a Payload revalidation webhook (`lib/cms/revalidation.ts`, HMAC-signed with `CMS_REVALIDATION_SECRET`) and 60s ISR as the fallback.

## Agents & MCP

- **`epyc-builder`** (`.claude/agents/epyc-builder.md`) — use for all GitHub issue and PR management on this repo. Invoke it via the Agent tool whenever creating, updating, or closing issues, or managing labels/milestones. Do not create GitHub issues directly from the main context.
- **`mcp__strapi-epyc`** — MCP server for reading and writing Strapi content (projects, blogs, gallery, authors). Connects to **production** Strapi at `cms.epyc.in`. Use `list_*` / `get_*` tools to read; `update_*` + `publish_*` to write. Always read before write (see CMS section above).

---

Expand All @@ -127,5 +126,5 @@ Dynamic content (projects, blog posts, gallery) is fetched from Strapi via `lib/
- **CI auto-deploys** on push to either branch (`.github/workflows/deploy-staging.yml` / `deploy-production.yml`). Manual deploy: `pnpm deploy:staging` or `pnpm deploy:production`.
- Both commands run `opennextjs-cloudflare build` then `wrangler deploy`.
- The contact form runs as a separate Cloudflare Worker (`workers/contact-webhook/`).
- **Contact form storage**: enquiries are written to **Cloudflare D1** (binding `DB`, one database per environment — see `wrangler.jsonc`), then handed to `CONTACT_QUEUE` for webhook delivery to n8n. D1 is the durable record; the webhook is how the team reads them. There is no Strapi collection for enquiries. Read rows with `wrangler d1 execute <db> --remote --command "SELECT * FROM contact_submissions"`.
- **CMS content changes do not require a redeploy** — pages use ISR (`revalidate: 60`) and pick up Strapi changes within 60 seconds.
- **Contact form storage**: enquiries are written to **Cloudflare D1** (binding `DB`, one database per environment — see `wrangler.jsonc`), then handed to `CONTACT_QUEUE` for webhook delivery to n8n. D1 is the durable record; the webhook is how the team reads them. There is no CMS collection for enquiries. Read rows with `wrangler d1 execute <db> --remote --command "SELECT * FROM contact_submissions"`.
- **CMS content changes do not require a redeploy** — pages use ISR (`revalidate: 60`) and pick up CMS changes within 60 seconds.
2 changes: 1 addition & 1 deletion app/md/[[...path]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// same body — which gives agents a plain URL when they cannot set headers.
//
// Two content paths:
// 1. CMS-driven routes are built from Strapi fields (`lib/markdown/sources.ts`).
// 1. CMS-driven routes are built from CMS fields (`lib/markdown/sources.ts`).
// 2. Everything else is served by fetching this app's own HTML for the route
// and converting it. New pages are therefore covered the day they ship,
// with no per-page work.
Expand Down
9 changes: 0 additions & 9 deletions cloudflare-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,20 @@ declare namespace Cloudflare {
DB: D1Database;
CONTACT_QUEUE: Queue;
ASSETS: Fetcher;
STRAPI_URL: string;
STRAPI_API_TOKEN: string;
STRAPI_PREVIEW: string;
NEXT_PUBLIC_MEDIA_BASE_URL: string;
NEON_DATABASE_URL: string;
}
interface ProductionEnv {
DB: D1Database;
CONTACT_QUEUE: Queue;
ASSETS: Fetcher;
STRAPI_URL: string;
STRAPI_API_TOKEN: string;
STRAPI_PREVIEW: string;
NEXT_PUBLIC_MEDIA_BASE_URL: string;
NEON_DATABASE_URL: string;
}
interface Env {
DB: D1Database;
CONTACT_QUEUE: Queue;
ASSETS: Fetcher;
STRAPI_URL: string;
STRAPI_API_TOKEN: string;
STRAPI_PREVIEW: string;
NEXT_PUBLIC_MEDIA_BASE_URL: string;
NEON_DATABASE_URL: string;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/blogs/normalise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function pickImageUrl(media: Media, size: CoverSize): { url: string; width: numb
}

export function normalise(blog: Blog, size: CoverSize = 'card'): NormalisedBlog {
// Strapi returns `null` for an unset media relation even though the type
// The CMS returns `null` for an unset media relation even though the type
// says otherwise — guard so a cover-less post doesn't crash the render.
const picked = blog.coverImage ? pickImageUrl(blog.coverImage, size) : null
const dateSource = blog.publishedDate ?? blog.publishedAt
Expand Down
8 changes: 1 addition & 7 deletions lib/cms/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getCMSProviderName, getContentState, isPreviewDeployment } from './config'
import { getContentState, isPreviewDeployment } from './config'

describe('CMS configuration', () => {
it('fails closed to published content', () => {
Expand All @@ -12,10 +12,4 @@ describe('CMS configuration', () => {
expect(getContentState({ CMS_MODE: 'draft', DEPLOYMENT_ROLE: 'preview' })).toBe('draft')
expect(isPreviewDeployment({ DEPLOYMENT_ROLE: 'preview' })).toBe(true)
})

it('keeps Strapi as the rollback default', () => {
expect(getCMSProviderName({})).toBe('strapi')
expect(getCMSProviderName({ CMS_PROVIDER: 'payload' })).toBe('payload')
expect(getCMSProviderName({ CMS_PROVIDER: 'unknown' })).toBe('strapi')
})
})
6 changes: 0 additions & 6 deletions lib/cms/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import type { ContentState } from './types'

export type CMSProviderName = 'strapi' | 'payload'

type Environment = Record<string, string | undefined>

export function getCMSProviderName(env: Environment = process.env): CMSProviderName {
return env.CMS_PROVIDER === 'payload' ? 'payload' : 'strapi'
}

/** Draft access needs two explicit preview flags. Any invalid configuration fails closed. */
export function getContentState(env: Environment = process.env): ContentState {
return env.CMS_MODE === 'draft' && env.DEPLOYMENT_ROLE === 'preview' ? 'draft' : 'published'
Expand Down
7 changes: 2 additions & 5 deletions lib/cms/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { getCMSProviderName, getContentState } from './config'
import { getContentState } from './config'
import { PayloadProvider } from './payload-provider'
import { StrapiProvider } from './strapi-provider'
import type { CMSProvider } from './types'

let provider: CMSProvider | undefined

export function getCMS(): CMSProvider {
if (provider) return provider
provider = getCMSProviderName() === 'payload'
? new PayloadProvider({ draft: getContentState() === 'draft' })
: new StrapiProvider()
provider = new PayloadProvider({ draft: getContentState() === 'draft' })
return provider
}

Expand Down
Loading
Loading