refactor(ui): one content renderer, and it refuses a block it does not know

The privacy policy held the site's only block renderer, and the two approved
service pages need the same vocabulary plus ordered steps, a figure and a link
list. Two renderers would have drifted, so there is one:
src/components/content/ContentBlocks.jsx, with p (including inline links), h3,
ul, ol (a step, optionally with its explanation), callout, image and links.

The old renderer sent an unknown block type to its paragraph case, which
rendered an empty <p>. A misspelt type silently dropped a paragraph of copy and
nothing said a word. The shared one throws, and the prerender turns that into a
build failure naming the route, the section and the block index.

PrivacyPolicy passes in the two block types only a policy has, email and
contactBlock, rather than forking the renderer. That page is the one an ad
platform must be able to read, so its markup had to be untouched: the renderer
was written to emit exactly what the page emitted before, prop order included.

Proven: dist/privacy-policy/index.html is byte for byte identical before and
after, 82,392 bytes either way, with asset hashes normalised. And a block type
misspelt as 'emial' fails the build with "prerender: /privacy-policy could not
be rendered: content: section "marketing", block 3: unknown block type".

RelatedLinks is the second shared piece, for the links the sheets ask each page
to carry. It renders nothing when a page has no links, so the pages that have
none are unchanged.

Closes #229.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-09-10 04:45:48 -05:00
parent 0d575f2977
commit 7ec4241f7d
4 changed files with 253 additions and 80 deletions

View File

@ -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

View File

