From d4c664a6abfd4ee38ba288873d21c42dedf6a415 Mon Sep 17 00:00:00 2001 From: Rob Jacques <2531474+robbyrob42@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:44:44 -0700 Subject: [PATCH 1/2] feat(forensics): structured data on every route, and a dateModified that cannot lie A GEO audit on 2026-09-04 scored the property at 51/100 on the root page and 43.25/100 across all eight routes, both in the tool's "foundation" band. The measured categories tell a clear story: meta was perfect at 14/14 on every page, robots allowed all 27 AI crawlers, and the raw HTML carried 777 words and 19 headings without JavaScript. What was missing was structured data (2.25/16), llms.txt (0/18) and AI discovery endpoints (0/6). Structured data, on every page rather than only the root. The previous convention emitted Organization on the home page alone, reasoning that it describes the property rather than the page and that repeating it five times states one fact five times. That is right about authorship and wrong about retrieval: an answer engine parses each URL independently and cannot carry the root page's entity across to /method. Six of eight routes emitted nothing. The answer is a per-page @graph with stable @ids, not copy-paste. The Organization is one entity referenced from every route, which satisfies the original objection properly -- one fact, stated once, addressed by identity -- while giving each page its own primary entity. Pages that support more say more: ContactPage on /scope, CollectionPage plus an ItemList roster on /experts, ProfilePage plus Person on a profile, FAQPage on /engagements built from the copy deck's existing questions. makesOffer rides only on the two pages that actually display the rate card. dateModified is derived from git rather than hand-kept. A date written by hand is a claim nobody verifies, and a page that says it was updated last week when it was not is the kind of small untruth this property avoids everywhere else. scripts/content-modified.mjs records the last commit touching each route's sources before the build; a route with no recorded date emits no dateModified at all rather than a fabricated one. The deploy workflow now checks out full history, because git log on a shallow clone reports the shallow boundary and every page would claim the date of the most recent push. llms.txt, /ai/summary.json and /ai/faq.json are generated from the copy deck for the same reason knowsAbout is: a hand-kept second copy of the site's structure goes stale the first time a route changes. /.well-known/ai.txt states the crawling posture explicitly and disallows /api/, which carries live matter detail written before a conflict check has run. Two things deliberately not done. No SearchAction in the WebSite node -- the property has no search endpoint and declaring one that 404s is a claim the site cannot honour. No areaServed, still: the copy nowhere states which jurisdictions this practice takes, and that remains a copy decision the markup follows rather than leads. sameAs is wired but empty, on both Organization and Person, and emitted only when populated. It is the Knowledge Graph disambiguation signal and the audit scored Social Trust 0/5 and Academic Trust 0/5 on its absence, but every URL has to be a profile that exists and that the practice controls -- a sameAs pointing at something that is not the same entity merges two entities in the index. The fields are waiting on real URLs. astro check goes from 4 errors to 2; the remaining two are pre-existing parser complaints in scope.astro. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EDibtmcGWcytbgVpb2su7F --- .github/workflows/deploy.yml | 8 + .gitignore | 2 + sites/forensics/package.json | 8 +- sites/forensics/public/.well-known/ai.txt | 25 +++ sites/forensics/scripts/content-modified.mjs | 107 +++++++++ sites/forensics/src/copy/landing.ts | 15 ++ sites/forensics/src/copy/schema.ts | 28 +++ sites/forensics/src/layouts/Layout.astro | 127 ++++++----- sites/forensics/src/lib/modified.ts | 48 ++++ sites/forensics/src/lib/schema.ts | 205 ++++++++++++++++++ sites/forensics/src/pages/ai/faq.json.ts | 32 +++ sites/forensics/src/pages/ai/summary.json.ts | 63 ++++++ sites/forensics/src/pages/engagements.astro | 13 ++ .../forensics/src/pages/experts/[slug].astro | 34 ++- sites/forensics/src/pages/experts/index.astro | 29 +++ sites/forensics/src/pages/index.astro | 2 +- sites/forensics/src/pages/llms.txt.ts | 70 ++++++ sites/forensics/src/pages/privacy.astro | 26 +-- sites/forensics/src/pages/scope.astro | 1 + 19 files changed, 748 insertions(+), 95 deletions(-) create mode 100644 sites/forensics/public/.well-known/ai.txt create mode 100644 sites/forensics/scripts/content-modified.mjs create mode 100644 sites/forensics/src/lib/modified.ts create mode 100644 sites/forensics/src/lib/schema.ts create mode 100644 sites/forensics/src/pages/ai/faq.json.ts create mode 100644 sites/forensics/src/pages/ai/summary.json.ts create mode 100644 sites/forensics/src/pages/llms.txt.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ca84933..7eef3b2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -72,8 +72,16 @@ jobs: workspace: '@rootsystem/forensics' changed: ${{ needs.changes.outputs.forensics }} steps: + # Full history, not the default shallow clone. The forensics build derives + # `dateModified` for each route from the last commit that touched its + # sources (sites/forensics/scripts/content-modified.mjs). On a depth-1 + # clone `git log` reports the shallow boundary instead, so every page + # would claim it was modified on the day of the most recent push -- + # a freshness signal that is wrong rather than merely absent. - uses: actions/checkout@v5 if: matrix.changed == 'true' + with: + fetch-depth: 0 - uses: jdx/mise-action@v3 if: matrix.changed == 'true' diff --git a/.gitignore b/.gitignore index 4e3644d..613affd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ .claude/ # Build Output +# Route dates derived from git history; rewritten on every build. +sites/forensics/src/generated/ out/ dist/ .astro/ diff --git a/sites/forensics/package.json b/sites/forensics/package.json index db1ffd9..09b99e7 100644 --- a/sites/forensics/package.json +++ b/sites/forensics/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "dev": "node scripts/content-modified.mjs && astro dev", + "build": "node scripts/content-modified.mjs && astro build", "preview": "astro preview", - "check": "astro check", - "deploy": "astro build && wrangler deploy" + "check": "node scripts/content-modified.mjs && astro check", + "deploy": "yarn build && wrangler deploy" }, "dependencies": { "@astrojs/cloudflare": "^14.1.5", diff --git a/sites/forensics/public/.well-known/ai.txt b/sites/forensics/public/.well-known/ai.txt new file mode 100644 index 0000000..fd66c27 --- /dev/null +++ b/sites/forensics/public/.well-known/ai.txt @@ -0,0 +1,25 @@ +# forensics.rootsystem.com — AI crawler and usage policy +# +# Crawling is open, and deliberately so. This property exists to be found and +# read by counsel evaluating an expert, and an answer engine summarising it +# accurately is doing the same job the site does. The permissive posture here +# matches robots.txt, which is Allow: / for every agent. +# +# ai.txt is a convention rather than a standard. It is stated here so the +# position is explicit rather than inferred from the absence of a file. + +User-agent: * +Allow: / + +# Training on the published pages is permitted. Everything served here is +# marketing and credential material intended for publication. +Allow-training: yes + +# Nothing under /api/ is public. Case intake carries live matter detail written +# before a conflict check has run, and it is not crawlable, not indexable and +# not available for training. +Disallow: /api/ + +Contact: partners@rootsystem.com +Sitemap: https://forensics.rootsystem.com/sitemap-index.xml +Summary: https://forensics.rootsystem.com/ai/summary.json diff --git a/sites/forensics/scripts/content-modified.mjs b/sites/forensics/scripts/content-modified.mjs new file mode 100644 index 0000000..9bd9053 --- /dev/null +++ b/sites/forensics/scripts/content-modified.mjs @@ -0,0 +1,107 @@ +/** + * Derives a `dateModified` per route from git history, before the Astro build. + * + * Why git rather than a hand-kept date: a date written by hand is a claim + * nobody verifies, and the first time it goes stale it is worse than absent -- + * answer engines weight freshness, and a page that says it was updated last + * week when it was not is the kind of small untruth this property avoids + * everywhere else. Git already records when a page's inputs changed, so the + * date cannot drift from the work. + * + * ASSUMPTION, stated because it is the weak point: a route's inputs are its + * page file plus the copy deck it renders. The deck is one file for the whole + * property, so a copy edit to any section moves the date on every route that + * reads the deck. That is coarse but not false -- schema.org defines + * dateModified as when the work was most recently modified, and a change to a + * page's source inputs is a modification of that page whether or not the + * visible bytes moved. It errs toward "something that feeds this page changed", + * never toward a date nothing supports. + * + * The output is generated, gitignored, and rewritten on every build. It is + * imported as plain JSON so nothing in the page pipeline shells out to git at + * render time. + * + * FALLBACK BEHAVIOUR MATTERS IN CI. `actions/checkout` clones shallow by + * default, and `git log` on a shallow clone reports the shallow boundary rather + * than the real last-touch commit. The deploy workflow sets `fetch-depth: 0` + * for that reason. If git is unavailable or a path has no history at all, the + * route falls back to the HEAD commit date, and finally to the build time -- + * a build never fails over this, it just gets less precise. + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const siteRoot = resolve(here, '..') +const repoRoot = resolve(siteRoot, '../..') +const outputPath = resolve(siteRoot, 'src/generated/content-modified.json') + +/** + * Routes and the source paths that determine their content. + * + * Paths are repo-relative because that is what `git log` wants. Every route + * includes the copy deck: the deck is where the words live, and a route whose + * words changed has been modified regardless of whether its .astro file did. + */ +const COPY_DECK = 'sites/forensics/src/copy/landing.ts' + +const ROUTES = { + '/': ['sites/forensics/src/pages/index.astro', COPY_DECK], + '/method/': ['sites/forensics/src/pages/method.astro', COPY_DECK], + '/matters/': ['sites/forensics/src/pages/matters.astro', COPY_DECK], + '/engagements/': ['sites/forensics/src/pages/engagements.astro', COPY_DECK], + '/experts/': ['sites/forensics/src/pages/experts/index.astro', COPY_DECK], + '/experts/[slug]/': ['sites/forensics/src/pages/experts/[slug].astro', COPY_DECK], + '/scope/': ['sites/forensics/src/pages/scope.astro', COPY_DECK], + // The privacy policy is shared with sites/www and lives outside this site. + '/privacy/': ['sites/forensics/src/pages/privacy.astro', 'legal/privacy-policy.ts'], +} + +/** Runs git and returns trimmed stdout, or null if git is unusable here. */ +function git(args) { + try { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return null + } +} + +/** + * The most recent commit date across a set of paths, as an ISO 8601 string. + * + * `--` separates paths from revisions so a path that looks like a ref cannot be + * misread. `%cI` is the committer date in strict ISO 8601, which is what + * schema.org wants and what answer engines parse without guessing a timezone. + */ +function lastModified(paths) { + const result = git(['log', '-1', '--format=%cI', '--', ...paths]) + return result || null +} + +const headDate = git(['log', '-1', '--format=%cI']) || new Date().toISOString() + +const modified = Object.fromEntries( + Object.entries(ROUTES).map(([route, paths]) => [ + route, + lastModified(paths) || headDate, + ]), +) + +// `generatedAt` is recorded for debugging a stale build, not for publication -- +// nothing renders it. Publishing a build timestamp as dateModified is exactly +// the untruth this file exists to prevent. +const payload = { generatedAt: new Date().toISOString(), routes: modified } + +mkdirSync(dirname(outputPath), { recursive: true }) +writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`) + +console.log( + `content-modified: wrote ${Object.keys(modified).length} route dates ` + + `(head ${headDate})`, +) diff --git a/sites/forensics/src/copy/landing.ts b/sites/forensics/src/copy/landing.ts index 9a92848..b1444f3 100644 --- a/sites/forensics/src/copy/landing.ts +++ b/sites/forensics/src/copy/landing.ts @@ -612,6 +612,21 @@ const copy: Landing = landingSchema.parse({ // answer (NOTIFY_CC in wrangler.jsonc). responseTime: 'We reply within one business day.', }, + + // Structured-data identity. Nothing here renders as visible copy. + // + // EMPTY AND WAITING ON REAL URLS. `sameAs` should list canonical profiles + // that are demonstrably this practice -- a LinkedIn company page, a + // Crunchbase entry, a professional-society listing. It is the Knowledge Graph + // disambiguation signal and the cheapest trust signal available, but every + // entry has to be a page that exists and that the practice controls: a + // `sameAs` pointing at something that is not the same entity merges two + // entities in the index, which is worse than saying nothing. + // + // The same field exists per person on each expert profile below. + organization: { + sameAs: [], + }, }) export default copy diff --git a/sites/forensics/src/copy/schema.ts b/sites/forensics/src/copy/schema.ts index 9e15ca6..f1771ba 100644 --- a/sites/forensics/src/copy/schema.ts +++ b/sites/forensics/src/copy/schema.ts @@ -308,6 +308,16 @@ export const landingSchema = z.object({ publications: z.array( z.object({ title: z.string(), where: z.string(), note: z.string() }), ), + // Canonical profiles for this person elsewhere -- LinkedIn, ORCID, + // Google Scholar, a licensing board's public lookup. Feeds `sameAs` in + // the Person markup, which is how a search engine resolves a name to + // one person rather than to everyone who shares it. For an expert + // witness that disambiguation is also a vetting convenience: counsel + // searching the name should land on the record, not on a namesake. + // + // Empty by default and emitted only when populated. Nothing goes in + // here that the practice does not control and cannot verify. + sameAs: z.array(z.url()).default([]), }), ) .min(1), @@ -335,6 +345,24 @@ export const landingSchema = z.object({ footer: z.string(), responseTime: z.string(), }), + + // Entity identity, for structured data only -- nothing here renders as copy. + // + // `sameAs` is the Knowledge Graph disambiguation signal: the list of other + // canonical URLs that are demonstrably the same entity, which is what lets a + // search engine resolve this practice to one thing instead of guessing. A GEO + // audit on 2026-09-04 scored Social Trust 0/5 and Academic Trust 0/5 on the + // absence of it, and it is the cheapest E-E-A-T signal available. + // + // Defaults to empty and is emitted only when populated. Every URL here must + // be a profile that exists and is controlled by the practice -- a `sameAs` + // pointing at a page that is not the same entity is worse than none, because + // it merges two entities in the index. + organization: z + .object({ + sameAs: z.array(z.url()).default([]), + }) + .default({ sameAs: [] }), }) export type Landing = z.infer diff --git a/sites/forensics/src/layouts/Layout.astro b/sites/forensics/src/layouts/Layout.astro index 0a99bd5..61c20b0 100644 --- a/sites/forensics/src/layouts/Layout.astro +++ b/sites/forensics/src/layouts/Layout.astro @@ -20,6 +20,8 @@ import Masthead from '../components/Masthead.astro' import SiteFooter from '../components/SiteFooter.astro' import copy from '../copy/landing' import { assertPublishable } from '../copy/schema' +import { lastModifiedFor } from '../lib/modified' +import { graph, organizationNode, webPageNode, websiteNode } from '../lib/schema' // Enforced here rather than per page: with five routes, a gate that only one // route runs is not a gate -- a spoke could ship draft copy through a green @@ -33,70 +35,70 @@ const DESIGN_DIRECTION: 'a' | 'b' = 'a' interface Props { title: string description: string + /** + * A more specific schema.org page type than WebPage where one fits -- + * ContactPage for the intake form, CollectionPage for an index. + */ + pageType?: string + /** + * True only on pages that actually display the rate card. The Organization + * node carries `makesOffer` on those and nowhere else, so the markup never + * claims an offer the visible page does not state. + */ + publishesRates?: boolean + /** Extra JSON-LD nodes merged into this page's graph. */ + schemaNodes?: object[] + /** `@id` of the node this page is primarily about, if not the Organization. */ + aboutId?: string } -const { title, description } = Astro.props +const { + title, + description, + pageType, + publishesRates = false, + schemaNodes = [], + aboutId, +} = Astro.props const canonical = new URL(Astro.url.pathname, Astro.site) const ogImage = new URL('/images/forensics-card.png', Astro.site) /* - * Structured data, home page only -- the same convention sites/www follows. - * Organization markup describes the property, not the page, so repeating it on - * five routes states one fact five times. + * Structured data, on every page rather than only the root. * - * `knowsAbout` is derived from the copy deck rather than written out here. The - * practice areas and the bench disciplines are the answer to "what is this - * practice competent in", and they already live in one place; a second hand-kept - * list would drift from the visible page the first time either is edited. This - * is the single highest-value field on the property for the queries that matter - * -- "[discipline] expert witness" -- and it costs nothing to keep true. + * The previous convention emitted Organization on the home page alone, on the + * reasoning that it describes the property rather than the page. That is right + * about authorship and wrong about retrieval: an answer engine parses each URL + * independently and does not carry the root page's entity across to /method. A + * 2026-09-04 GEO audit measured the cost -- schema scored 2.25 of 16 across the + * property because six of eight routes emitted nothing at all. * - * `@type: Organization` rather than ProfessionalService. ProfessionalService - * inherits from LocalBusiness, which expects a postal address for rich results, - * and this practice has no public one to give. - * - * `makesOffer` carries the published rates. Publishing them at all is a - * deliberate divergence from every close comparable, so the markup should say - * so rather than leave a differentiator invisible to a crawler. - * - * Deliberately absent: `areaServed`. The page nowhere states which - * jurisdictions this practice takes, and inventing one in markup that the copy - * does not support is exactly the drift this block is built to avoid. It is a - * copy decision first; the field follows it. + * The graph in src/lib/schema.ts answers the original objection properly. Nodes + * carry stable `@id`s, so the Organization is one entity referenced from every + * route rather than eight unrelated copies of one fact. Per-node reasoning -- + * why Organization and not ProfessionalService, why `knowsAbout` is derived, + * why `areaServed` is still absent -- lives in that file with the code it + * governs. */ -const isHome = Astro.url.pathname === '/' - -const knowsAbout = [ - ...copy.practiceAreas.areas.map((area) => area.name), - ...copy.bench.groups.flatMap((group) => group.disciplines), +const routeModified = lastModifiedFor(Astro.url.pathname) + +const nodes = [ + webPageNode({ + url: canonical, + name: title, + description, + dateModified: routeModified, + ...(pageType && { type: pageType }), + ...(aboutId && { aboutId }), + }), + websiteNode({ copy, site: new URL(Astro.site!) }), + organizationNode({ + copy, + site: new URL(Astro.site!), + withOffers: publishesRates, + }), + ...schemaNodes, ] - -const organization = { - '@context': 'https://schema.org', - '@type': 'Organization', - name: 'Root System Forensics', - url: 'https://forensics.rootsystem.com', - logo: new URL('/images/favicon/light/apple-touch-icon.png', Astro.site).href, - image: ogImage.href, - description: copy.meta.description, - email: copy.contact.email, - parentOrganization: { - '@type': 'Organization', - name: 'Root System', - url: 'https://rootsystem.com', - }, - knowsAbout, - makesOffer: copy.pricing.tiers.map((tier) => ({ - '@type': 'Offer', - name: tier.name, - description: tier.summary, - priceSpecification: { - '@type': 'PriceSpecification', - price: tier.price, - priceCurrency: 'USD', - }, - })), -} --- @@ -135,18 +137,15 @@ const organization = { - {/* Per-route head content. The expert profiles use it for their Person - markup, which belongs on the profile and nowhere else. A page passing - nothing renders nothing -- but note that content addressed to a slot - that does not exist is dropped silently, which is how the first version - of the profile markup went missing. */} + {/* Per-route head content. A page passing nothing renders nothing -- but + note that content addressed to a slot that does not exist is dropped + silently, which is how the first version of the profile markup went + missing. Structured data no longer travels through here; it goes in the + page's graph below via the `schemaNodes` prop, so that nodes can + reference each other by `@id`. */} - { - isHome && ( -