How EPP becomes a static site

EPP is a server-rendered Next.js app that also acts as an API adapter and download proxy — responsibilities that belong to the backend. Two site flavours (eLife, Biophysics Colab) are managed by abusing i18n namespaces as a theming system, adding friction to every component and test.

The goal: pre-built HTML served from Fastly Object Storage, no Node.js runtime, no container. Article data is fetched once at build time; only changed articles are rebuilt. Infrastructure reduces to a Fastly Object Storage bucket and a Fastly service.

Five phases, each independently deployable:

Phase 1 — Test hardening

Complete before any migration work. Steps 1e and 1f are hard prerequisites for Phase 4 — they decouple the component library from Next.js and i18n so Phase 4 only touches page files, not components.

1a — Local visual regression via Playwright in Docker

Chromatic runs cloud-only; there is no local visual regression. @storybook/test-runner is already installed — it drives a built Storybook with Playwright and toHaveScreenshot(). Running inside a pinned Docker image pins the browser and font stack, making screenshots reproducible across macOS, Linux, and CI. @playwright/test is already at 1.57.0; use the matching image.

Add .storybook/test-runner.ts to take screenshots per story, reusing the Chromatic viewport modes:

// .storybook/test-runner.ts
import { getStoryContext } from '@storybook/test-runner';
import { expect } from '@playwright/test';

export const postVisit = async (page, context) => {
  const storyContext = await getStoryContext(page, context);
  const modes = storyContext.parameters?.chromatic?.modes;

  if (modes) {
    for (const [name, cfg] of Object.entries(modes)) {
      if (cfg.viewport) await page.setViewportSize(cfg.viewport);
      await page.waitForLoadState('networkidle');
      await expect(page).toHaveScreenshot(
        `${context.id}-${name}.png`,
        { maxDiffPixelRatio: 0.02 }
      );
    }
  } else {
    await expect(page).toHaveScreenshot(
      `${context.id}.png`,
      { maxDiffPixelRatio: 0.02 }
    );
  }
};

Add two scripts to package.json (build Storybook, serve it, run the test runner in Docker). On macOS Docker Desktop replace localhost with host.docker.internal.

"test-storybook:visual": "yarn build-storybook && \
  npx serve storybook-static -p 6006 -n & \
  docker run --rm --network host \
    -v \"$(pwd):/work\" -w /work \
    mcr.microsoft.com/playwright:v1.57.0-noble \
    yarn test-storybook --url http://localhost:6006",

"test-storybook:visual:update": "yarn build-storybook && \
  npx serve storybook-static -p 6006 -n & \
  docker run --rm --network host \
    -v \"$(pwd):/work\" -w /work \
    mcr.microsoft.com/playwright:v1.57.0-noble \
    yarn test-storybook --url http://localhost:6006 --update-snapshots"

Run test-storybook:visual:update once to generate baselines and commit them. Chromatic continues on PRs for reviewer-facing diffs; the Docker run is the local and CI gate.

1b — Fix Playwright local setup

playwright.config.ts has both baseURL and webServer commented out. The three existing browser test specs hardcode http://localhost:3001 and require a manually started server. Playwright supports an array of webServer entries; configure it to start both Wiremock and Next.js before the suite runs:

