diff --git a/.design-sync/previews/GalleryCard.tsx b/.design-sync/previews/GalleryCard.tsx index b6b06b5..98ccb55 100644 --- a/.design-sync/previews/GalleryCard.tsx +++ b/.design-sync/previews/GalleryCard.tsx @@ -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 = [ diff --git a/.env.example b/.env.example index bfd8252..1faca24 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,9 @@ -# 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= @@ -14,16 +11,6 @@ PAYLOAD_PREVIEW_TOKEN= # 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 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index b7c23b5..4379cbe 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -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 @@ -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 }' \ diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 448a61c..07bbf64 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -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 @@ -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 }' \ diff --git a/CLAUDE.md b/CLAUDE.md index 247dc08..ceee970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -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. --- @@ -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/`. 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. @@ -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`. @@ -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). --- @@ -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 --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 --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. diff --git a/app/md/[[...path]]/route.ts b/app/md/[[...path]]/route.ts index a29b2c0..c0834bb 100644 --- a/app/md/[[...path]]/route.ts +++ b/app/md/[[...path]]/route.ts @@ -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. diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index 632937b..53e87d2 100644 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -6,9 +6,6 @@ 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; } @@ -16,9 +13,6 @@ 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; } @@ -26,9 +20,6 @@ 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; } diff --git a/lib/blogs/normalise.ts b/lib/blogs/normalise.ts index ddfb36b..015dc95 100644 --- a/lib/blogs/normalise.ts +++ b/lib/blogs/normalise.ts @@ -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 diff --git a/lib/cms/config.test.ts b/lib/cms/config.test.ts index b92ceb7..ca31adb 100644 --- a/lib/cms/config.test.ts +++ b/lib/cms/config.test.ts @@ -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', () => { @@ -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') - }) }) diff --git a/lib/cms/config.ts b/lib/cms/config.ts index 6a22091..9bf48f0 100644 --- a/lib/cms/config.ts +++ b/lib/cms/config.ts @@ -1,13 +1,7 @@ import type { ContentState } from './types' -export type CMSProviderName = 'strapi' | 'payload' - type Environment = Record -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' diff --git a/lib/cms/index.ts b/lib/cms/index.ts index d2247c3..eef9a10 100644 --- a/lib/cms/index.ts +++ b/lib/cms/index.ts @@ -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 } diff --git a/lib/cms/payload-provider.ts b/lib/cms/payload-provider.ts index d456bac..634e635 100644 --- a/lib/cms/payload-provider.ts +++ b/lib/cms/payload-provider.ts @@ -103,7 +103,7 @@ export class PayloadProvider implements CMSProvider { } private async list(collection: string, params: Record = {}): Promise { - if (!this.base) throw new Error('Payload: PAYLOAD_URL is required when CMS_PROVIDER=payload') + if (!this.base) throw new Error('Payload: PAYLOAD_URL is required') const url = new URL(`/api/${collection}`, this.base) Object.entries({ depth: '2', limit: '100', draft: String(this.draft), ...params }).forEach(([key, value]) => url.searchParams.set(key, value), diff --git a/lib/cms/strapi-provider.ts b/lib/cms/strapi-provider.ts deleted file mode 100644 index 250b072..0000000 --- a/lib/cms/strapi-provider.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { fetchStrapi } from '@/lib/strapi/client' -import type { StrapiBlog, StrapiGalleryItem, StrapiList, StrapiMedia, StrapiProject } from '@/lib/strapi/types' -import type { Blog, CMSProvider, GalleryItem, ListOptions, Media, Project } from './types' - -const BLOG_POPULATE = { - 'populate[coverImage][fields]': 'url,width,height,alternativeText,formats', - 'populate[author][fields]': 'name,slug', -} -const GALLERY_POPULATE = { 'populate[image][fields]': 'url,width,height,alternativeText' } - -function media(value?: StrapiMedia | null): Media | null { - if (!value) return null - return { ...value, id: String(value.id) } -} - -function blog(value: StrapiBlog): Blog { - return { - ...value, - id: value.documentId || String(value.id), - coverImage: media(value.coverImage), - author: value.author ? { ...value.author, id: String(value.author.id) } : null, - } -} - -function project(value: StrapiProject): Project { - return { - ...value, - id: value.documentId || String(value.id), - thumbnail: media(value.thumbnail), - type: (value.type ?? '').split(',').map((item) => item.trim()).filter(Boolean), - } -} - -function gallery(value: StrapiGalleryItem): GalleryItem { - return { - ...value, - id: value.documentId || String(value.id), - image: media(value.image), - designers: (value.designer ?? '').split(',').map((item) => item.trim()).filter(Boolean), - } -} - -export class StrapiProvider implements CMSProvider { - async listBlogs(options: ListOptions = {}): Promise { - const { data } = await fetchStrapi>('/blogs', { - ...BLOG_POPULATE, - ...(options.excludeSlug ? { 'filters[slug][$ne]': options.excludeSlug } : {}), - sort: 'publishedDate:desc', - 'pagination[limit]': String(options.limit ?? 100), - }) - return data.filter((item) => item.slug).map(blog) - } - - async getBlogBySlug(slug: string): Promise { - const { data } = await fetchStrapi>('/blogs', { - 'filters[slug][$eq]': slug, - ...BLOG_POPULATE, - 'pagination[limit]': '1', - }) - return data[0] ? blog(data[0]) : null - } - - async listProjects(options: ListOptions = {}): Promise { - const { data } = await fetchStrapi>('/projects', { - 'populate[thumbnail][fields]': 'url,width,height,alternativeText,formats', - 'populate[industry][fields]': 'title,slug', - 'populate[platform][fields]': 'title,slug', - sort: 'featured:desc,publishedAt:desc', - 'pagination[limit]': String(options.limit ?? 200), - }) - return data.filter((item) => item.slug).map(project) - } - - async listGalleryItems(options: ListOptions = {}): Promise { - const { data } = await fetchStrapi>('/gallery-items', { - ...GALLERY_POPULATE, - ...(options.excludeSlug ? { 'filters[slug][$ne]': options.excludeSlug } : {}), - 'pagination[limit]': String(options.limit ?? 500), - }) - return data.filter((item) => item.slug).map(gallery) - } - - async getGalleryItemBySlug(slug: string): Promise { - const { data } = await fetchStrapi>('/gallery-items', { - 'filters[slug][$eq]': slug, - ...GALLERY_POPULATE, - 'pagination[limit]': '1', - }) - return data[0] ? gallery(data[0]) : null - } - - async listBlogSlugsForSitemap() { - const blogs = await this.listBlogs({ limit: 1000 }) - return blogs.map(({ slug, publishedAt }) => ({ slug, publishedAt })) - } -} diff --git a/lib/image-loader.ts b/lib/image-loader.ts index 6c4f1f3..7ce7a8e 100644 --- a/lib/image-loader.ts +++ b/lib/image-loader.ts @@ -29,7 +29,7 @@ export default function imageLoader({ src, width, quality }: LoaderArgs): string const options = `width=${width},quality=${quality ?? 75},format=auto` // `next dev` isn't behind a Cloudflare zone — cdn-cgi doesn't work. - // Bare Strapi paths need the media base prepended so they're fetchable. + // Bare CMS media paths need the media base prepended so they're fetchable. // // The `?w=` is carried purely so Next can see the width in the returned URL. // Without it every logs "loader property that does not implement @@ -47,10 +47,10 @@ export default function imageLoader({ src, width, quality }: LoaderArgs): string // Public folder assets (/images/site/...) — cdn-cgi on the main zone. if (src.startsWith('/images/')) return `/cdn-cgi/image/${options}${src}` - // Absolute URL fallback — shouldn't happen with Strapi bare paths but handle gracefully. + // Absolute URL fallback — shouldn't happen with bare CMS media paths but handle gracefully. if (src.startsWith('http')) return `/cdn-cgi/image/${options}/${src}` - // Strapi bare path (/hash.webp) — route through media.epyc.in cdn-cgi. + // Bare CMS media path (/hash.webp) — route through media.epyc.in cdn-cgi. // Source and cdn-cgi are on the same Cloudflare zone so "this zone only" is satisfied. return `${mediaBase}/cdn-cgi/image/${options}${src}` } diff --git a/lib/markdown/from-html.ts b/lib/markdown/from-html.ts index e8a6380..5109670 100644 --- a/lib/markdown/from-html.ts +++ b/lib/markdown/from-html.ts @@ -1,6 +1,6 @@ // Dependency-free HTML → Markdown converter. // -// Used for two inputs: Strapi rich-text `content` (a small, predictable tag set) +// Used for two inputs: CMS rich-text `content` (a small, predictable tag set) // and full rendered pages fetched from this app (see `app/md/[[...path]]/route.ts`). // A DOM-based converter (turndown et al.) needs a DOM shim that does not exist in // the Workers runtime, so this walks the tag stream positionally instead: opening diff --git a/lib/projects/normalise.ts b/lib/projects/normalise.ts index 531cfe4..a235e69 100644 --- a/lib/projects/normalise.ts +++ b/lib/projects/normalise.ts @@ -43,7 +43,7 @@ function pickImageUrl(media: Media): { url: string; width: number; height: numbe } export function normaliseProject(project: Project): NormalisedProject { - // Strapi returns null for unset media/relations in draft mode even though + // The CMS returns null for unset media/relations in draft mode even though // the types say required — guard every dereference so an incomplete draft // renders with placeholders/fallbacks instead of crashing the page. const picked = project.thumbnail ? pickImageUrl(project.thumbnail) : null diff --git a/lib/strapi/client.ts b/lib/strapi/client.ts deleted file mode 100644 index 7dfb3ce..0000000 --- a/lib/strapi/client.ts +++ /dev/null @@ -1,45 +0,0 @@ -const BASE = process.env.STRAPI_URL -const TOKEN = process.env.STRAPI_API_TOKEN -// When true, fetch Strapi Draft & Publish drafts (`?status=draft`) instead of -// published content. Drafts are never edge-cached so editors see live edits. -const PREVIEW = - process.env.DEPLOYMENT_ROLE === 'preview' && - (process.env.CMS_MODE === 'draft' || process.env.STRAPI_PREVIEW === 'true') - -const EMPTY = { data: [], meta: { pagination: { start: 0, limit: 0, total: 0 } } } - -export async function fetchStrapi(path: string, params?: Record): Promise { - // STRAPI_URL is not available at CI build time — return an empty list so - // static pre-rendering succeeds. ISR revalidates on the first real request. - if (!BASE) return EMPTY as T - - let url: URL - try { - url = new URL(`/api${path}`, BASE) - } catch { - console.error(`Strapi: invalid STRAPI_URL "${BASE}"`) - return EMPTY as T - } - - if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)) - // Strapi v5 draft mode — only set if the caller hasn't already chosen a status. - if (PREVIEW && !url.searchParams.has('status')) url.searchParams.set('status', 'draft') - - let res: Response - try { - res = await fetch(url, { - headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}, - // Drafts must always be fresh; published content uses 60s ISR. - ...(PREVIEW ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }), - }) - } catch (err) { - console.error(`Strapi: fetch failed for ${path}`, err) - return EMPTY as T - } - - if (!res.ok) { - console.error(`Strapi ${res.status}: ${path}`) - return EMPTY as T - } - return res.json() -} diff --git a/lib/strapi/types.ts b/lib/strapi/types.ts deleted file mode 100644 index 92f2915..0000000 --- a/lib/strapi/types.ts +++ /dev/null @@ -1,77 +0,0 @@ -export type StrapiMediaFormats = { - large?: { url: string; width: number; height: number } - medium?: { url: string; width: number; height: number } - small?: { url: string; width: number; height: number } - thumbnail?: { url: string; width: number; height: number } -} - -export type StrapiMedia = { - id: number - url: string - width: number - height: number - alternativeText?: string | null - formats?: StrapiMediaFormats -} - -export type StrapiAuthor = { id: number; name: string; slug: string } - -export type StrapiBlog = { - id: number - documentId: string - title: string - slug: string - publishedDate: string | null - publishedAt: string - updatedAt: string - // Typed nullable because draft entries (staging `?status=draft`) may have - // these unset. Normalisers/pages guard accordingly. - coverImage?: StrapiMedia | null - coverImageAlt?: string | null - author?: StrapiAuthor | null - readTime?: string | null - content?: string | null - metaTitle?: string | null - metaDescription?: string | null -} - -export type StrapiIndustry = { id: number; title: string; slug: string } -export type StrapiPlatform = { id: number; title: string; slug: string } - -export type StrapiProject = { - id: number - documentId: string - title: string - slug: string - publishedAt: string - // Nullable in draft mode (staging `?status=draft`) — normalisers guard. - thumbnail?: StrapiMedia | null - thumbnailAlt?: string | null - type?: string | null - industry?: StrapiIndustry | null - platform?: StrapiPlatform | null - redirectLink?: string | null - caseStudyPath?: string | null - featured: boolean -} - -export type StrapiGalleryItem = { - id: number - documentId: string - title: string - slug: string - image?: StrapiMedia | null - imageAlt?: string | null - videoUrl?: string | null - content?: string | null - designer?: string | null - externalUrl?: string | null - year?: string | null -} - -export type StrapiList = { - data: T[] - meta: { pagination: { start: number; limit: number; total: number } } -} - -export type StrapiSingle = { data: T } diff --git a/package.json b/package.json index 925609f..48e9043 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,7 @@ "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy:staging": "NEXT_PUBLIC_DEPLOY_ENV=staging opennextjs-cloudflare build && wrangler deploy --env staging && wrangler deploy --env staging --config workers/contact-webhook/wrangler.jsonc", "deploy:production": "NEXT_PUBLIC_DEPLOY_ENV=production opennextjs-cloudflare build && wrangler deploy --env production && wrangler deploy --env production --config workers/contact-webhook/wrangler.jsonc", - "cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts", - "cms:parity": "tsx scripts/cms-parity.ts" + "cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts" }, "dependencies": { "@hookform/resolvers": "^5.2.2", diff --git a/scripts/cms-parity.ts b/scripts/cms-parity.ts deleted file mode 100644 index efe6917..0000000 --- a/scripts/cms-parity.ts +++ /dev/null @@ -1,205 +0,0 @@ -// Compares what the website would render from Strapi against what it would -// render from Payload, through the same provider interface the pages use. -// -// The comparison runs on normalised output — the shape the components actually -// receive — rather than on raw API responses, so a difference here is a -// difference a visitor could see. -// -// Some differences are intended by the migration. Those are declared below and -// reported as "explained"; anything else is a defect and fails the run. -// -// Usage: -// STRAPI_URL=https://cms.epyc.in STRAPI_API_TOKEN=... \ -// PAYLOAD_URL=https://epyc-payload-cms.epyc.workers.dev \ -// pnpm cms:parity - -import { writeFile, mkdir } from 'node:fs/promises' -import { PayloadProvider } from '../lib/cms/payload-provider' -import { StrapiProvider } from '../lib/cms/strapi-provider' -import type { CMSProvider } from '../lib/cms/types' -import { normalise } from '../lib/blogs/normalise' -import { normaliseProject } from '../lib/projects/normalise' -import { normaliseGallery } from '../lib/gallery/normalise' - -const REPORT = 'artifacts/parity-report.json' - -type Diff = { entity: string; key: string; field: string; strapi: unknown; payload: unknown; explained?: string } - -/** Media URLs changed shape twice over: Strapi served a resized derivative at a - * bare path (`/large_x.webp`), Payload records the original at an absolute URL - * (`https://media.epyc.in/x.webp`). Cloudflare resizes either form on request — - * verified against the live zone — so only the underlying file must match. */ -function canonicalMedia(value: unknown): string { - return String(value ?? '') - .replace(/^https?:\/\/[^/]+/, '') - .replace(/(^|\/)(large|medium|small|thumbnail)_/, '$1') -} - -/** Differences the migration intends. Each returns a reason when it recognises - * the pair, so an intended change is never mistaken for a defect — and a defect - * is never waved through as intended. */ -const EXPECTED: Array<(diff: Omit) => string | undefined> = [ - // Strapi served a pre-resized derivative (`/large_x.webp`); Payload records - // the original (`/x.webp`) because resizing happens at Cloudflare's edge. - ({ field, strapi, payload }) => { - if (!/image|src|url|thumbnail|cover/i.test(field)) return undefined - if (!strapi || !payload) return undefined - return canonicalMedia(strapi) === canonicalMedia(payload) - ? 'Original at an absolute URL replaces Strapi derivative; Cloudflare resizes on request' - : undefined - }, - // Image dimensions follow from the same change. - ({ field, strapi, payload }) => - /width|height/i.test(field) && typeof strapi === 'number' && typeof payload === 'number' && payload >= strapi - ? 'Original image is larger than the derivative Strapi served' - : undefined, - // Duplicate service spellings folded on import. - ({ field, strapi, payload }) => { - if (!/types|typesDisplay/i.test(field)) return undefined - const fold = (value: unknown) => - String(value ?? '') - .replace(/UI\/UX|UX-UI/g, 'UI-UX') - .replace(/\bDEV\b/g, 'DEVELOPMENT') - return fold(strapi) === fold(payload) ? 'Duplicate type spellings normalised on import' : undefined - }, - // The U+2011 slug that never matched the website's filter. - ({ field, strapi, payload }) => - field === 'industry' && String(strapi).replace(/[‐-―]/g, '') === String(payload) - ? 'Non-breaking hyphen removed so the E-Commerce filter matches' - : undefined, -] - -function explain(diff: Omit): string | undefined { - for (const rule of EXPECTED) { - const reason = rule(diff) - if (reason) return reason - } - return undefined -} - -/** Fields that cannot match by construction: database identities and the - * timestamps a fresh import necessarily rewrites. */ -const IGNORED = new Set(['id', 'createdAt', 'updatedAt']) - -function compare(entity: string, key: string, strapi: Record, payload: Record, diffs: Diff[]) { - const fields = new Set([...Object.keys(strapi), ...Object.keys(payload)]) - for (const field of fields) { - if (IGNORED.has(field)) continue - const a = strapi[field] - const b = payload[field] - if (JSON.stringify(a) === JSON.stringify(b)) continue - - // Recurse one level so a nested image reports `image.src`, not `image`. - if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) { - compare(entity, key, a as Record, b as Record, diffs) - continue - } - const diff = { entity, key, field, strapi: a, payload: b } - diffs.push({ ...diff, explained: explain(diff) }) - } -} - -async function collections(provider: CMSProvider) { - const [blogs, projects, gallery] = await Promise.all([ - provider.listBlogs({ limit: 1000 }), - provider.listProjects({ limit: 500 }), - provider.listGalleryItems({ limit: 1000 }), - ]) - return { - blogs: blogs.map((blog) => normalise(blog)), - projects: projects.map(normaliseProject), - gallery: gallery.map(normaliseGallery), - } -} - -async function main() { - const strapi = new StrapiProvider() - const payload = new PayloadProvider({ draft: false }) - - console.log('Reading Strapi…') - const fromStrapi = await collections(strapi) - console.log('Reading Payload…') - const fromPayload = await collections(payload) - - // fetchStrapi answers with an empty list when STRAPI_URL is unset, which would - // otherwise render as "every document is missing from Strapi" — a scary report - // about nothing. - const strapiTotal = fromStrapi.blogs.length + fromStrapi.projects.length + fromStrapi.gallery.length - if (strapiTotal === 0) { - console.error('Strapi returned no documents at all. Set STRAPI_URL and STRAPI_API_TOKEN — without them this comparison is meaningless.') - process.exit(2) - } - - const diffs: Diff[] = [] - const summary: Array> = [] - - for (const entity of ['blogs', 'projects', 'gallery'] as const) { - const left = fromStrapi[entity] as Array> - const right = fromPayload[entity] as Array> - const leftSlugs = left.map((item) => String(item.slug)) - const rightSlugs = right.map((item) => String(item.slug)) - - const missing = leftSlugs.filter((slug) => !rightSlugs.includes(slug)) - const extra = rightSlugs.filter((slug) => !leftSlugs.includes(slug)) - // Order is part of the rendered output: /projects and /gallery have no - // client-side sort, so the sequence the provider returns is what visitors see. - const orderMatches = JSON.stringify(leftSlugs) === JSON.stringify(rightSlugs) - - summary.push({ - entity, - strapi: left.length, - payload: right.length, - missingInPayload: missing.length, - onlyInPayload: extra.length, - orderMatches, - }) - - for (const slug of missing) diffs.push({ entity, key: slug, field: '(document)', strapi: 'present', payload: 'ABSENT' }) - for (const slug of extra) diffs.push({ entity, key: slug, field: '(document)', strapi: 'ABSENT', payload: 'present' }) - if (!orderMatches) { - diffs.push({ - entity, - key: '(ordering)', - field: 'sequence', - strapi: leftSlugs.slice(0, 8), - payload: rightSlugs.slice(0, 8), - }) - } - - const bySlug = new Map(right.map((item) => [String(item.slug), item])) - for (const item of left) { - const counterpart = bySlug.get(String(item.slug)) - if (counterpart) compare(entity, String(item.slug), item, counterpart, diffs) - } - } - - const unexplained = diffs.filter((diff) => !diff.explained) - const explained = diffs.filter((diff) => diff.explained) - - console.log('\nCounts and ordering') - console.table(summary) - - if (explained.length > 0) { - const reasons = explained.reduce>((counts, diff) => { - counts[diff.explained!] = (counts[diff.explained!] ?? 0) + 1 - return counts - }, {}) - console.log('\nIntended differences') - console.table(reasons) - } - - await mkdir('artifacts', { recursive: true }) - await writeFile(REPORT, JSON.stringify({ summary, unexplained, explained }, null, 2)) - - if (unexplained.length > 0) { - console.error(`\n${unexplained.length} unexplained difference(s) — full detail in ${REPORT}`) - for (const diff of unexplained.slice(0, 30)) { - console.error(` ${diff.entity}/${diff.key} ${diff.field}: strapi=${JSON.stringify(diff.strapi)?.slice(0, 90)} payload=${JSON.stringify(diff.payload)?.slice(0, 90)}`) - } - process.exitCode = 1 - } else { - console.log(`\nNo unexplained differences. Report written to ${REPORT}`) - } -} - -void main() diff --git a/wrangler.jsonc b/wrangler.jsonc index 518ea46..c73620f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -22,17 +22,16 @@ "observability": { "enabled": true, }, - // CMS selection. These are plaintext, not secrets: the tokens they pair with - // arrive through `wrangler secret bulk` in CI. Named environments do NOT + // CMS configuration. These are plaintext, not secrets: the tokens they pair + // with arrive through `wrangler secret bulk` in CI. Named environments do NOT // inherit `vars`, so each env below repeats the full set. // // Draft reads need CMS_MODE=draft AND DEPLOYMENT_ROLE=preview together, so a // single mistyped variable cannot expose drafts on the public domain. "vars": { - "CMS_PROVIDER": "strapi", "CMS_MODE": "published", "DEPLOYMENT_ROLE": "development", - "PAYLOAD_URL": "https://cms.epyc.in", + "PAYLOAD_URL": "https://epyc-payload-cms.epyc.workers.dev", }, // Producer side of the contact-form webhook job. The consumer is a separate @@ -63,7 +62,6 @@ "staging": { "name": "epyc-website-staging", "vars": { - "CMS_PROVIDER": "payload", "CMS_MODE": "draft", "DEPLOYMENT_ROLE": "preview", "PAYLOAD_URL": "https://epyc-payload-cms.epyc.workers.dev", @@ -87,7 +85,6 @@ "production": { "name": "epyc-website-production", "vars": { - "CMS_PROVIDER": "payload", "CMS_MODE": "published", "DEPLOYMENT_ROLE": "production", "PAYLOAD_URL": "https://epyc-payload-cms.epyc.workers.dev",