@ -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 <p>, 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
) : (
<Link key={index} to={part.to} className={LINK_CLASS}>
{part.text}
</Link>
),
)
}
export const Paragraph = ({ block }) => (
<p className="mt-4 text-base leading-relaxed text-soft-text">
<Inline value={block.text} />
</p>
)
const Heading = ({ block }) => (
<h3 className="mt-8 text-lg font-semibold text-primary-navy">{block.text}</h3>
)
const BulletList = ({ block }) => (
<ul className="mt-4 space-y-2">
{block.items.map((item) => (
<li key={item} className="flex gap-3 text-base leading-relaxed text-soft-text">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
)
// 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 }) => (
<ol className="mt-4 space-y-3 list-decimal pl-5 marker:font-numeric marker:font-semibold marker:text-primary-blue">
{block.items.map((item) => {
const step = typeof item === 'string' ? { text: item } : item
return (
<li key={step.text} className="pl-1 text-base leading-relaxed text-soft-text">
<span className="font-semibold text-text">{step.text}</span>
{step.detail ? <span className="block text-base leading-relaxed text-soft-text">{step.detail}</span> : null}
</li>
)
})}
</ol>
)
const Callout = ({ block }) => (
<p className="mt-5 rounded-md border border-border border-l-[3px] border-l-accent-gold bg-section-alt p-5 text-base leading-relaxed text-text">
<Inline value={block.text} />
</p>
)
// 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 ? (
<figure className="mt-6">
<img
src={block.src}
alt={block.alt}
width={block.width}
height={block.height}
loading="lazy"
decoding="async"
className="w-full rounded-md border border-border bg-white"
/>
{block.caption ? (
<figcaption className="mt-2 text-sm text-soft-text">{block.caption}</figcaption>
) : null}
</figure>
) : null
const LinkList = ({ block }) => (
<ul className="mt-4 space-y-2">
{block.items.map((item) => (
<li key={item.to} className="flex gap-3 text-base leading-relaxed text-soft-text">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
<Link to={item.to} className={LINK_CLASS}>
{item.label}
</Link>
</li>
))}
</ul>
)
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 <Render block={block} />
}
/**
* 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 }) => (
<article id={section.id} className="mt-12 scroll-mt-28">
<h2 className={headingClassName ?? 'text-2xl md:text-3xl font-bold text-primary-navy'}>
{section.number ? (
<>
<span className="font-numeric">{section.number}.</span> {section.title}
</>
) : (
section.title
)}
</h2>
{section.blocks.map((block, index) => (
<Block
key={`${section.id}-${index}`}
block={block}
section={section.id}
index={index}
renderers={renderers}
/>
))}
</article>
)

View File

@ -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 = '' }) => (
<ul className={className}>
{links.map((link) => (
<li key={link.to} className="flex gap-3 text-base leading-relaxed text-soft-text">
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-primary-blue" aria-hidden="true" />
<Link to={link.to} className={LINK_CLASS}>
{link.label}
</Link>
</li>
))}
</ul>
)
const RelatedLinks = ({ links, title = 'Related services', as: Heading = 'h2', className = '', headingClassName }) => {
if (!links?.length) return null
return (
<section className={className}>
<Heading className={headingClassName ?? 'text-2xl font-bold text-primary-navy'}>{title}</Heading>
<LinkList links={links} className="mt-4 space-y-2" />
</section>
)
}
export default RelatedLinks

View File

@ -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 = '' }) => (
<a
href={`mailto:${PRIVACY_EMAIL}`}
className={`font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors ${className}`}
>
<a href={`mailto:${PRIVACY_EMAIL}`} className={`${LINK_CLASS} ${className}`}>
{PRIVACY_EMAIL}
</a>
)
// 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 <p className="mt-4 text-base leading-relaxed text-soft-text">{text}</p>
}
// 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 <Paragraph block={block} />
const [before, after] = text.split(PRIVACY_EMAIL)
const [before, after] = block.text.split(PRIVACY_EMAIL)
return (
<p className="mt-4 text-base leading-relaxed text-soft-text">
{before}
@ -28,59 +30,35 @@ const Paragraph = ({ text, linkEmail }) => {
)
}
const Block = ({ block }) => {
switch (block.type) {
case 'h3':
return <h3 className="mt-8 text-lg font-semibold text-primary-navy">{block.text}</h3>
const EmailBlock = () => (
<p className="mt-4 text-base leading-relaxed">
<EmailLink />
</p>
)
case 'ul':
return (
<ul className="mt-4 space-y-2">
{block.items.map((item) => (
<li key={item} className="flex gap-3 text-base leading-relaxed text-soft-text">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
)
const ContactBlock = () => (
<div className="mt-5 rounded-md border border-border bg-section-alt p-6">
<p className="text-base font-semibold text-primary-navy">Queue North Technologies</p>
<p className="mt-2 text-base leading-relaxed text-soft-text">
Email: <EmailLink />
</p>
<p className="mt-1 text-base leading-relaxed text-soft-text">
Website:{' '}
<a
href="https://queuenorth.com"
className="font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors"
>
https://queuenorth.com
</a>
</p>
</div>
)
case 'callout':
return (
<p className="mt-5 rounded-md border border-border border-l-[3px] border-l-accent-gold bg-section-alt p-5 text-base leading-relaxed text-text">
{block.text}
</p>
)
case 'email':
return (
<p className="mt-4 text-base leading-relaxed">
<EmailLink />
</p>
)
case 'contactBlock':
return (
<div className="mt-5 rounded-md border border-border bg-section-alt p-6">
<p className="text-base font-semibold text-primary-navy">Queue North Technologies</p>
<p className="mt-2 text-base leading-relaxed text-soft-text">
Email: <EmailLink />
</p>
<p className="mt-1 text-base leading-relaxed text-soft-text">
Website:{' '}
<a
href="https://queuenorth.com"
className="font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors"
>
https://queuenorth.com
</a>
</p>
</div>
)
default:
return <Paragraph text={block.text} linkEmail={block.linkEmail} />
}
const POLICY_RENDERERS = {
...BASE_RENDERERS,
p: PolicyParagraph,
email: EmailBlock,
contactBlock: ContactBlock,
}
const PrivacyPolicy = () => {
@ -140,20 +118,7 @@ const PrivacyPolicy = () => {
</nav>
{sections.map((section) => (
<article key={section.id} id={section.id} className="mt-12 scroll-mt-28">
<h2 className="text-2xl md:text-3xl font-bold text-primary-navy">
{section.number ? (
<>
<span className="font-numeric">{section.number}.</span> {section.title}
</>
) : (
section.title
)}
</h2>
{section.blocks.map((block, index) => (
<Block key={`${section.id}-${index}`} block={block} />
))}
</article>
<ContentSection key={section.id} section={section} renderers={POLICY_RENDERERS} />
))}
</div>
</section>