# Ghost integration guidelines

Use this guide when adding a Ghost-backed page, section, article listing, or download to `pfr-main-website`. It defines the engineering contract. Content editors should use the separate [Ghost editor guide](./ghost-editor-guide.md).

## Choose the integration type first

| Integration type          | Use it for                                                  | Required behaviour                                                                                                                                         |
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Editorial content         | Insights, Resources, participant articles, case studies     | Fetch by editorial tag or slug. Missing detail content may return `notFound()`; placement-only Posts must never appear in listings.                        |
| Content placement         | Ghost-managed copy inside a website-owned page layout       | Fetch all page placements together, strictly validate each Post, and fall back section-by-section to built-in copy.                                        |
| Legal or critical content | Privacy policies and other pages that must remain available | Use a reviewed local fallback, sanitize rich HTML, and report why the fallback was activated.                                                              |
| Downloadable file         | PDFs attached to Ghost Posts                                | Accept only trusted Ghost file URLs and serve through the existing download route, which validates the response and rebuilds PDFs without source metadata. |

Do not combine these patterns. In particular, an editorial Post is not a content-placement Post, and a general placement fallback is not sufficient for legal content.

## Non-negotiable rules

1. Ghost owns approved content, not application structure. The website continues to own layout, components, routes, links, buttons, icons, images, accessibility, and responsive behaviour.
2. Use Ghost **Posts**, not Ghost Pages.
3. Keep all Ghost HTTP behaviour in [`services/blogActions.ts`](../services/blogActions.ts). Page modules, components, and loaders must not build Content API URLs or call Ghost directly.
4. Ghost reads are server-side. Load content in a server page or a `lib/*-ghost-content.ts` loader and pass typed data into presentational components.
5. Make one batched tag request per placement page. Do not issue one Ghost request per section.
6. Treat all Ghost data as untrusted. Validate structure, counts, lengths, tags, URLs, and HTML before rendering it.
7. A malformed placement is rejected as a whole. Never merge part of an invalid Post with fallback fields.
8. Every placement has built-in copy. A Ghost outage or editor mistake must not take down the surrounding page.
9. Never log or report the Ghost Content API key, query string, full response, or Post HTML.
10. Every integration ships with focused regression tests and editor documentation.

## Network and cache contract

All Content API calls go through the shared client in [`services/blogActions.ts`](../services/blogActions.ts). It provides the standard contract:

- `Accept-Version: v6.0`
- a five-second timeout
- `next.revalidate: 60` by default
- safe URL construction and response error handling
- Sentry reporting without the Content API key or query string
- defensive exclusion of website-placement Posts from editorial results

Use `fetchPostsByTag`, `fetchPostBySlug`, or the existing `fetchAll*Posts` helpers. Extend the shared client if a new query shape is genuinely needed; do not introduce a second Ghost client.

Public Ghost-backed routes should expose `export const revalidate = 60` so the route policy is visible. Placement loaders should also pass a named options constant:

```ts
const FETCH_OPTIONS = { revalidate: 60, timeoutMs: 5000 };
```

Only override this policy when a documented product requirement needs different freshness. Test the resulting `fetch` options. Do not use `cache: "no-store"` by default.

## Tags and selection

Add new placement tags to [`constants/blog-data.ts`](../constants/blog-data.ts):

1. Add a page-specific `*_PLACEMENT_TAGS` tuple.
2. Include it in `WEBSITE_PLACEMENT_TAGS`.
3. Document the exact tag and Post schema in the [Ghost editor guide](./ghost-editor-guide.md).

A singleton content placement must have exactly one website-placement tag. It must not have an Insights, Resources, or other editorial category tag. Sort candidates newest-first, then use `newestSingleton` from [`lib/ghost-content-primitives.ts`](../lib/ghost-content-primitives.ts).

Repeatable placements, such as testimonials, must be explicitly designed as repeatable. Filter out multi-placement Posts, validate each remaining Post independently, sort newest-first, and apply a documented maximum.

Editorial Posts keep their normal editorial category tag. A service feature/resource tag is additive where the editor guide says so.

## Defaults and content ownership

Define fallback content once, outside the React component:

- use a page loader module for small page-specific defaults;
- use a file under `constants/` when the same defaults feed both a loader and UI configuration;
- keep presentation-only data such as icons, logos, images, routes, and button labels in the component.

Derive the returned content type from the shared default when practical. Do not duplicate the same paragraph, list, or testimonial in both a loader and a component.

Use consistent names:

- `lib/<page>-ghost-content.ts` and a colocated `<page>-ghost-content.test.ts`;
- `DEFAULT_<PAGE>_CONTENT` for the complete fallback;
- `load<Page>Content` for the page loader;
- `parse<Section>` for an exported placement parser;
- `FETCH_OPTIONS` for the loader's shared request policy.

## Parsing placement Posts

Use the canonical helpers in [`lib/ghost-content-primitives.ts`](../lib/ghost-content-primitives.ts):

- `boundedText` for trimmed, non-empty, length-limited strings;
- `exactRoot` for an exact sequence of top-level HTML elements;
- `plainText` when an element may contain text only;
- `sortPublishedNewest` before placement selection;
- `newestSingleton` for a single-placement candidate.

Keep only genuinely page-specific parsing local, such as a fixed-length list or a required `<strong>` segment. Do not create aliases or pass-through wrappers for the shared helpers.

A standard parser is small and rejects unexpected markup:

