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..b90bb70 100644 --- a/sites/forensics/src/copy/landing.ts +++ b/sites/forensics/src/copy/landing.ts @@ -592,6 +592,22 @@ const copy: Landing = landingSchema.parse({ note: 'With Process Metrix/Insitec Measurement Systems, SEMATECH and Sandia National Laboratories.', }, ], + // Canonical profiles elsewhere, feeding `sameAs` in the Person markup. + // Both were verified before being published here: the ORCID record + // resolves to Rob Jacques through the public API, and the LinkedIn + // vanity URL is the one the profile copy was written from. + // + // ORCID is listed as the resolvable https:// form rather than the bare + // identifier, because `sameAs` takes URLs and a bare ORCID is not one. + // + // No Google Scholar entry: a Scholar profile is a researcher page a + // person claims at scholar.google.com, and there is no reason to create + // one around a single conference paper from 2000. If the practice starts + // publishing, that changes. + sameAs: [ + 'https://www.linkedin.com/in/robertjacques1/', + 'https://orcid.org/0009-0005-3159-3392', + ], }, ], @@ -612,6 +628,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 && ( -