166.4 Legal Pages Bot Readability Seo
Documentation for Sessions
S166.4 — Legal Pages Bot-Readability & SEO
Date: 2026-03-02
Status: Partially Complete (workaround deployed, ideal solution blocked)
Decision ID: dev_1772485367977_juaj1tn
Branch: integrate/165-166
Problem Statement
The A2P campaign (Twilio TCR verification) was rejected because the legal page URLs (app.gograb.space/legal/privacy-policy, app.gograb.space/legal/terms-of-use) are not bot-readable. The Angular SPA serves a 9KB empty shell with zero legal content — TCR bots do a simple HTTP fetch (like curl) and see nothing.
Additionally:
gograb.space/legal/privacy-policy(withoutapp.subdomain) redirects to the marketing homepage — there's no legal content at the root domain- The legal pages work fine for real users (JS renders the content) but bots never execute JS
What Was Done
1. Brand-SEO Integration (COMPLETE)
Added SeoService and JsonLdService from @libs-foundational/brand-seo to all 3 legal components. This gives browser users proper dynamic meta tags when navigating via the Angular app.
Files modified:
libs/libs-structural/legal-lib/src/lib/components/privacy-policy/privacy-policy.component.tslibs/libs-structural/legal-lib/src/lib/components/terms-of-use/terms-of-use.component.tslibs/libs-structural/legal-lib/src/lib/components/legal-guide/legal-guide.component.ts
Each component now calls in its constructor:
this.seo.setPage({
title: 'Privacy Policy',
description: this.document.descriptionEn,
canonicalUrl: 'https://app.gograb.space/legal/privacy-policy',
keywords: '...',
robots: 'index, follow',
schemaType: 'WebPage',
});
this.jsonLd.set({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Privacy Policy - GoGrab Space',
// ...
}, 'legal-page');
And cleans up in ngOnDestroy():
this.jsonLd.remove('legal-page');
2. SeoService Fix (COMPLETE)
File: libs/libs-foundational/brand-seo/src/lib/services/seo.service.ts
Removed the isPlatformBrowser guard from the canonical <link> tag injection (line 80). The DOCUMENT token works fine on the server in Angular SSR, so restricting it to browser-only was unnecessarily preventing canonical URLs in prerendered output.
Before:
if (isPlatformBrowser(this.platformId)) {
this.updateCanonical(pageUrl);
}
After:
this.updateCanonical(pageUrl);
3. Post-Build Prerender Script (COMPLETE — WORKAROUND)
File: tools/scripts/build/prerender-legal-pages.js
A Node.js script that runs after nx build gograb-space to generate bot-readable static HTML for legal pages. It:
- Reads the built
dist/apps/gograb-space/browser/index.html(Angular shell with scripts/styles) - Reads each legal component's
.htmltemplate file - Extracts the English content block (
@if (language() === 'en') { ... }) - Strips Angular directives (
@if,[],(),routerLink,<lib-*>tags) - Injects into the shell HTML with: title, meta description, keywords, canonical URL, OG tags, Twitter Card, JSON-LD
- Writes to
dist/apps/gograb-space/browser/legal/{slug}/index.html
Key details:
- Root component selector is
<gograb-root>, NOT<app-root> - The loading spinner (id="app-initial-loader") is stripped — it includes a massive inline
<style>block - Angular scripts are preserved so the app hydrates for real users
- Generates 3 pages:
/legal/index.html,/legal/privacy-policy/index.html,/legal/terms-of-use/index.html
Integrated into build pipeline:
// package.json
"build:gograb": "NODE_OPTIONS='--max-old-space-size=6144' ... nx build gograb-space --configuration=production --parallel=1 && node tools/scripts/build/prerender-legal-pages.js"
Verified output:
| Metric | privacy-policy | terms-of-use |
|---|---|---|
| File size | 49 KB | 49 KB |
<title> |
Privacy Policy - GoGrab Space | Terms of Use - GoGrab Space |
<meta description> |
Full 150-char desc | Full 150-char desc |
| Canonical URL | https://app.gograb.space/legal/privacy-policy | https://app.gograb.space/legal/terms-of-use |
| OG tags | 6 | 6 |
| JSON-LD | WebPage schema with publisher | WebPage schema with publisher |
| Content sections | 19 | 21 |
| Angular scripts | Preserved for hydration | Preserved for hydration |
4. NX Circular Dependency Override (COMPLETE — WORKAROUND)
File: apps/gograb-space/project.json
Added "dependsOn": [] to the build target to bypass a circular dependency:
gograb-space:build → vitto-hub:build → vitto-maker:build → vitto-hub:build
This was introduced in Session 165 when vitto-maker started importing from vitto-hub:
VittoApiService— imported inmaker-action.service.ts,character-segment.component.ts,knowledge-segment.component.tsBrainContextService— imported invitto-profile.service.ts,knowledge-segment.component.ts
5. SSR Infrastructure (COMPLETE but NOT ACTIVATED)
Files in place:
apps/gograb-space/src/main.server.ts— server entry pointapps/gograb-space/src/app/app.config.server.ts— merges with client configapps/gograb-space/src/app/app.routes.server.ts— legal routes =RenderMode.Prerender, rest =RenderMode.Clientapps/gograb-space/src/ssr-browser-shim.ts— stubswindow,document,localStorage, etc.apps/gograb-space/src/reflect-metadata-ssr.ts— reflect-metadata for SSRapps/gograb-space/tsconfig.app.json—"files": ["src/main.ts", "src/main.server.ts"]
NOT in project.json (reverted because of the window is not defined error):
"server": "apps/gograb-space/src/main.server.ts",
"outputMode": "static"
Remaining Gaps (for next agent)
GAP 1: Angular SSR Prerender — window is not defined (HIGH VALUE)
The ideal solution. If fixed, the post-build script becomes unnecessary and brand-seo handles everything natively.
To activate: Add these two lines to apps/gograb-space/project.json build options:
"server": "apps/gograb-space/src/main.server.ts",
"outputMode": "static"
What happens: Build succeeds (esbuild compiles both browser + server bundles) but route extraction fails:
✘ [ERROR] An error occurred while extracting routes.
window is not defined
Root cause analysis:
Angular's route extraction runs the server bundle in a Node.js worker thread. During module evaluation (before any app code runs), some library references window as a bare global. The ssr-browser-shim.ts sets globalThis.window = windowStub, but it doesn't execute early enough in the bundled output.
What was tried:
- Side-effect import in
main.server.ts(first import) — shim runs but NOT before all transitive imports evaluate - Adding shim to
polyfillsarray — Angular builder applies polyfills to browser bundle, not server worker - Both together — same error
Investigation approaches NOT yet tried:
- Find the offender: Run the built server bundle directly in Node.js to get a stack trace. The bundle should be in a temp directory during build, or add
"outputPath"for server output. Try:node --stack-trace-limit=100 dist/apps/gograb-space/server/server.mjs(if it exists) - esbuild banner injection: The Angular builder wraps esbuild. Check if there's a way to inject code via esbuild's
banneroption that runs before any module evaluation. This would be:globalThis.window = globalThis.window || globalThis; - Audit top-level
windowreferences: Searchnode_modulesfor libraries that accesswindowat the top level (not inside functions). Common culprits: socket.io-client, firebase, some analytics libs - Use
externalDependencies: Angular 20's builder has anexternalDependenciesoption to exclude specific packages from the server bundle. If the offending package isn't needed for legal page rendering, exclude it - Custom webpack/esbuild plugin: Angular's builder supports plugins. A plugin could inject the shim as the very first code in the server bundle
- Angular 20
serverPolyfillsoption: Check if Angular 20.1+ added this. Search Angular GitHub issues for "window is not defined" + "outputMode static"
Key constraint: The app has 238+ NX libraries and MANY heavy providers (MongoDB sync, WebSocket, storage system, module registry). Most have isPlatformBrowser() guards in their APP_INITIALIZER blocks, but some libraries may access window during static import evaluation.
GAP 2: NX Circular Dependency — vitto-hub ↔ vitto-maker (MEDIUM)
Current workaround: "dependsOn": [] in gograb-space's project.json. This skips ALL library pre-builds, which is fine because the Angular application builder bundles from source directly.
Proper fix: Extract shared services into a new library:
@libs-ai/vitto-core (NEW)
├── VittoApiService (moved from vitto-hub)
└── BrainContextService (moved from vitto-hub)
Then:
vitto-hubimports from@libs-ai/vitto-corevitto-makerimports from@libs-ai/vitto-core- No circular dependency
Files that import the problematic services from vitto-hub:
libs/libs-ai/vitto-maker/src/lib/services/vitto-profile.service.ts(VittoApiService, BrainContextService)libs/libs-ai/vitto-maker/src/lib/services/maker-action.service.ts(VittoApiService)libs/libs-ai/vitto-maker/src/lib/components/segments/character-segment/character-segment.component.ts(VittoApiService)libs/libs-ai/vitto-maker/src/lib/components/segments/knowledge-segment/knowledge-segment.component.ts(VittoApiService, BrainContextService)
Where they're exported from vitto-hub:
VittoApiService— fromlibs/libs-ai/vitto-hub/src/lib/services/index.tsBrainContextService— fromlibs/libs-ai/vitto-hub/src/lib/components/index.ts
NX config that triggers the cycle: nx.json line 48: "build": { "dependsOn": ["^build"] }
GAP 3: SEO Duplication Between Components and Script (LOW)
The SEO meta tag configuration exists in two places:
- Angular components (runtime, for browser users) — uses
SeoService+JsonLdService - prerender-legal-pages.js (build-time, for bots) — hardcodes the same values
If someone updates the legal page description or adds a new legal page, they need to update BOTH. This is a maintenance risk.
Possible fixes:
- Have the script import from
legal.constants.tsdirectly (it's a TypeScript file, but the data is static) - Create a shared
legal-seo-config.jsonthat both the components and the script read - If SSR prerender is fixed (Gap 1), delete the script entirely
GAP 4: Spanish Prerender (LOW)
Only English content is prerendered. The script's extractEnglishContent() function specifically targets @if (language() === 'en'). If Spanish bot-readability is needed for the A2P campaign, a extractSpanishContent() equivalent would be needed, plus additional pages at /legal/privacy-policy?lang=es.
GAP 5: gograb.space (root domain) Legal URLs (POSSIBLY BLOCKING)
The Testing Director noted that gograb.space/legal/privacy-policy (without app. subdomain) redirects to the homepage. This is a DNS/hosting config issue — the root domain doesn't serve the Angular app.
If the A2P campaign URLs must be at gograb.space rather than app.gograb.space, this needs a Cloudflare redirect rule or a separate deployment. Current deploy serves to app.gograb.space via Cloudflare Pages.
Architecture Reference
Brand-SEO Library (@libs-foundational/brand-seo)
libs/libs-foundational/brand-seo/src/
├── index.ts # Public API
└── lib/
├── models/
│ ├── app-brand.model.ts # AppBrand interface
│ └── seo-page-config.model.ts # SeoPageConfig interface
├── presets/ # 6 brand configs
│ ├── gograb-space.brand.ts
│ ├── vitto.brand.ts
│ └── ... (4 more)
├── providers/
│ └── provide-brand-seo.ts # provideBrandSeo() function
├── services/
│ ├── seo.service.ts # Meta tags (SSR-safe)
│ ├── seo-context.service.ts # Dynamic space/universe context (signals)
│ ├── favicon.service.ts # Browser-only favicon
│ └── json-ld.service.ts # Structured data (SSR-safe)
├── strategies/
│ └── brand-title.strategy.ts # Custom TitleStrategy: "Page | Space | Brand"
└── tokens/
└── brand-tokens.ts # APP_BRAND InjectionToken
Integration (already done):
// app.config.ts line 204
...provideBrandSeo(GOGRAB_SPACE_BRAND),
Legal Library (@libs-structural/legal-lib)
libs/libs-structural/legal-lib/src/
├── index.ts
└── lib/
├── constants/
│ └── legal.constants.ts # LEGAL_DOCUMENTS registry, getLegalDocument()
├── types/
│ └── legal.types.ts # LegalLanguage, LegalSection, LegalDocument
├── legal.routes.ts # LegalRoutes (lazy-loaded components)
└── components/
├── legal-guide/ # Hub page at /legal
├── privacy-policy/ # 1910 lines of template HTML (EN + ES)
├── terms-of-use/ # 1810 lines of template HTML (EN + ES)
└── shared/
├── legal-layout/ # Two-column layout wrapper
├── language-toggle/ # EN/ES toggle
└── table-of-contents/ # Sticky sidebar TOC
Deployment Pipeline
# Build (includes prerender)
yarn build:gograb
# Deploy to Cloudflare Pages
yarn deploy:wrangler:gograb
# → deploys dist/apps/gograb-space/browser/ to gograb-space-app project
# The prerendered legal pages are at:
# dist/apps/gograb-space/browser/legal/index.html
# dist/apps/gograb-space/browser/legal/privacy-policy/index.html
# dist/apps/gograb-space/browser/legal/terms-of-use/index.html
Cloudflare Pages serves static files first, so when a bot requests /legal/privacy-policy, Cloudflare finds legal/privacy-policy/index.html and serves that directly (full HTML with content). When a real user visits, the same HTML loads, but Angular hydrates and takes over.
Key Gotchas for Next Agent
- Root component is
<gograb-root>, NOT<app-root>— checkapps/gograb-space/src/app/app.component.tsselector - The loading spinner in index.html has an inline
<style>block — simple div-matching regexes won't work; need[\s\S]*?<\/style>to capture it all nx build gograb-spacefails without"dependsOn": []due to vitto-hub ↔ vitto-maker cycle — don't remove this override unless the circular dependency is fixed firstoutputMode: 'static'+serverin project.json triggers thewindow is not definederror — don't add these back without first solving GAP 1- The
</head>tag in the built index.html is on the same line as preload links (no newline before it) — string replacement still works but don't expect it on its own line - Deploy source is
./dist/apps/gograb-space/browser(not/dist/apps/gograb-space/) — thebrowsersubdirectory is the actual web root - The prerender script uses ESM (
importsyntax) — run with Node.js 18+ or add"type": "module"check