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