feat(website): AI search optimization — Search Console harness, structured data, and measurement - #826
Open
blove wants to merge 35 commits into
Open
feat(website): AI search optimization — Search Console harness, structured data, and measurement#826blove wants to merge 35 commits into
blove wants to merge 35 commits into
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d sitemap Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure analysis helpers (striking distance, zero-impression pages, unindexed, canonical mismatches, weak CTR) plus a markdown report generator over the .gsc snapshots. The report reads inspection-errors.json when present so a partial inspection sweep is stated as partial: failed URLs stay in the sitemap inventory, the failure count is reported, and the index-health counts are labelled lower bounds rather than implying a clean bill of health. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tests: real ordering assertions for findStrikingDistance and findWeakCtr (the previous one-row fixture passed with .sort() deleted), table-driven filter boundaries including the inclusive/exclusive asymmetry between impressions and ctr, and first coverage for findCanonicalMismatches and findWeakCtr. The both-canonicals-required policy is now pinned by test and stated in the doc comment. Report: InspectionFailure moves to api.ts so the pull/report serialization contract is declared once; snapshot reads report a missing or corrupt file by name and point at the pull instead of throwing a raw ENOENT, with readOptional keeping its own existence check so genuine absence stays distinguishable; URL comparison normalizes protocol, host case, www, fragment and query string so a tagged URL is no longer reported as a page with zero impressions; all four bullet lists are capped, not just the failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google ignores changefreq/priority and uses lastmod when it is honest, so the sitemap now emits only lastmod, derived per route from its real source: blog frontmatter dates, docs .mdx files, and page.tsx templates (plus the solutions data module for the programmatic /solutions/* pages). Times come from git commit history rather than file mtimes: a fresh CI checkout rewrites every mtime to clone time, which would claim the whole site changed on every deploy. Shallow clones are detected and their grafted boundary commits discarded, and any route we cannot date honestly simply omits lastmod rather than fabricating one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `shallow` marker lives in the common git dir, not the per-worktree gitdir, so `--absolute-git-dir` misses it inside a linked worktree (.git/worktrees/<name> vs .git) and the sitemap silently dropped lastmod for every file-derived route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aths Moves the sitemap date logic out of site-metadata.ts into sitemap-dates.ts and removes the remaining ways a date could be invented: - Delete the file-mtime fallback. "Not shallow" never implied "committed" — a fresh full clone also rewrites every mtime to checkout time, so a single lookup miss could publish a build-time lastmod. - Run git with core.quotePath=false (octal-escaped non-ASCII paths would never match a lookup, silently falling through) and log.showSignature=false (a user's config could interleave gpg: lines into the file list). - Bound the subprocess with timeout/SIGKILL so a stalled git cannot hang the build; a kill throws and degrades like any other git failure. - Require a well-formed "<epoch> <sha1>" remainder before treating a line as a commit header, and take paths verbatim so leading whitespace survives. Blog routes now take the later of the frontmatter date and their .mdx commit time, since lastmod means last modified rather than published. Extracts parseGitLog as a pure function and unit-tests the degradation the design rests on — most importantly that a grafted shallow-boundary commit yields no entry rather than a clone-time one — plus a test pinning the absence of changeFrequency/priority. Integration tests now tolerate a history-less checkout instead of asserting completeness unconditionally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Emit article:published_time / modified_time / author / tag on blog posts, with modified_time derived from real git commit times (sitemap-dates) rather than defaulting to the publish date. createPageMetadata gains an optional per-page social image for the Task 9 OG-image routes. Unify the brand on "Threadplane" (was "ThreadPlane" in blog titles and prose) and the docs title separator on an em dash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the redundant `lastModified > published` guard in the blog route: it could never change the value (getRouteLastModified already returns the max) and its only effect was flipping modifiedTime to undefined, which made unedited posts emit a date-only article:published_time while edited ones emitted a full ISO timestamp. The `?? publishedTime` fallback in createPageMetadata is now the one place that rule lives, and both fields are ISO timestamps on every post. Date parsing moves to the shared `publishedDate()` helper, so a malformed frontmatter date drops the article block instead of shipping NaN-adjacent garbage as article:published_time. getRouteLastModified's post index is now a required `postsByRoute` (renamed: the keys are route paths) rather than an optional argument with a drafts policy that diverged from the caller's, which could return different answers for the same route. Callers build it with the new getPostsByRoute() or pass the post they already hold. Replaces the tautological SITE_NAME assertion with a real scan of src, content, scripts, and e2e for the mis-cased brand, and covers the modifiedTime fallback plus the absence of article keys on non-article pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure builders for Organization, WebSite, SoftwareSourceCode, BlogPosting, TechArticle, and BreadcrumbList nodes, plus a JsonLd render component. Not mounted on any route yet. Every emitted URL was verified to resolve: the repository is cacheplane/angular-agent-framework (not blove/...), sameAs links the public package page rather than the member-gated npm org page, and the author URL and per-post OG image are omitted until /about and the fixed image route exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
expectSerializable claimed a round-trip but never compared the parsed result to the input, so JSON.parse(JSON.stringify(obj)) could not fail and the six "serializes to JSON" tests were vacuous. Adding toStrictEqual immediately caught blogPostingJsonLd leaving an undefined-valued `keywords` key when a post has no tags, where techArticleJsonLd already used the omit pattern for dateModified; both builders now agree. Also adds table-driven coverage of @context and the Organization @id reference across every builder that carries one, a rootJsonLd() @graph that makes the three root-layout nodes physically inseparable so an @id reference cannot be orphaned by mounting a subset, and an exported BreadcrumbCrumb type. Drops Organization.logo: a 1200x630 marketing social card is not a brand mark, and no square mark exists in the repo. Omitting is honest; the property should be restored once a real mark ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root layout mounts `rootJsonLd()` as a single `@graph`, so the Organization node travels with every page and the `@id` references made by BlogPosting and TechArticle always resolve. Blog posts emit BlogPosting + BreadcrumbList; docs pages emit TechArticle + BreadcrumbList. Both reuse the exact sources their `generateMetadata` already uses, so `dateModified`, `og:modified_time`, and the sitemap `<lastmod>` agree: `getPostLastModified()` factors the single-post date derivation the blog route had inline, and `resolveDocDescription()` exposes the description accessor that `getDocMetadata()` builds the meta description from. The docs breadcrumb links its library rung at that library's introduction page, mirroring the visible <DocsBreadcrumb>; `/docs/<library>` has no route and would have been a crumb pointing at a 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l surfaces The docs-description test was a tautology: this branch made `getDocMetadata` call `resolveDocDescription`, so both sides of the comparison had become the same function and 116 double MDX reads asserted nothing. Replaced with a spec that renders the docs route component and compares the JSON-LD it actually emits against the route's own `generateMetadata` — the surface where divergence can really occur. `libraryIntroPath()` now lives in docs-config and is called by both the visible <DocsBreadcrumb> and the page's BreadcrumbList, so the "markup mirrors the visible trail" claim is structural instead of commented. The new spec asserts the JSON-LD library rung equals the href the rendered component produces, and that every non-final rung is a route the sitemap knows. Also: the sitemap-agreement test no longer passes on two undefineds; the "unmodified" fallback rule collapses into one `resolveModifiedTime()` shared by og and JSON-LD; the docs fallback description and the docs last-modified derivation each get one home; and both page mounts hoist their builder calls above the return. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every blog post's social card returned HTTP 500 in production. Satori rejects a div with more than one child node unless it carries an explicit `display`, and the byline rendered three children (author, separator, date) with none. The root `/opengraph-image` route was unaffected only because it is prerendered at build time, while `/blog/[slug]/opengraph-image` is server-rendered on demand. Separately, the route read the bundled Garamond TTF through a parent traversal (`join(here, '../../EBGaramond-Bold.ttf')`) that Next's file tracer cannot statically resolve, so the font was absent from the deployed function's trace — confirmed by diffing the two routes' `route.js.nft.json`. Move font loading into `src/app/og-font.ts`, colocated with the TTF, so the sibling-filename form traces for every caller. Both routes now share it. Make the failure mode safe: `satoriFonts` returns `undefined` rather than `[]` when every font fails, since Satori throws on an empty list but falls back to its bundled Noto Sans when the option is omitted. A plain card beats a 500. With the route verified locally, point `og:image`/`twitter:image` and the BlogPosting `image` at the per-post card, flipping the two TODOs that deliberately waited on this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-post card route was `ƒ` (server-rendered on demand), so Satori markup errors could only surface as a production 500 — which is how the missing `display: flex` on the byline shipped and broke all nine cards. The root route escaped the same class of bug purely because it is prerendered, where such an error fails the build instead. Add `generateStaticParams`, mirroring this segment's `page.tsx`, so every published post's card is generated at build time. The route now reports as `●` and emits nine 1200x630 PNGs into the build output. This turns render-time Satori failures into build failures, drops two uncached Google Fonts round-trips and an MDX read per request on a path crawlers hit, and moves `resolveWebsiteDir()` onto the build's cwd — which is known to resolve — instead of an unverified serverless cwd. Also: - Extract `loadCardFonts` so both routes stop hand-assembling the same font descriptors, and widen `OgFont.weight` to the CSS weight domain so call sites no longer need `as const`. - Add a shared `ogImagePath(slug)` used by both `blog/[slug]/page.tsx` and `blogPostingJsonLd`; the two built the same URL independently and could drift with both suites green. - Drop the two defensive `display: flex` values on single-child divs and scope the comment to the byline, which is the one that was diagnosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Docs and blog headings rendered a literal `#` text node before the heading children, so every extracted heading came out as `#Prerequisites` / `#1. Install the packages` — polluting search snippets, page outlines, and anything summarizing the page from the DOM. The `#` is now CSS generated content on `.heading-anchor::before`, and the anchor is rendered after `{children}`. Extracted text is exactly the heading text, while the permalink stays a real link with its `aria-label` and its place in the tab order. Both MDX heading overrides (MdxRenderer and the choosing-an-adapter page, which had its own copy of the same bug) now share one `mdxHeadingComponents` module, covered by a regression spec asserting heading `textContent` carries no `#`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The heading `#` glyph is now CSS generated content, so the visible permalink affordance hangs entirely on one declaration that jsdom cannot see — it does not resolve pseudo-element content. Add a Playwright assertion in the docs spec that reads `getComputedStyle(el, '::before').content` off the rendered anchor and pins it to `"#"`, alongside a check that the heading text carries no glyph. Verified red by commenting out the rule (received "none"). Also label the permalink with the heading text rather than the slug, so it announces "Link to At a glance" instead of "Link to at-a-glance". Children are frequently nested nodes, so the text is flattened recursively and falls back to the id when nothing can be derived. Correct the module comment to name the real selectors: the CSS is scoped to H2/H3 inside `.docs-prose`, not to `.heading-anchor` generally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Establishes an attributable author for the site: an AboutPage JSON-LD graph whose mainEntity is a Person built from the existing `blogAuthors['brian']` record, referencing the Organization the root layout already mounts. Now that the route exists, blog bylines carry `author.url` pointing at it — deliberately omitted before, when a 404 author URL was the worse signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every BlogPosting byline now carries the same `@id` the /about Person declares, so the two are one entity to a consumer rather than two nodes that merely share a name — which is the point of the attribution work. `knowsAbout` moves onto the Author record beside `bio`: it is a claim about the person, so it belongs with the person rather than with one page that renders them. Also: /about reads the author through `getAuthor()` for its missing-key fallback, and shares the repository URL constant instead of adding a third copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google Search Console's Generative AI report is UI-only, and AI crawlers never execute JavaScript, so the client PostHog snippet cannot observe either signal. Add edge middleware that classifies the request and emits marketing:ai_crawler_visit / marketing:ai_referral_visit. - ai-traffic.ts: pure classifiers. Crawler UAs are matched on published tokens (plain Googlebot/Applebot deliberately excluded); referrers are matched on the parsed hostname exactly or as a subdomain, so lookalikes such as evil-chatgpt.com.attacker.net do not classify. - Capture goes direct to the PostHog ingest host over fetch, not through posthog-node (a Node library; middleware runs on the Edge runtime) and not through the /ingest/* rewrites, which exist for the browser. - Registered with FetchEvent#waitUntil so the response is never delayed, with a 2s abort: a dropped event is acceptable, a hung request is not. - Anonymous by construction: $process_person_profile false, $ip null, pathname only (never the query string), and no UA on referral events. - Crawler events are deduped per crawler/path/hour, best-effort and per-instance, so a crawler looping one URL cannot become a firehose. - Matcher excludes api, _next, ingest, og/twitter image routes and any path whose last segment has an extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…load Both inputs to this feature are attacker-controlled, and the per-key dedup was not an abuse ceiling: varying the path defeats it entirely, and the referral path had no limiter at all. Demonstrated live — 700 requests with a spoofed chatgpt.com Referer produced 700 events. Anyone reading the bundle could bill the PostHog account arbitrarily or poison the dataset this feature exists to produce. - Add a per-instance token bucket on TOTAL emissions (500/hour, burst 500), so the blast radius is bounded regardless of key variety. Chosen well above honest volume: 141 sitemap URLs, and a simultaneous full-site sweep by three crawlers landing on one instance is ~423. Per-instance and best-effort like the dedup: it bounds blast radius, it is not a global quota. - Cover sendToPostHog, where every privacy invariant lives. Asserts the exact wire payload for both event types: $process_person_profile false, $ip null, no query string, no referrer URL, no UA on referral events, and that a caller cannot override the anonymous properties. - Observe /llms.txt, /llms-full.txt, /sitemap.xml and /robots.txt. These are the file written for AI consumers and the strongest crawler-intent signals that exist; losing them to the blunt extension rule undercut the feature's own premise. - Anchor the og/twitter-image exclusion to a segment boundary, so a page named /my-opengraph-image-guide is no longer silently dropped. - Rename middleware.ts to proxy.ts (Next 16 deprecates the middleware file convention). NextProxy is NextMiddleware, so waitUntil is unchanged. This moves the runtime from edge to nodejs, re-verified below. - Dedup key is now JSON.stringify([bucket, crawler, path]): a path may legitimately contain any delimiter, and a collision silently drops an event. Drop keepalive (a browser-unload primitive; waitUntil is what holds the invocation). Drop the unreachable '[::1]' branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts only the proxy.ts rename from 3413824. Every other change in that commit — the emission token bucket, the sendToPostHog payload coverage, the llms.txt/sitemap.xml/robots.txt matchers, the anchored og-image exclusion — is kept. The rename silenced a cosmetic deprecation warning at the cost of moving the runtime from Edge to Node on every request to a public production site. Next 16's proxy convention always runs on Node, so `git mv` was not a file rename but a deployment-shape change: different cold-start and pricing characteristics on Vercel, and unverifiable from a local build. The deprecation is a warning, not a break, and 16.1.7 builds and runs the middleware convention fine. The migration is still worth doing — it additionally makes process.env runtime-read instead of build-inlined, so token rotation would stop needing a redeploy — but it belongs in its own change with a preview deploy behind it, not bundled in as a side effect of a warning fix. A comment on the file records that the warning is deliberate and that the migration is mechanical, because the code uses only Web-standard APIs. Re-verified on Edge after the revert, rather than assumed to carry over from the Node run: chunks back under server/edge/ with all five matchers; a 5s-hanging PostHog left /contact, /llms.txt and /pricing returning 200 in 9-69ms with all three events still delivered; two 700-request floods (spoofed chatgpt.com Referer, and GPTBot across 700 distinct paths) each capped at 501 events; llms.txt, llms-full.txt, sitemap.xml and robots.txt all captured; ordinary traffic, Googlebot, a google.com referrer and the evil-chatgpt.com.attacker.net lookalike all emitted nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every blog post shipped zero images. Add three hand-authored SVG diagrams, one per architecture-heavy post, each drawn from what the post actually explains: - ag-ui-event-flow: the client-tool round trip — the browser ships its tool catalog up, the server streams AG-UI events down, and the tool result the browser produces starts the next run. - langgraph-threads-and-runs: the ACTIVE_THREAD signal as the pivot between the thread list and the active run. - agent-contract-boundary: two runtimes reduced into one neutral contract of Angular signals, with user intent travelling back. Each SVG paints its own surface and carries a <title>/<desc>, so it reads on a light or dark page without depending on the host theme. Colors come from the design tokens' light palette. Add an `img` override to MdxRenderer that emits explicit width/height (so the box is reserved before the file loads, keeping layout shift at zero) plus lazy loading and async decoding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GSC harness README and every generated report already point at this file. Write it. Covers what each source can and cannot answer, the monthly routine over gsc:pull / gsc:report, the AI crawler and referral events emitted from Edge middleware, and the baseline from the first real pull (2026-05-19 → 2026-08-17) so later pulls have something to compare to. Two things it is deliberately blunt about: - The Search Console Generative AI performance report is UI-only. It is not in searchanalytics.query, not a searchAppearance value, and not in the BigQuery export. The only way to read it is by hand, so the doc carries the manual procedure and a running log to paste it into. - Query-dimension totals (528 impressions) are lower than page-dimension totals because Google anonymizes rare queries. Page-level is the true volume. Plus a do-not-do list — llms.txt as a Search tactic, content chunking, AI-specific keyword rewrites, inauthentic mentions — so nobody re-adds them. We keep /llms.txt because some non-Google assistants read it, not because it helps Google. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diagrams were authored at 880 wide. `.docs-prose` computes to 706.56px — `max-width: 70ch` wins over Tailwind's `max-w-none` because it sits later in the built stylesheet — so they rendered at scale 0.803 and put 12.5px labels at 10.0px. On a 375px viewport the column is 323px, scale 0.372, detail text at 4.6px. Unreadable, and never measured. Re-author all three at a 700 viewBox so they render 1:1 in the column, with the type scale raised a step (meta 13px, eyebrow 11.5px). The wider layouts do not survive 700px, so each one goes more vertical: - ag-ui-event-flow: one column of six steps, browser/server marked by a tinted left rule and a corner tag, with the return rail on the left. - langgraph-threads-and-runs: two rows pivoting on ACTIVE_THREAD, edge labels moved above each exchange instead of into the column gap. - agent-contract-boundary: four stacked bands instead of four columns. Below 706px the paragraph scrolls rather than the figure shrinking — the same treatment `.docs-table-scroll` gives a wide table — so labels never scale below their authored size. Verified: the page itself does not gain a horizontal scrollbar at 371px. Also drop the inline `style` from the img override in favour of a `.docs-diagram` class. It duplicated `.docs-prose > p > img`, silently overrode that rule's `margin: 2rem auto`, and being inline would have clobbered any author-supplied `style` on a future markdown image. Per-diagram intrinsic sizes replace the single shared constant now that the three differ in height. Measured effective font size of the smallest text class, in the built page: 11.5px at 1280px wide and 11.5px at 375px (scale 1.0 at both). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bullet sat under a heading covering both AI events, but `shouldEmitCrawlerEvent` is only called on the crawler path in middleware.ts. The 500/hour token bucket is the one bound both share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeps keys/gcp.json (Search Console service account) out of git. Also added to .git/info/exclude so it takes effect in every worktree immediately, not only after this branch merges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
docs/superpowers/plans/2026-08-20-ai-search-optimization.md, built against Google's AI optimization guide.Summary
apps/website/scripts/gsc/) — dependency-free service-account JWT auth, snapshot puller, and analysis report.npm run gsc:pull && npm run gsc:report.lastmod, OpenGraph article metadata, schema.org JSON-LD across every page type, per-post OG images, clean heading extraction./aboutpage with aPersonentity, unified with the blog author across all posts.Fixes a live production bug
/blog/<slug>/opengraph-imagehas been returning 500 on every post since PR #410 — all nine social cards were broken. Root cause was a Satori layout error (a three-child<div>withoutdisplay: flex), not the font issue first suspected; that turned out to be a second, separate defect silently degrading the typeface. The route is now prerendered, so this class of bug fails the build instead of shipping.First real Search Console pull (2026-05-19 → 08-17)
injectagent: 101 impressions, 2.0% CTR at position 5.6 — ranking well, converting badlyDeliberate decisions
Organization.logoomitted — no square brand asset exists in the repo (LogoMark.tsxrenders an emoji). Pointing it at the 1200×630 marketing card would assert something false. One line to restore once a mark exists.proxy.tssilences a deprecation warning but moves the runtime to Node — a deployment-shape change. Deferred to its own PR with a preview deploy; aDEFERRED, DELIBERATELYcomment explains why.What Google says NOT to do — and we didn't
No
llms.txtas a Search tactic, no content chunking, no AI-specific keyword rewrites, no inauthentic mentions./llms.txtis kept only because some non-Google assistants read it.solutions-data.tsnow carries a scaled-content-abuse guardrail.Test plan
cd apps/website && npx vitest run --config vite.config.mts— 307 passed, 5 failednx run website:e2e— 59/59 passednx lint website— 0 errorsnx build website --configuration=production— successnx test posthog-tools— 55/55@idrefs; all 9 posts havedateModified==og:modified_time== sitemap<lastmod>; 141 sitemap URLs all withlastmodand nochangefreq/priority; 9 prerendered OG images, 9 distinct md5sThe 5 failures pre-date this branch — stale content assertions in
thanks/page.spec.tsx,PostCard.spec.tsx,Differentiator.spec.tsx, files this branch never touches. They have never run in CI becauseapps/websitehas no Nxtesttarget (tracked separately). This branch takes the suite from 90 passing to 307.Post-merge, needs a human
/blog/*and/docs/*URL.lastmodsurvived deployment:curl -s https://threadplane.ai/sitemap.xml | grep -c '<lastmod>'. If it returns ~9 instead of 141, Vercel is shallow-cloning and the git-derived dates degraded to blog-only (honest by design, but the win won't land).us.i.posthog.com./aboutprose and theknowsAboutlist — a factual skeleton with no invented credentials, but the connective wording should be yours.compliancevscustomer-supportsolutions pages — they overlap more than boilerplate, and none of the three contain the "real code" the new guardrail demands.The runbook's baseline figures come from a
.gsc/pull that is gitignored, so they are not auditable from the repo.🤖 Generated with Claude Code