```ts
export function parseExampleHero(post: Post) {
  const title = boundedText(post.title, 140);
  const parsed = exactRoot(post.html, ["p"]);
  const lead = parsed && plainText(parsed.$(parsed.elements[0]), 1000);

  return title && lead ? { title, lead } : null;
}
```

Validation rules must be explicit and documented:

- exact root element order and count;
- exact list item count where the design has a fixed number of slots;
- plain text unless rich text is intentionally supported;
- maximum lengths for every editor-controlled field;
- required images/files and trusted URL rules;
- whether a placement is singleton or repeatable.

## Standard placement loader

Keep fetching, ordering, selection, parsing, and fallback logic in one loader. Inject the fetcher so the loader can be tested without Ghost:

```ts
const FETCH_OPTIONS = { revalidate: 60, timeoutMs: 5000 };

type PlacementFetcher = (
  tags: string[],
  limit: number | "all",
  options: typeof FETCH_OPTIONS,
) => Promise<PostData>;

export async function loadExampleContent(
  fetcher: PlacementFetcher = fetchPostsByTag,
): Promise<ExampleContent> {
  let posts: Post[] = [];

  try {
    ({ posts } = await fetcher(
      EXAMPLE_PLACEMENT_TAGS.slice(),
      "all",
      FETCH_OPTIONS,
    ));
  } catch {
    // Each section uses its built-in fallback below.
  }

  const orderedPosts = sortPublishedNewest(posts);
  const heroPost = newestSingleton(orderedPosts, "#example-hero");

  return {
    hero: heroPost
      ? parseExampleHero(heroPost) || DEFAULT_EXAMPLE_CONTENT.hero
      : DEFAULT_EXAMPLE_CONTENT.hero,
  };
}
```

The page should call the loader once and pass the result down. Presentational components must not know about Ghost tags, Post shapes, fetch failures, or parsing.

## Rich HTML, metadata, and files

Plain-text placements should never use `dangerouslySetInnerHTML`.

When rich Ghost HTML is a deliberate requirement:

- sanitize it with a narrow allowlist before parsing or rendering;
- forbid scripts, forms, embeds, event handlers, inline styles, and unsafe URI schemes unless a reviewed requirement explicitly allows them;
- maintain a complete reviewed local fallback for legal content;
- report distinct safe reasons for fetch failure, missing content, and contract/tag failure.

For JSON-LD, always serialize data with [`jsonForScript`](../utils/json-for-script.ts). Never place `JSON.stringify(untrustedData)` directly inside a `<script>` element.

For Ghost PDFs, use the existing `/api/download-article` flow and URL validators. Do not proxy arbitrary URLs or return the original PDF bytes. The route enforces trusted hosts, redirects, MIME/size bounds, cancellation, and clean-document rebuilding to remove standard, custom, and XMP metadata.

If a new Ghost image host is introduced, update and review the `next/image` `remotePatterns` in `next.config.js`; do not broadly allow remote images.

## Required tests

Every new placement loader needs a focused `*.test.ts` covering:

- valid content;
- missing content and fetch failure;
- malformed root structure or nested markup;
- empty and overlong fields;
- incorrect item counts;
- newest-published selection;
- rejection of a Post with multiple website-placement tags;
- section-level fallback without affecting valid siblings;
- the exact fetch tags, limit, timeout, and revalidation options.

Add security-specific cases when relevant:

- script-breakout characters in JSON-LD;
- unsafe HTML tags, attributes, and URI schemes;
- untrusted file hosts, redirects, MIME, size, and embedded PDF metadata;
- missing or invalid legal content and its fallback telemetry reason.

Follow the existing `ts-node` test/config pattern. Add the focused script to `test:ghost` in `package.json`; `prebuild` runs this aggregate before every production build.

Run at least:

```bash
npm run test:ghost
npm run lint
npm run build
```

Also run a targeted Prettier check on every changed/new text file and manually open the affected route. Confirm meaningful content, no framework error overlay, and the expected fallback by temporarily using a mocked failing fetch in tests—never by mutating production Ghost.

## Anti-patterns

Do not merge code that:

- calls the Ghost Content API directly from a page or component;
- duplicates URL, timeout, cache, or telemetry logic;
- fetches each placement separately;
- copies fallback content into multiple files;
- adds a one-off parser for behaviour already in `ghost-content-primitives`;
- accepts extra root elements, nested formatting, or unlimited text “because Ghost is trusted”;
- silently renders partial malformed content;
- puts a placement-only Post into an editorial listing;
- embeds unescaped JSON with `JSON.stringify` in a script element;
- renders unsanitized Ghost HTML;
- serves original PDF bytes or accepts arbitrary file hosts;
- makes a legal page unavailable when Ghost is down;
- activates a legal fallback without an operational signal;
- changes layout, icons, links, or behaviour through copy-only Ghost fields.

## Definition of done

- [ ] Integration type and content ownership are explicit.
- [ ] Tags are declared centrally and documented for editors.
- [ ] Ghost access uses the shared client with bounded, cached reads.
- [ ] Placement pages fetch once and fail section-by-section to one source of fallback copy.
- [ ] Parsers use shared primitives and reject unexpected structure.
- [ ] Untrusted HTML, JSON-LD, URLs, images, and files use the approved security path.
- [ ] Focused tests cover success, failure, malformed content, selection, caching, and relevant security cases.
- [ ] The new test runs through `test:ghost` and therefore `prebuild`.
- [ ] Lint, TypeScript/build, touched-file formatting, and manual route verification pass.
- [ ] The Ghost editor guide describes the exact Post type, tag, format, limits, and publishing behaviour.
- [ ] No secrets, Ghost mutations, deployments, or unrelated refactors are included.