// playwright.config.ts
webServer: [
  {
    command: 'yarn wiremock',
    url: 'http://localhost:8080/__admin/health',
    reuseExistingServer: !process.env.CI,
  },
  {
    command: 'API_SERVER=http://localhost:8080 yarn start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
],
use: {
  baseURL: 'http://localhost:3000',
},

Replace all hardcoded http://localhost:3001 URLs in existing specs with relative paths. Tests then run identically against local dev, CI, and staging.

1c — Add E2E tests for critical flows

Article tabs

Tests asserting correct content per URL survive both the current router-based tabs and the Phase 3 static HTML files:

test('fulltext tab is active by default', async ({ page }) => {
  await page.goto('/reviewed-preprints/85111');
  await expect(page.locator('[aria-current="page"]')).toHaveText('Full text');
});

test('figures tab renders figures', async ({ page }) => {
  await page.goto('/reviewed-preprints/85111/figures');
  await expect(page.locator('[aria-current="page"]')).toHaveText('Figures');
});

URL redirects

The same assertions verify the Caddy configuration in step 3c:

test('articles path redirects to reviewed-preprints', async ({ request }) => {
  const response = await request.get('/articles/85111', { maxRedirects: 0 });
  expect(response.status()).toBe(301);
  expect(response.headers()['location']).toContain('/reviewed-preprints/85111');
});

VOR redirect

Add a VOR Wiremock fixture, then assert that a VOR article at /reviewed-preprints/ redirects to /articles/. The test documents the requirement so whichever step 3a approach is chosen can be verified against it.

Both EPP flavours

The Wiremock fixture biophysics-colab-111111 exists but no browser test uses it. Add an E2E test asserting flavour-specific strings — "Curators" not "Editors", "Curated Preprint" not "Reviewed Preprint" — before step 1f refactors i18n.

1d — Unit test getServerSideProps logic

getServerSideProps in [...path].page.tsx (lines 218–317) has four branches: VOR redirect, preprint redirect, unknown msid → 404, and PDF variant pre-fetching imgInfo. No unit tests exist. Mock fetchVersion via fetch-mock (already installed); assert the returned shape ({ redirect }, { notFound: true }, { props }) for each branch.

1e — Decouple components from next/*

@storybook/nextjs shims next/image, next/router, and next/navigation. Phase 4 requires @storybook/react, which has no shims. Removing the Next.js dependencies now means story files don't change during the Astro migration.

Replace next/image in components

Seven components use next/image for logos and icons: site-header, site-header-biophysics-colab, biophysics-colab-site-footer, footer-main, investors, error-messages, page-not-found. Replace each with a plain <img>. Don't introduce a wrapper — the Phase 4 replacement is astro:assets <Image>, a different API.

Replace useRouter() with a prop

[...path].page.tsx reads the active tab from useRouter(). Derive it in getServerSideProps from the request path and pass it down as an activeTab prop instead.

Swap @storybook/nextjs@storybook/react

Once no component imports from next/*, update .storybook/main.js and confirm baselines are unchanged. Add an explicit i18n decorator to preview.tsx (removed again in step 1f).

1f — Replace i18n namespace abuse with typed SiteConfig

i18n has three namespaces (default, elife, biophysics_colab) holding site config — publisher name, editor labels, timeline titles, URLs — selected via NEXT_PUBLIC_SITE_NAME. There is one language. Problems: nested $t() references are invisible to TypeScript; all 17 components calling useTranslation() require I18nextProvider to render, making isolation testing awkward and Astro island integration unnecessarily complex.

Replace with a typed SiteConfig object and a single React context:

// src/site-config.ts
export type SiteConfig = {
  publisherShort: string;
  publisherLong: string;
  processUrl: string;
  aboutAssessmentsUrl: string;
  aboutAssessmentsDescription: string;
  editorsAndReviewersTitle: string;
  timelineVersionTitle: string;
  timelineVersionTitleWithEvaluation: string;
  reviewProcessReviewed: string;
  reviewProcessRevised: string;
  canonicalUrl: (msid: string) => string;
  // ... one field per flavour-varying value
};

export const elifeConfig: SiteConfig = {
  publisherShort: 'eLife',
  editorsAndReviewersTitle: 'Editors',
  canonicalUrl: (msid) => `https://elifesciences.org/reviewed-preprints/${msid}`,
  // ...
};

export const biophysicsColabConfig: SiteConfig = {
  publisherShort: 'Biophysics Colab',
  editorsAndReviewersTitle: 'Curators',
  canonicalUrl: (msid) => `/reviewed-preprints/${msid}`,
  // ...
};

useSiteConfig() replaces every useTranslation() call. Set the context once in _app.page.tsx from config.siteName. Nested $t() references become plain string composition; interpolated values (e.g. URL templates) become functions, as shown with canonicalUrl above. TypeScript surfaces any missed call sites at compile time.

Update Storybook stories to pass both configs explicitly — this gives Chromatic and the Docker visual tests flavour coverage on every PR without needing the i18n provider or an environment variable.

Phase 2 — Extract /api, eliminate proxies

Remove all server-side concerns from this repo. After this phase the client has zero API routes. The Playwright specs covering the API endpoints migrate to the server repo along with the code they test, where they become integration tests for the server.

2a — Move reviewed-preprints API to enhanced-preprint-server

/api/reviewed-preprints and /api/reviewed-preprints/[msid] are a format adapter: they take the EPP internal schema and emit application/vnd.elife.reviewed-preprint+json; version=1. The server already owns the data; it should own the format too.

Move the ~400-line transformation in reviewed-preprints.page.ts to enhanced-preprint-server. Logic to port:

  • Author-line generation and date formatting
  • eLife Assessment extraction from evaluation HTML (findTerms regex for significance/strength terms)
  • x-total-count header, pagination, and date-range query parameters
  • Full-text content serialisation for indexContent

Move api-reviewed-preprints.spec.ts with the code. Co-ordinate with the external consumer team before retiring the client-side route.

2b — Move citation proxy to server

/api/citations/[msid]/bibtex and /ris exist because the browser knows the msid but the upstream citation endpoint requires a DOI. The proxy resolves this by looking up the article, extracting the DOI, then fetching the citation and returning it with a Content-Disposition: attachment header. The server already resolves msid → DOI; move the proxy there and update article pages to link directly to the server citation endpoints.

2c — Replace download proxies with direct links

/api/downloads/[msid]/pdf and /xml proxy files from upstream to attach a Content-Disposition: attachment; filename=… header.

  • PDF: article.pdfUrl is present in article data at render time. Render <a href="{pdfUrl}" download="{msid}-v{version}.pdf">. The download attribute forces a save dialog with the chosen filename.
  • XML: generateArticleXmlUri(msid, versionIdentifier) deterministically constructs the URL. Same approach.

The download attribute is restricted by browser security to same-origin requests. If files are served from a different origin, the fix is a Content-Disposition: attachment header on the CDN distribution — a configuration change, not a proxy.

2d — Collapse operational endpoints

robots.txt becomes a static file in public/robots.txt. Maintain separate variants per environment and select the correct one at deploy time. Delete robots.page.ts.

/ping is a health check. A static public/ping file returning 200 satisfies any check that verifies only HTTP status. Delete ping.page.ts.

/status checks whether the upstream API is reachable — a concern that belongs to the server, which owns that dependency. Move it there and delete status.page.ts.

Phase 3 — Static export, served by Caddy

Eliminate the Node.js runtime entirely. After Phase 2 the client has no API routes, making output: 'export' possible. The Phase 1 E2E tests verify the Caddy configuration — run the same Playwright suite against the Caddy-served static output and it should pass unchanged.

3a — Convert SSR pages to static generation

Both getServerSideProps functions switch to static generation. index.page.tsx is a straightforward conversion — call fetchVersions() in getStaticProps instead of at request time.

[...path].page.tsx requires getStaticPaths to enumerate all articles at build time. Generate only the base /msid path; Caddy's try_files serves the same file for sub-paths like /msid/figures, and the active tab is determined client-side from the URL (as a prop after step 1e):

export const getStaticPaths: GetStaticPaths = async () => {
  const { items } = await fetchVersions();
  return {
    paths: items.map(({ msid }) => ({ params: { path: [msid] } })),
    fallback: false,
  };
};

The VOR redirect. The redirect logic at lines 270–291 of [...path].page.tsx cannot live in a static HTML file. It must be resolved before this step ships. Three options in order of preference:

  1. Unify the URL spaces. Serve everything under /reviewed-preprints/, add <link rel="canonical"> for SEO, and retire the /articles/ path for preprints. No server round-trip required.
  2. Have Caddy proxy the decision to a lightweight endpoint on enhanced-preprint-server that returns the canonical URL for a given msid. Caddy performs the redirect.
  3. Defer to Phase 4, where Astro middleware can resolve it at build time by writing redirect HTML stubs during the static build.

3b — Static export config and image handling

// next.config.js
module.exports = {
  output: 'export',
  images: { unoptimized: true },
  // existing config...
};

images: { unoptimized: true } disables the /_next/image server-side resize endpoint. It has no visible effect here: the seven components using next/image are all SVG/PNG logos; article figures use IIIF URL parameters for sizing independently of Next.js. Phase 4 replaces logos with astro:assets <Image> for build-time WebP generation.

3c — URL rewrites to Caddy

The rewrite rules in next.config.js move to the Caddyfile. The Phase 1 redirect tests run against the Caddy-served output and verify correctness:

epp.elifesciences.org {
  root * /srv/out

  @articles path_regexp art ^/articles/(.+)$
  redir @articles /reviewed-preprints/{re.art.1} 301

  @previews path_regexp prev ^/previews/(.+)$
  redir @previews /reviewed-preprints/{re.prev.1} 301

  @numeric path_regexp num ^/(\d+)(\.bib|\.ris|\.pdf|\.xml)?$
  redir @numeric /reviewed-preprints/{re.num.1}{re.num.2} 301

  file_server
  try_files {path} {path}.html {path}/index.html =404
}

3d — Rebuild webhook

With fallback: false, new articles return 404 until a rebuild completes. Configure enhanced-preprint-server to emit a webhook on article publication that triggers the CI/CD pipeline. The pipeline runs next build, then updates Caddy's document root atomically via a symlink swap or rolling deploy.

Add a scheduled rebuild (for example, hourly) as a fallback in case a webhook delivery fails silently. The scheduled rebuild bounds the worst-case delay for a new article becoming visible.

Phase 4 — Migrate to Astro

Introduces incremental builds: only re-render pages for changed or new articles, not the full corpus. Because Phase 1 decoupled the component library from Next.js-specific imports and replaced i18n with a typed config, this phase focuses on Astro page files and content collections. The 147 React components and their stories do not change.

4a — Project setup and content collections

Astro's content layer (Astro 5) is what enables incremental builds. It replaces getStaticProps and getStaticPaths with a centralised data-fetching layer that caches results between builds in .astro/data-store.json. Each entry carries an id and a digest. On every build Astro compares digests against the cache; only entries whose digest changed get their pages re-rendered. One new article = one changed digest = only that article's pages rebuilt.

// src/content/config.ts
import { defineCollection } from 'astro:content';

const articles = defineCollection({
  loader: async () => {
    const { items } = await fetch(
      `${import.meta.env.API_SERVER}/api/preprints`
    ).then(r => r.json());
    return items.map((item) => ({
      id: item.msid,
      digest: `${item.msid}-v${item.versionIdentifier}-${item.published}`,
      ...item,
    }));
  },
});

export const collections = { articles };

For the incremental cache to work in CI, .astro/data-store.json must be persisted between runs. Without this, every CI build is a full rebuild:

# GitHub Actions
- uses: actions/cache@v4
  with:
    path: .astro
    key: astro-${{ github.ref_name }}-${{ github.run_id }}
    restore-keys: astro-${{ github.ref_name }}-

4b — Page files and BaseLayout

Astro pages live in src/pages/ as .astro files. The existing React components are used directly — no component rewrites required.

---
// src/pages/reviewed-preprints/[msid].astro
import { getCollection, getEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { ArticlePage } from '../../components/pages/article/article-page';
import { elifeConfig, biophysicsColabConfig } from '../../site-config';

export async function getStaticPaths() {
  const articles = await getCollection('articles');
  return articles.map((entry) => ({ params: { msid: entry.id } }));
}

const { msid } = Astro.params;
const entry = await getEntry('articles', msid);
const siteName = import.meta.env.PUBLIC_SITE_NAME ?? 'elife';
const siteConfig = siteName === 'biophysics-colab'
  ? biophysicsColabConfig : elifeConfig;
---
<BaseLayout title={entry.data.title} siteName={siteName}>
  <ArticlePage article={entry.data} activeTab="fulltext"
    siteConfig={siteConfig} />
</BaseLayout>

_app.page.tsx and _document.page.tsx become src/layouts/BaseLayout.astro, handling GTM, Cookiebot, font variables, and the layout switch. Replace next/font/google with @fontsource/noto-serif / @fontsource/noto-sans. NEXT_PUBLIC_*PUBLIC_* via import.meta.env. No I18nextProvider needed — SiteConfig is a plain prop.

4c — Split catch-all into per-tab pages

In Astro, each tab is a separate .astro file. Tab navigation becomes plain <a href> links between static HTML files — no JavaScript required, works without JS, indexed independently by crawlers:

src/pages/reviewed-preprints/
  [msid].astro          → fulltext (canonical)
  [msid]/figures.astro  → figures tab
  [msid]/reviews.astro  → peer review tab

Both /reviewed-preprints/msid and /msid/fulltext currently show fulltext. Make [msid].astro canonical; add a one-line Caddy 301 from /msid/fulltext/msid. The PDF tab can become a static page or be retired in favour of the direct PDF link from step 2c.

4d — Audit client:* directives

Astro renders React components to static HTML at build time and ships zero JavaScript for them by default. A component only hydrates on the client when opted into with a client:* directive. Missing a directive on an interactive component produces no error — the component renders correctly but ignores user events.

Directive Hydrates when Use for
client:load Page loads immediately Components needed on first interaction
client:idle Browser reaches idle Lower-priority interactive components
client:visible Scrolled into viewport Components below the fold
client:only="react" Never SSR’d Components that cannot render server-side

Most of the 147 components are presentational and need no directive. Interactive components identified by Phase 1 E2E tests: authors toggle (client:visible), clipboard copy (client:visible), jump-to menu (client:idle), modal (client:load).

4e — Build-time image optimisation

Replace the plain logo <img> tags (from step 1e) with astro:assets <Image> for build-time WebP generation:

---
import { Image } from 'astro:assets';
import elifeLogo from '../../images/elife-logo.svg';
---
<Image src={elifeLogo} width={120} height={40} alt="eLife" />

4f — Verify visual regression baselines

Run the Docker visual regression suite against the Astro build. Run Phase 1 E2E tests against the Caddy-served Astro output. Both should pass unchanged.

Phase 5 — Eliminate the container

Upload out/ to Fastly Object Storage; Fastly CDN serves directly from its own network. Delete the k8s Deployment, Service, and IngressRoute. Retire the Caddy Dockerfile. No cross-cloud dependency.

5a — Provision Fastly Object Storage bucket and configure the Fastly service

Create a Fastly Object Storage bucket via the Fastly API or CLI. No "static website hosting" mode — the CDN handles URL routing via VCL. Auth is a Fastly API token; no IAM or cross-cloud credentials needed.

Point the Fastly service backend at the bucket. Add VCL for:

  • Directory-style URLs — append index.html when path has no file extension
  • 404 handling — serve 404/index.html with status 404 on missing objects
  • Surrogate keys — tag HTML responses by msid for per-article cache purge
# Example Fastly VCL fragment — directory index rewriting
sub vcl_recv {
  if (req.url !~ "\.[a-zA-Z0-9]+$") {
    set req.url = req.url + "index.html";
  }
}

Test before DNS moves: point the Fastly service at Object Storage while Caddy stays live in k8s. Use a Fastly staging service or Fastly-Debug: 1 to verify article pages, homepage, and 404 handling.

5b — Update CI/CD: upload to Object Storage and purge Fastly cache

Replace Docker build + kubectl apply with an Object Storage upload and Fastly purge. Fastly Object Storage is S3-compatible, so aws s3 sync --endpoint-url works. Two passes for correct cache headers:

# Hashed assets — cache forever
aws s3 sync out/ s3://epp-static/ \
  --endpoint-url "$FASTLY_STORAGE_ENDPOINT" \
  --exclude "*.html" \
  --cache-control "public, max-age=31536000, immutable" \
  --delete

# HTML — always revalidate
aws s3 sync out/ s3://epp-static/ \
  --endpoint-url "$FASTLY_STORAGE_ENDPOINT" \
  --include "*.html" \
  --cache-control "no-cache" \
  --delete

After upload, purge by surrogate key (single article) or purge the whole service (full rebuild):

fastly purge --service-id "$FASTLY_SERVICE_ID" \
             --surrogate-key "rp/$MSID vor/$MSID"

Run the hashed-asset pass first — HTML must not reference an asset hash before the asset is uploaded.

5c — VOR redirect in Fastly VCL

If the VOR redirect was not resolved in step 3a: use a Fastly Edge Dictionary mapping each msid to its type (vor or rp), populated by CI via the Fastly API. VCL issues a 301 before Object Storage is consulted:

# Populate dictionary during CI build (pseudocode)
for article in articles:
  fastly dictionary upsert \
    --dict-id "$DICT_ID" \
    --item-key "$article.msid" \
    --item-value "$article.type"  # "vor" or "rp"
# VCL recv — redirect if URL prefix does not match article type
sub vcl_recv {
  declare local var.msid STRING;
  declare local var.type STRING;
  set var.msid = regsuball(req.url, "^/(articles|reviewed-preprints)/([^/?]+).*", "\2");
  set var.type = table.lookup(article_types, var.msid, "");

  if (var.type == "vor" && req.url ~ "^/reviewed-preprints/") {
    return(synth(301, "/articles/" + var.msid));
  }
  if (var.type == "rp" && req.url ~ "^/articles/") {
    return(synth(301, "/reviewed-preprints/" + var.msid));
  }
}

5d — Remove k8s manifests and cut over DNS

Lower DNS TTL to 60s at least 24 hours before. Switch the Fastly backend from the k8s ingress to Object Storage; verify via Fastly's hostname. If DNS already points to Fastly, the backend switch is the entire cutover — DNS does not move.

After a monitoring period, delete from the k8s manifests repo:

  • Deployment — EPP client pods and Caddy container
  • Service — ClusterIP or NodePort for the Caddy container
  • IngressRoute (Traefik) — routing rule from Traefik to the service
  • Any HorizontalPodAutoscaler targeting the EPP deployment

Remove the Caddy Dockerfile and Caddyfile. URL rewrite rules are now in Fastly VCL (step 5c) or were already removed in step 3c.

Step overview and risk register

Step What changes Key risk
1a Playwright screenshot assertions on Storybook stories run inside pinned Docker image; baseline screenshots committed

Medium — Pixel-diff threshold must be calibrated against real rendering noise before the check becomes gating.

1b Playwright webServer config starts both Wiremock and Next.js automatically; hardcoded URLs replaced with baseURL

Low — No VOR Wiremock fixture exists; VOR redirect test in 1c is blocked until one is added.

1c E2E tests for article tabs, URL redirects, VOR redirect, homepage, 404, and both EPP flavours

Medium — VOR Wiremock fixture is a prerequisite. Biophysics-colab flavour assertions depend on i18n being correctly initialised until step 1f replaces it.

1d Unit tests for all four getServerSideProps branches

Low — Standard unit testing with mockable dependencies.

1e Seven components: next/image<img>. Page component: useRouter()activeTab prop. Storybook: @storybook/nextjs@storybook/react

Medium — Framework swap breaks stories that still use router context. Complete the prop refactor first.

1f i18n namespace abuse replaced with typed SiteConfig; both flavours explicitly covered in Storybook and E2E tests

Medium — 17 components to update; nested $t() references require a full audit of i18n.ts before starting. Visual regression baseline is the safety net.

2a Reviewed-preprints format adapter (~400 lines) moves to enhanced-preprint-server; API specs move with it; external consumers re-pointed

High — Complex transform logic; server may be a different language; external consumer coordination requires another team.

2b Citation proxy moves to server; article pages link directly to server citation endpoints

Low — Server already resolves msid → DOI. 30 lines of proxy logic.

2c PDF and XML download proxies removed; article pages use download attribute direct links

Medium — Browser ignores download on cross-origin URLs. CDN must add Content-Disposition: attachment or links open in-browser.

2d robots.txt becomes a static file; /ping becomes a static file; /status moves to server

Low — Confirm nothing monitors /ping or /status on the client before removing them.

3a getServerSidePropsgetStaticProps + getStaticPaths; VOR redirect decision made and implemented

Medium — Build time scales with article count; benchmark early. VOR redirect logic must be resolved before this step ships.

3b output: 'export' added; images: { unoptimized: true } set

Low — No user-visible effect for this project's image types (SVG logos, IIIF figures).

3c URL rewrites move from next.config.js to Caddyfile; Phase 1 redirect tests verify the result

Medium — Caddy rewrite syntax differs from Next.js. Dynamic VOR redirect cannot be a static rule; requires a resolution from 3a.

3d EPP server emits rebuild webhook on article publication; CI rebuilds and redeploys; scheduled fallback rebuild added

Medium — Silent webhook failure leaves new articles returning 404. Scheduled rebuild is the fallback. Endpoint must be authenticated.

4a Astro project initialised with React integration; content collections with digest caching; CI configured to persist .astro/data-store.json

High — CI cache must be explicitly persisted or every build is a full rebuild. React 19 + @astrojs/react compatibility must be verified upfront.

4b Astro page files and BaseLayout.astro written; SiteConfig passed as prop (no i18n provider); Noto fonts via @fontsource

Medium — GTM and Cookiebot script placement differs from Next.js <Head>. Verify in built output and test both site configurations.

4c Catch-all replaced by per-tab .astro files; tab navigation becomes plain <a href> links

Low — Fulltext URL duplication resolved by a one-line Caddy redirect. Phase 1 tab tests verify correctness.

4d client:* directives added to interactive components: authors toggle, clipboard, jump-to-menu, modal

High — Missing a directive silently breaks interactivity with no error. Only detectable through E2E interaction tests. Phase 1 interaction tests must be complete first.

4e Logo <img> tags replaced with astro:assets <Image> for build-time WebP generation

Low — SVGs pass through unchanged. Check for externally-referenced SVGs before enabling.

4f Docker visual regression suite re-run against Astro output; Phase 1 E2E suite run against Caddy-served Astro build

Low — Confirmation step. Main failure mode is a missed client:* directive from 4d.

5a Fastly Object Storage bucket provisioned; Fastly service backend switched to Object Storage with directory-index VCL, 404 handling, and per-article surrogate keys

Low — Newer product; confirm it is available on the account plan. Review the Fastly static-site tutorial for current bucket and backend syntax before starting.

5b CI/CD replaces Docker build and kubectl apply with aws s3 sync --endpoint-url to Fastly Object Storage (separate passes for HTML and hashed assets) and Fastly surrogate-key purge

Medium — The --delete flag opens a short window where a deleted object is not yet replaced. Upload new and changed objects first; delete stale ones last.

5c Fastly Edge Dictionary populated by CI with msid→type mappings; VCL recv issues 301 if URL prefix does not match article type — resolves the VOR redirect deferred in step 3a

Medium — Dictionary must be updated after the Object Storage upload completes, not before. A race between the dictionary update and the upload would cause a correctly-redirected URL to 404 transiently.

5d k8s Deployment, Service, and IngressRoute deleted; Caddy Dockerfile retired; DNS TTL lowered before cutover; Fastly backend switch used as the cutover mechanism

Low — If Fastly already fronts the cluster, cutover is a single backend change with instant rollback. Confirm Fastly's position in the stack before planning the sequence.