diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index b8d420b..492708b 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -108,11 +108,17 @@ had, which is a mismatch, and a mismatch costs the entire prerendered page.
### The three boundaries worth knowing about
-1. **The privacy policy has two renderers, one source.** `src/data/privacyPolicy.js`
- is the single source of truth; `src/pages/PrivacyPolicy.jsx` renders it in the
- SPA and the prerender step emits a static copy. This exists because Meta's
- crawler does not execute JavaScript, and a policy it cannot read is a policy
- that does not count. **Do not add policy text to a component.**
+1. **The privacy policy has one source and one renderer, and ships as static
+ HTML.** `src/data/privacyPolicy.js` is the source of truth;
+ `src/pages/PrivacyPolicy.jsx` renders it through the shared
+ `src/components/content/ContentBlocks.jsx`, passing in the two block types
+ only a policy has, and the prerender step writes the result to
+ `dist/privacy-policy/index.html`. There is no separate server-side renderer,
+ and there never was: the page is readable to Meta's crawler because it is
+ prerendered, like every other page, and a policy an ad platform cannot read
+ is a policy that does not count. **Do not add policy text to a component**,
+ and when the renderer changes, prove this page's built HTML is unchanged
+ byte for byte before believing the change was safe.
2. **Zoho is an overlay, never a dependency.** Every handler writes SQLite first
and then calls the forwarder without awaiting it. A Zoho outage, a bad token
diff --git a/src/components/content/ContentBlocks.jsx b/src/components/content/ContentBlocks.jsx
new file mode 100644
index 0000000..e6e0c05
--- /dev/null
+++ b/src/components/content/ContentBlocks.jsx
@@ -0,0 +1,166 @@
+import { Link } from 'react-router-dom'
+
+/**
+ * One renderer for every long-form page on this site.
+ *
+ * The privacy policy had the only block renderer here, and the two approved
+ * service pages needed the same vocabulary plus ordered steps, a figure and a
+ * link list. Two renderers would have drifted, and the privacy page is the one
+ * page a lead form depends on being readable, so it uses this one too, with its
+ * two policy-only block types passed in rather than forked.
+ *
+ * The markup is deliberately identical to what the privacy page emitted before
+ * this component existed, down to prop order, because React writes attributes in
+ * that order and the page's output had to stay byte for byte the same.
+ *
+ * An unknown block type THROWS. The old renderer sent one to the paragraph case,
+ * which rendered an empty
, so a misspelt type silently dropped a paragraph
+ * of copy and no build or check would have said a word. Failing here fails the
+ * prerender, which names the route.
+ */
+
+export const LINK_CLASS =
+ 'font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors'
+
+/**
+ * Paragraph text is either a string or a list of parts, where a part is a
+ * string or a link. That is what lets approved copy carry an internal link
+ * without the copy itself knowing any markup.
+ */
+export const Inline = ({ value }) => {
+ if (!Array.isArray(value)) return value
+ return value.map((part, index) =>
+ typeof part === 'string' ? (
+ part
+ ) : (
+
+ {part.text}
+
+ ),
+ )
+}
+
+export const Paragraph = ({ block }) => (
+
+
+
+)
+
+const Heading = ({ block }) => (
+
{block.text}
+)
+
+const BulletList = ({ block }) => (
+
+ {block.items.map((item) => (
+
+
+ {item}
+
+ ))}
+
+)
+
+// A sequence, where a step is either a line or a line plus its explanation.
+// Native list numbering, so the number is the list's and not typed into copy.
+const StepList = ({ block }) => (
+
+ {block.items.map((item) => {
+ const step = typeof item === 'string' ? { text: item } : item
+ return (
+
+)
+
+// Renders only once a file exists. A slot waiting on an image the owner has to
+// supply renders nothing at all: a placeholder that looks deliberate outlives
+// the issue that would have replaced it.
+const Figure = ({ block }) =>
+ block.src ? (
+
+
+ {block.caption ? (
+ {block.caption}
+ ) : null}
+
+ ) : null
+
+const LinkList = ({ block }) => (
+
+ {block.items.map((item) => (
+
+
+
+ {item.label}
+
+
+ ))}
+
+)
+
+export const BASE_RENDERERS = {
+ p: Paragraph,
+ h3: Heading,
+ ul: BulletList,
+ ol: StepList,
+ callout: Callout,
+ image: Figure,
+ links: LinkList,
+}
+
+export const Block = ({ block, section, index, renderers = BASE_RENDERERS }) => {
+ const Render = renderers[block?.type]
+ if (!Render) {
+ throw new Error(
+ `content: section ${JSON.stringify(section)}, block ${index}: unknown block type ` +
+ `${JSON.stringify(block?.type)}. Known types: ${Object.keys(renderers).join(', ')}.`,
+ )
+ }
+ return
+}
+
+/**
+ * A section of long-form copy: an anchor, its heading, and its blocks. The id
+ * is what a search result or an AI answer links to, so it is on the article.
+ */
+export const ContentSection = ({ section, renderers = BASE_RENDERERS, headingClassName }) => (
+
+
+ {section.blocks.map((block, index) => (
+
+ ))}
+
+)
diff --git a/src/components/content/RelatedLinks.jsx b/src/components/content/RelatedLinks.jsx
new file mode 100644
index 0000000..a410628
--- /dev/null
+++ b/src/components/content/RelatedLinks.jsx
@@ -0,0 +1,36 @@
+import { Link } from 'react-router-dom'
+import { ArrowRight } from 'lucide-react'
+import { LINK_CLASS } from './ContentBlocks'
+
+/**
+ * The links a page points at, with the anchor text the copy sheet approved.
+ *
+ * Shared by the service and industry pages, which want the same list in two
+ * places and at two heading levels. It renders NOTHING without links, so a page
+ * that has none is byte for byte what it was.
+ */
+export const LinkList = ({ links, className = '' }) => (
+
+ {links.map((link) => (
+
+
+
+ {link.label}
+
+
+ ))}
+
+)
+
+const RelatedLinks = ({ links, title = 'Related services', as: Heading = 'h2', className = '', headingClassName }) => {
+ if (!links?.length) return null
+
+ return (
+
+ {title}
+
+
+ )
+}
+
+export default RelatedLinks
diff --git a/src/pages/PrivacyPolicy.jsx b/src/pages/PrivacyPolicy.jsx
index 547da67..82822d8 100644
--- a/src/pages/PrivacyPolicy.jsx
+++ b/src/pages/PrivacyPolicy.jsx
@@ -1,24 +1,26 @@
import SEO from '@/components/SEO'
import { buildBreadcrumbLd } from '@/lib/seo'
import { ShieldCheck } from 'lucide-react'
+import { BASE_RENDERERS, ContentSection, LINK_CLASS, Paragraph } from '@/components/content/ContentBlocks'
import { EFFECTIVE_DATE, LAST_UPDATED, PRIVACY_EMAIL, sections } from '@/data/privacyPolicy'
+// This page renders through the shared content renderer, with the two block
+// types only a privacy policy has passed in beside the common ones. It is the
+// one page an ad platform must be able to read, so its markup is unchanged: the
+// renderer was written to emit exactly what this page emitted before.
+
const EmailLink = ({ className = '' }) => (
-
+
{PRIVACY_EMAIL}
)
-// Renders a paragraph, splitting the privacy address out as a mailto link when present.
-const Paragraph = ({ text, linkEmail }) => {
- if (!linkEmail || !text.includes(PRIVACY_EMAIL)) {
- return
{text}
- }
+// A paragraph, with the privacy address split out as a mailto link when the
+// block asks for it. Everything else is an ordinary paragraph.
+const PolicyParagraph = ({ block }) => {
+ if (!block.linkEmail || !block.text.includes(PRIVACY_EMAIL)) return
- const [before, after] = text.split(PRIVACY_EMAIL)
+ const [before, after] = block.text.split(PRIVACY_EMAIL)
return (