Commit Graph

5 Commits

Author SHA1 Message Date
null 2f1e24892a fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.

1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
   history, and the image build cannot see git: .dockerignore excludes .git and
   node:alpine has no git binary. prerender.js read the failure into an empty
   catch commented "git unavailable or file untracked", so all 18 URLs came out
   undated while the build printed a success line. Local builds looked perfect,
   which is why nobody caught it.

   release.sh now computes the map where git exists, passes it as the
   SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
   has dates, refusing to publish one that does not. prerender prints the count
   on every run, so "18 URLs, 0 dated" can never again read as success. The
   route-to-source map moved into scripts/lib/routes.js, where a service page
   now also counts its own content file, so editing one page's copy moves that
   page's date and no other.

   Proven: an image built with the arg carries 18 lastmod entries; a build with
   git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
   lastmod" and warns.

2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
   zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
   zips were all sent to the daemon on every build, along with four agent
   workspaces. The final image copies only built output, so none of it ever
   shipped, but one careless COPY would have changed that. Proven by listing the
   context from inside a throwaway image: before, all of it; after, none of it.

3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
   untracked module the code imports produces an image that works and a tag that
   cannot rebuild it. release.sh now refuses while untracked files are present,
   and pre-commit's note counts them too.

#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:58:37 -05:00
null 0d575f2977 feat(build): the copy is checked before a single page is built from it
src/data is prose in a data structure, and nothing checked it. The long-form
service pages make that dangerous in a specific way: their copy arrives as an
owner-approved markdown sheet that MIXES DIRECTIONS TO THE WEBSITE MANAGER INTO
THE COPY. "Do not promise that every number is always portable." "Keep this
factual:" "Place an official 8x8 Work screenshot beside this section." Those
lines look exactly like copy, and publishing one puts an internal instruction on
a customer-facing page.

scripts/lib/content.js decides whether the content layer is publishable, and
prerender.js runs it before rendering anything, so every build enforces it: the
pre-commit hook, npm run verify, and the Docker image build. It refuses a
website-manager direction, an em dash, a U+FFFD, markdown or an HTML tag left in
a string, an unknown block type, a section id that is not letter-first, unique
and free of the layout's own ids, a section that does not open with its direct
answer (unless it declares kind list or faq), a FAQ question with no answer, a
link to a route or fragment that does not exist, an image whose src is missing
from public/ or has no alt or no dimensions, and the missing benefits or
idealFor list that the short layout maps without checking. A description over
160 characters is a note, not a failure: owner-approved copy is published as
written.

scripts/lib/routes.js is now the one route list. prerender.js built its own
while src/routes.jsx built the router's, and nothing compared them: a route in
one and not the other is never prerendered, so the server answers it with
404.html while the site's own navigation links to it. entry-server.jsx exports
the router table so the build can compare the two.

Proven by mutation, seventeen of them, each expecting exactly one finding and
getting it: unknown block type, FAQ answer removed, answer moved below its list,
duplicate id, digit-leading id, link to /services/contact-centre,
#no-such-section, missing image file, image without dimensions, image without
alt, em dash, a manager direction, markdown bold, U+FFFD, an HTML tag, missing
h1, empty section. Both generated content modules pass unmutated. Against a real
build: an em dash added to industries.js failed npm run build naming the field,
and a /pricing route added to src/routes.jsx failed it naming the route.

Closes #230.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:43:01 -05:00
null a6b87c7123 fix(ui): React was throwing away the prerendered page on every route
Suspected from the code while planning Batch 17, then confirmed in Chromium:
every page logged React error #418, a hydration mismatch. React answers a
mismatch by discarding the server DOM and re-rendering the page on the client.
So the prerender ran, crawlers received it, and every visitor's browser threw it
away and did the work again.

Two causes, both ours:

1. prerender hoisted the JSON-LD scripts out of the body into <head>. React
   hoists only async scripts with a src, so on the client that script stays
   where its component renders it. The DOM and the client's first render
   therefore disagreed on every page that emits structured data. JSON-LD is
   valid anywhere in the document, so it now stays where React puts it. Title,
   meta and link tags are still hoisted, because React hoists those itself.

2. main.jsx rendered sonner's <Toaster> in the first client pass, and the server
   entry never rendered one, so the client expected a <section> the prerendered
   HTML did not have. It mounts after hydration instead, which costs nothing: a
   toast can only follow an interaction.

Measured in a real browser, all 18 sitemap pages, before and after: hydration
errors 18 to 0, other console and page errors 0. Each page keeps its
server-rendered DOM (an h1 stamped before hydration survives), and still has
exactly one head title and one canonical. Structured data is unchanged in
substance: 27 JSON-LD blocks across 19 pages, and OAI-SearchBot still receives
Service, BreadcrumbList and Organization on the contact-center page.

Closes #226.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:37:33 -05:00
null 28b07abc28 fix(build): the prerender preloaded the logo, hoisted any tag, and hid its errors
Four faults in the one build step that decides what a crawler receives.
Closes #233 and #232.

1. Every page preloaded /logo.png. The hero hint was keyed on the first
   `loading="eager"` image, and that is the header logo, on every page. So the
   image each page actually paints first was never preloaded, and React's own
   preload for it was being discarded as a duplicate. It now keys on the image
   React marked with a high fetch priority, matched case-insensitively, because
   React writes the attribute camelCase in HTML and a case-sensitive match would
   have quietly removed every preload instead.

2. The hoist moved ANY title, meta or link out of the body into <head>. An
   inline <svg><title> is a picture's label, and microdata rides in
   <meta itemprop>: both would have become page-level head tags the moment the
   long-form copy carried an icon with a title. SVG blocks are now parked before
   the hoist, and itemprop tags stay where they are.

3. `page.replace('</head>', body)` interprets `$&`, `$'` and `$$` INSIDE the
   replacement, and the replacement is page copy. React escapes & into &amp;, so
   any `$` immediately before an escaped character injected markup. Copy carries
   no `$` today; the next page with a price would have. Replacement is now a
   split and join, and each marker must appear exactly once.

4. A render error named no route, and a Suspense fallback shipped silently as
   an empty page. Both now fail the build and say which route.

Also refuses to run over its own output: dist/index.html is both the template
and the home page, so a second run without a rebuild gave every page two
canonicals.

Proven by mutation, each restored afterwards: a probe <svg><title> in the footer
stays in the body on all 19 pages and no page gains a second title; a Suspense
boundary fails the build naming the route; a second prerender run refuses; copy
reading "Save $10 & more" reaches the page literally with one root div; and
every page now preloads its own hero, with none preloading the logo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 04:32:33 -05:00
null ac6f5d1f78 feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.

Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
  is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, [email protected] as mailto

Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.

Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
  and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup

SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 01:45:52 -05:00