Skip to content

feat(seo): server-render sponsor page content for crawlers - #111

Merged
Sam-Aitech merged 4 commits into
mainfrom
chore/sponsor-seo-ssr
Sep 4, 2026
Merged

feat(seo): server-render sponsor page content for crawlers#111
Sam-Aitech merged 4 commits into
mainfrom
chore/sponsor-seo-ssr

Conversation

@Sam-Aitech

Copy link
Copy Markdown
Owner

Summary

  • Server-renders full sponsor page content (status, licence details, change history, FAQ) inside #root for bot requests, so crawlers index real content instead of the generic SPA fallback shell — Google was treating ~124k sponsor pages as thin/duplicate and not indexing them.
  • Adds FAQPage + BreadcrumbList JSON-LD alongside the existing Organization schema.
  • Full review pass applied on top of the initial implementation (see commit 9fac47f): fixed a JSON-LD script-tag breakout (unescaped < in JSON.stringify output), a $-pattern corruption bug in the #root replace, a silent-failure path that could cache a broken/empty page for 6h with no error, and a bug where sponsors in GRACE_PERIOD were shown a false "licence revoked, cannot support a UK visa application" claim.

Test plan

  • tsc --noEmit clean
  • npm run lint clean
  • npm run build clean
  • 475 tests pass (43 files), including new route-level coverage for server/routes/seo.ts (cache hit/miss, DB-not-found fallthrough, non-bot bypass, JSON-LD escaping) and expanded sponsorSeoHtml.test.ts coverage (grace-period status, null fields, extra escaping paths, the $-corruption fix, the script-breakout fix)
  • Manual spot-check: hit /sponsor/:id/:slug with a bot User-Agent against a real sponsor id in a running instance and confirm rendered HTML + JSON-LD

🤖 Generated with Claude Code

Sam-Aitech and others added 4 commits September 4, 2026 20:21
Bots previously received the React shell with only <title>/meta swapped
— an empty <div id="root"> for all 124k /sponsor/:id pages, which Google
treats as thin/duplicate content. Injects real sponsor content (status,
licence details, change history, FAQ) inside #root, cached 6h per
sponsor and flushed nightly by the monitor job; React replaces it on
hydration for real browsers. Adds FAQPage + BreadcrumbList JSON-LD.

Reintegrated from a June 2026 branch that never merged. Kept current
main's simpler Cache-Control (public, max-age=3600, no
stale-while-revalidate) rather than silently reintroducing the value
a later main commit (13c5b82) deliberately dropped from this same line.

Unrelated to this change: the SubscriptionModal.tsx deletion originally
bundled with this work was already done independently on main (3b46e6b).

Full suite green, tsc clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion claim, silent failures)

Critical fixes:
- JSON-LD script-tag breakout: JSON.stringify() doesn't escape "<", so a
  register field containing "</script><script>..." (e.g. company name)
  could close the ld+json block early and inject live markup. Added
  toSafeJsonLd() (escapes "<" to \u003c) and used it for every JSON-LD
  block, replacing the raw JSON.stringify() calls.
- $-pattern corruption in the #root injection: seoBody was spliced into
  the replacement *string* passed to html.replace(), where $&/$$/$'/$1
  etc. are special substitution tokens — a literal "$" in DB-sourced
  sponsor data could silently corrupt the injected HTML. Switched to a
  replacer function.
- Silent injection failure: if the #root regex ever stops matching the
  built template (bundler/whitespace change), replace() was a no-op with
  no error, and the broken page got cached for 6h — reintroducing the
  exact thin-content problem this feature exists to fix. Now compares
  html before/after the replace, logs a warning and skips the cache
  write when nothing was injected.
- isRevoked collapsed GRACE_PERIOD into "revoked": sponsors 1 day into a
  register gap (status still unconfirmed) were shown "no longer a
  licensed sponsor... cannot support a UK visa application" — a false,
  legally-sensitive claim directly contradicting the correctly-hedged
  statusSentence() text on the same page. isRevoked now only matches
  REMOVED_REVOKED; GRACE_PERIOD gets its own softer "under review" notice.

Also:
- Removed duplicated status-label and HTML-escaping logic between seo.ts
  and sponsorSeoHtml.ts (statusLabel, escapeHtml both now exported and
  shared; escapeAttr in seo.ts is an alias of the same function).
- Extracted the #root injection regex into a shared, exported constant
  (ROOT_INJECTION_REGEX) instead of duplicating it with a
  "must stay in sync" comment between seo.ts and its test.
- Escaped the changeType fallback in the history list (defense-in-depth;
  was unescaped raw DB value).
- Corrected comments claiming #root is "empty" pre-injection (it holds a
  generic SSR fallback) and that React "hydrates" it (main.tsx calls
  createRoot().render(), a plain client-side remount, not hydration).
- Added route-level tests for server/routes/seo.ts (previously zero
  coverage): cache hit/miss, DB-not-found fallthrough, non-bot/non-html
  bypass, and the JSON-LD escaping fix.
- Added sponsorSeoHtml.ts coverage for GRACE_PERIOD/NEWLY_GRANTED
  statuses, null townCity/route/typeRating/removedAt branches, escaping
  beyond currentName (townCity, history previousValue/newValue), the
  changeType fallback, the $-corruption fix, and the JSON-LD
  script-breakout fix.

Verified: tsc clean, lint clean, build clean, 475 tests pass (43 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Extract the nested "(removed around ...)" template literal into a
  removedClause variable (S4624: nested template literals).
- Turn the isRevoked/isGracePeriod nested ternary for warningBlock into
  an if/else (S3358: nested ternary).
- Build the JSON-LD "<" unicode-escape via String.fromCharCode(92) +
  "u003c" instead of a backslash-escaped string literal (S7780), keeping
  the critical XSS-prevention behavior from the prior commit unchanged
  (verified by the existing script-breakout test).

Verified: tsc clean, lint clean, build clean, 475 tests pass (43 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Behaviorally identical for this ASCII backslash constant; fromCodePoint
is the modern preferred API.

Verified: tsc clean, lint clean, sponsor SEO tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

@Sam-Aitech
Sam-Aitech merged commit 7c8d06d into main Sep 4, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant