diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 27d7b494..6da0ae25 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -108,6 +108,8 @@ jobs: NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }} NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }} NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} + # Browsers load the live stats through it, and the build reads it once to bake the + # numbers of this moment into the landing page's static HTML (lib/platformStats.ts). NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }} NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }} # Build stamp for public/version.json (scripts/generate-version.js in prebuild); @@ -121,6 +123,9 @@ jobs: npm run build test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; } test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; } + # Fail-soft like the star count: browsers still load the stats live; only crawlers and + # LLM fetchers, which run no JS, see no numbers until the next deploy. + grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy" # One archive of the exact bytes that get uploaded, so the attestation has a single subject. - name: Package export diff --git a/src/Web/autopilot-monitor-web/app/page.tsx b/src/Web/autopilot-monitor-web/app/page.tsx index a05267c3..7bd85376 100644 --- a/src/Web/autopilot-monitor-web/app/page.tsx +++ b/src/Web/autopilot-monitor-web/app/page.tsx @@ -1,41 +1,17 @@ -import { AuthGate } from "../components/landing/AuthGate"; -import { LandingNavbar } from "../components/landing/LandingNavbar"; -import { Hero } from "../components/landing/Hero"; -import { StatsBand } from "../components/landing/StatsBand"; -import { JustAsk } from "../components/landing/JustAsk"; -import { Story } from "../components/landing/Story"; -import { CapabilitiesStrip } from "../components/landing/CapabilitiesStrip"; -import { Comparison } from "../components/landing/Comparison"; -import { HowItWorks } from "../components/landing/HowItWorks"; -import { Testimonials } from "../components/landing/Testimonials"; -import { FinalCta } from "../components/landing/FinalCta"; -import { SiteFooter } from "../components/SiteFooter"; -import { MarketingTracker } from "../components/MarketingTracker"; +import { LandingPage } from "../components/landing/LandingPage"; +import { loadPlatformStatsSnapshot } from "../lib/platformStats"; + +// The stats read below runs once per `next build` and bypasses Next's fetch cache (`no-store`, +// see lib/platformStats.ts). force-static keeps that legal in the static export; the landing +// tree reads no request-time API (cookies, headers, search params) that it would blank out. +export const dynamic = "force-static"; /** - * Landing page v2 — a scroll story of one enrollment. - * All theming goes through the lp-* tokens (globals.css); sections live - * in components/landing/. Auth behavior (AuthGate redirect, LoginButton - * portal handoff) is unchanged from v1. + * The landing page with the platform stats of the build baked into its static HTML, so + * crawlers and LLM fetchers that run no JS see the numbers too; browsers then load the live + * ones (components/landing/StatsBand.tsx). Awaited here, not under a Suspense boundary: React + * would move a resolved boundary of a page this size into a hidden div at the end of the body. */ -export default function LandingPage() { - return ( -
- {/* Client component: handles auth redirect + loading overlay */} - - - - - - - - - - - - - - -
- ); +export default async function Page() { + return ; } diff --git a/src/Web/autopilot-monitor-web/components/__tests__/landingStats.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/landingStats.test.ts new file mode 100644 index 00000000..47bb5f9b --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/__tests__/landingStats.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; + +// LandingNavbar and LoginButton read the auth context; the public pages render signed out. +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => ({ login: async () => undefined, isAuthenticated: false, user: null, getAccessToken: async () => null }), +})); +// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links. +vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null })); + +const { SNAPSHOT } = vi.hoisted(() => ({ + SNAPSHOT: { + items: [ + { label: "enrollments monitored", value: "12,481" }, + { label: "events processed", value: "8.3M" }, + ], + asOf: "2026-10-02T14:00:03.1234567Z", + }, +})); +// The blob read itself is covered in lib/__tests__/platformStats.test.ts. +vi.mock("@/lib/platformStats", async importOriginal => ({ + ...(await importOriginal()), + loadPlatformStatsSnapshot: async () => SNAPSHOT, +})); + +import LandingRoute from "@/app/page"; +import { StatsBand } from "@/components/landing/StatsBand"; + +/** + * Crawlers and LLM fetchers run no JS: the platform stats only reach them when the build renders + * the numbers into the landing page's HTML (lib/platformStats.ts, app/page.tsx). + */ +describe("platform stats in the landing page's static HTML", () => { + it("the band renders the baked numbers and marks the snapshot", () => { + const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: SNAPSHOT })); + expect(html).toContain(">12,481<"); + expect(html).toContain(">enrollments monitored<"); + expect(html).toContain(">8.3M<"); + expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`); + expect(html).not.toContain("animate-pulse"); + }); + + it("without a snapshot the band shows the loading skeleton and no marker", () => { + const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: null })); + expect(html).toContain('data-track-section="stats"'); + expect(html).toContain("animate-pulse"); + expect(html).not.toContain("data-stats-snapshot"); + }); + + // A Suspense boundary around the read would still resolve at build time, but React moves a + // resolved boundary of a page this size into a hidden div at the end of the body. + it("the page renders the numbers in place, not in a hidden streaming segment", async () => { + const { prelude } = await prerender(createElement(LandingRoute)); + const html = await new Response(prelude).text(); + + const bandStart = html.indexOf('data-track-section="stats"'); + const nextSection = html.indexOf('data-track-section="just_ask"'); + const figure = html.indexOf(">12,481<"); + expect(bandStart).toBeGreaterThan(-1); + expect(figure).toBeGreaterThan(bandStart); + expect(figure).toBeLessThan(nextSection); + expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`); + expect(html).not.toMatch(/