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:
parent
0d575f2977
commit
7ec4241f7d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,39 +30,13 @@ 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>
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
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 (
|
||||
const EmailBlock = () => (
|
||||
<p className="mt-4 text-base leading-relaxed">
|
||||
<EmailLink />
|
||||
</p>
|
||||
)
|
||||
)
|
||||
|
||||
case 'contactBlock':
|
||||
return (
|
||||
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">
|
||||
|
|
@ -76,11 +52,13 @@ const Block = ({ block }) => {
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue