From 7ec4241f7da80725554afc3c6d7e09d2595d05d7 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 10 Sep 2026 04:45:48 -0500 Subject: [PATCH] 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

. 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) --- docs/architecture/README.md | 16 ++- src/components/content/ContentBlocks.jsx | 166 +++++++++++++++++++++++ src/components/content/RelatedLinks.jsx | 36 +++++ src/pages/PrivacyPolicy.jsx | 115 ++++++---------- 4 files changed, 253 insertions(+), 80 deletions(-) create mode 100644 src/components/content/ContentBlocks.jsx create mode 100644 src/components/content/RelatedLinks.jsx 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 }) => ( + +) + +// 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 ( +
  1. + {step.text} + {step.detail ? {step.detail} : null} +
  2. + ) + })} +
+) + +const Callout = ({ block }) => ( +

+ +

+) + +// 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.alt} + {block.caption ? ( +
{block.caption}
+ ) : null} +
+ ) : null + +const LinkList = ({ block }) => ( + +) + +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.number ? ( + <> + {section.number}. {section.title} + + ) : ( + section.title + )} +

+ {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 = '' }) => ( + +) + +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 (

{before} @@ -28,59 +30,35 @@ const Paragraph = ({ text, linkEmail }) => { ) } -const Block = ({ block }) => { - switch (block.type) { - case 'h3': - return

{block.text}

+const EmailBlock = () => ( +

+ +

+) - case 'ul': - return ( -
    - {block.items.map((item) => ( -
  • -
  • - ))} -
- ) +const ContactBlock = () => ( +
+

Queue North Technologies

+

+ Email: +

+

+ Website:{' '} + + https://queuenorth.com + +

+
+) - case 'callout': - return ( -

- {block.text} -

- ) - - case 'email': - return ( -

- -

- ) - - case 'contactBlock': - return ( -
-

Queue North Technologies

-

- Email: -

-

- Website:{' '} - - https://queuenorth.com - -

-
- ) - - default: - return - } +const POLICY_RENDERERS = { + ...BASE_RENDERERS, + p: PolicyParagraph, + email: EmailBlock, + contactBlock: ContactBlock, } const PrivacyPolicy = () => { @@ -140,20 +118,7 @@ const PrivacyPolicy = () => { {sections.map((section) => ( -
-

- {section.number ? ( - <> - {section.number}. {section.title} - - ) : ( - section.title - )} -

- {section.blocks.map((block, index) => ( - - ))} -
+ ))}