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:


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:

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:

  1. Reads the built dist/apps/gograb-space/browser/index.html (Angular shell with scripts/styles)
  2. Reads each legal component's .html template file
  3. Extracts the English content block (@if (language() === 'en') { ... })
  4. Strips Angular directives (@if, [], (), routerLink, <lib-*> tags)
  5. Injects into the shell HTML with: title, meta description, keywords, canonical URL, OG tags, Twitter Card, JSON-LD
  6. Writes to dist/apps/gograb-space/browser/legal/{slug}/index.html

Key details:

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:

5. SSR Infrastructure (COMPLETE but NOT ACTIVATED)

Files in place:

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:

  1. Side-effect import in main.server.ts (first import) — shim runs but NOT before all transitive imports evaluate
  2. Adding shim to polyfills array — Angular builder applies polyfills to browser bundle, not server worker
  3. Both together — same error

Investigation approaches NOT yet tried:

  1. 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)
  2. esbuild banner injection: The Angular builder wraps esbuild. Check if there's a way to inject code via esbuild's banner option that runs before any module evaluation. This would be: globalThis.window = globalThis.window || globalThis;
  3. Audit top-level window references: Search node_modules for libraries that access window at the top level (not inside functions). Common culprits: socket.io-client, firebase, some analytics libs
  4. Use externalDependencies: Angular 20's builder has an externalDependencies option to exclude specific packages from the server bundle. If the offending package isn't needed for legal page rendering, exclude it
  5. Custom webpack/esbuild plugin: Angular's builder supports plugins. A plugin could inject the shim as the very first code in the server bundle
  6. Angular 20 serverPolyfills option: 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:

Files that import the problematic services from vitto-hub:

Where they're exported from vitto-hub:

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:

  1. Angular components (runtime, for browser users) — uses SeoService + JsonLdService
  2. 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:

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

  1. Root component is <gograb-root>, NOT <app-root> — check apps/gograb-space/src/app/app.component.ts selector
  2. 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
  3. nx build gograb-space fails without "dependsOn": [] due to vitto-hub ↔ vitto-maker cycle — don't remove this override unless the circular dependency is fixed first
  4. outputMode: 'static' + server in project.json triggers the window is not defined error — don't add these back without first solving GAP 1
  5. 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
  6. Deploy source is ./dist/apps/gograb-space/browser (not /dist/apps/gograb-space/) — the browser subdirectory is the actual web root
  7. The prerender script uses ESM (import syntax) — run with Node.js 18+ or add "type": "module" check