From 23ce92597ce4460b33161e578cf12fc2ea707db8 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Fri, 23 Oct 2026 18:55:11 +0000 Subject: [PATCH 1/2] Improve agent discoverability and add trust pages --- site/README.md | 36 ++++++++++- site/astro.config.mjs | 2 +- site/e2e/agent-readiness.spec.ts | 81 +++++++++++++++++++++++++ site/package.json | 3 +- site/scripts/verify-readiness.mjs | 65 ++++++++++++++++++++ site/src/components/Footer.astro | 5 +- site/src/components/NotFoundContent.tsx | 5 +- site/src/components/TrustPage.astro | 11 ++++ site/src/layouts/Base.astro | 5 +- site/src/lib/organization.ts | 12 ++-- site/src/lib/site.ts | 20 ++++++ site/src/pages/about.astro | 8 +++ site/src/pages/contact.astro | 12 ++++ site/src/pages/index.astro | 15 ++--- site/src/pages/privacy.astro | 9 +++ site/src/pages/race/[slug].astro | 3 +- site/src/pages/robots.txt.ts | 2 +- site/src/pages/sitemap.xml.ts | 6 +- 18 files changed, 274 insertions(+), 26 deletions(-) create mode 100644 site/scripts/verify-readiness.mjs create mode 100644 site/src/components/TrustPage.astro create mode 100644 site/src/lib/site.ts create mode 100644 site/src/pages/about.astro create mode 100644 site/src/pages/contact.astro create mode 100644 site/src/pages/privacy.astro diff --git a/site/README.md b/site/README.md index 5789d4e..03bd99c 100644 --- a/site/README.md +++ b/site/README.md @@ -19,6 +19,7 @@ npx playwright install chromium npm run e2e # desktop and mobile against the built site npm run test:design # both themes, Chromium/Firefox/WebKit, desktop + phones npm run verify:assets # enforce Cloudflare Free file count and size limits +npm run verify:readiness # GET/byte-compare every built endpoint against local preview npm run audit:prepare # build the original application's data artifacts npm run audit # compare the entire corpus against the original app npm run benchmark # repeated throttled browser measurements @@ -74,6 +75,39 @@ Organization JSON-LD uses the existing feedback form. The owner's postal address is intentionally omitted; the audit's complete Organization check will therefore remain unmet. Set the build variable `PUBLIC_CONTACT_EMAIL` only after a dedicated address can receive mail. No placeholder email is published. To create -`support@tritimes.org`, provision a mailbox or configure Cloudflare Email Routing +`contact@tritimes.org`, provision a mailbox or configure Cloudflare Email Routing with a verified destination inbox and the required DNS records, then verify receipt. Email provisioning requires mail/DNS access and (for forwarding) a destination inbox. + +## Agent readiness + +The public origin is `https://www.tritimes.org`, matching production's existing +apex redirect. Canonicals, Open Graph URLs, structured data, robots and sitemap +use that origin. The homepage names the product, and `/about`, `/contact`, and +`/privacy` are static pages linked from every footer and the sitemap. +`src/lib/site.ts` holds the Organization description and verified feedback URL. +The owner elected to omit a phone number and postal address, so the audit's +PostalAddress recommendation is deliberately unresolved. Do not invent an +address or publish an email before delivery is verified. + +For a new `contact@tritimes.org` forwarding address, an administrator needs +Cloudflare Email Routing access and a verified destination inbox. Check existing +mail DNS before enabling routing; preserve any active mail provider. After the +route is active, verify delivery, then set `PUBLIC_CONTACT_EMAIL` at build time to publish it on the Contact +page and in Organization `contactPoint.email`. +No email address has been provisioned by this change. + +After `npm run build`, start `npm run preview`, then run `npm run verify:readiness`. +It fetches every built public file, compares served bytes to the build, parses +all JSON-LD and JSON, decompresses all gzip data/search files, and checks shell +rewrites and true 404 responses. `READINESS_URL=https://… npm run verify:readiness` +checks the same build against a deployment. The browser readiness tests also +parse the sitemap as XML and verify the trust pages without JavaScript. + +Deployment uses the existing workflow and requires Cloudflare credentials. +After deploying, repeat verification against the live origin and rescan +[Is Agentic](https://is-agentic.com/scan/tritimes.org). Compare `scanned_at` because +the service may return a stored report. Search indexing, brand-query rankings, +and earned links require time and external work; metadata alone cannot guarantee +a top-ten result. Search Console verification and sitemap submission require an +owner's verified account. diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 9e967d4..277ca79 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -7,7 +7,7 @@ const manifest = new URL("./.cache/search-partitions.json", import.meta.url); const fineSearchBuckets = fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, "utf8")) : []; export default defineConfig({ - site: "https://tritimes.org", + site: "https://www.tritimes.org", // /race/foo → race/foo.html, which Cloudflare serves at /race/foo with no // trailing-slash redirect (matching the current URLs). build: { format: "file" }, diff --git a/site/e2e/agent-readiness.spec.ts b/site/e2e/agent-readiness.spec.ts index 4afd4f0..0586b7e 100644 --- a/site/e2e/agent-readiness.spec.ts +++ b/site/e2e/agent-readiness.spec.ts @@ -1,4 +1,85 @@ import { test, expect } from "@playwright/test"; +import { SITE_URL, FEEDBACK_URL, SITE_DESCRIPTION } from "../src/lib/site"; + +for (const [route, title] of [["/about", "About TriTimes"], ["/contact", "Contact TriTimes"], ["/privacy", "TriTimes Privacy"]]) { + test(`${route} is a substantive, linked trust page without JavaScript`, async ({ browser, baseURL }) => { + const context = await browser.newContext({ baseURL, javaScriptEnabled: false }); + const page = await context.newPage(); + expect((await page.goto(route))?.status()).toBe(200); + await expect(page).toHaveTitle(`${title} | TriTimes`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(title); + expect((await page.locator("main").innerText()).length).toBeGreaterThanOrEqual(500); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `${SITE_URL}${route}`); + for (const [label, href] of [["About", "/about"], ["Contact", "/contact"], ["Privacy", "/privacy"]]) { + await expect(page.getByRole("navigation", { name: "Footer navigation" }).getByRole("link", { name: label, exact: true })).toHaveAttribute("href", href); + } + const schema = JSON.parse(await page.locator('script[type="application/ld+json"]').first().innerText()); + const organization = schema.find((item: { "@type": string }) => item["@type"] === "Organization"); + expect(organization.description).toBe(SITE_DESCRIPTION); + expect(organization.url).toBe(SITE_URL); + expect(organization).not.toHaveProperty("address"); + expect(organization.contactPoint).not.toHaveProperty("telephone"); + expect(organization.contactPoint).toMatchObject({ "@type": "ContactPoint", contactType: "customer support", url: FEEDBACK_URL }); + if (route === "/contact") await expect(page.getByRole("link", { name: "TriTimes feedback form", exact: true })).toHaveAttribute("href", FEEDBACK_URL); + // Footer links must wrap at mobile widths rather than overflow. + await page.setViewportSize({ width: 320, height: 800 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await context.close(); + }); +} + +test("homepage connects the brand to triathlon results and its publisher", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/TriTimes.*Triathlon Results/); + await expect(page.getByRole("heading", { level: 1 })).toContainText("TriTimes triathlon results"); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `${SITE_URL}/`); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute("content", `${SITE_URL}/`); + const schema = JSON.parse(await page.locator('script[type="application/ld+json"]').first().innerText()); + const website = schema.find((item: { "@type": string }) => item["@type"] === "WebSite"); + const organization = schema.find((item: { "@type": string }) => item["@type"] === "Organization"); + expect(website.url).toBe(SITE_URL); + expect(website.publisher["@id"]).toBe(organization["@id"]); +}); + +test("robots and XML sitemap advertise canonical trust URLs", async ({ request, page }) => { + const robots = await request.get("/robots.txt"); + expect(robots.status()).toBe(200); + expect(robots.headers()["content-type"]).toContain("text/plain"); + expect(await robots.text()).toContain(`Sitemap: ${SITE_URL}/sitemap.xml`); + // Preserve the existing distinction between inference and training crawlers. + expect(await robots.text()).toContain("User-Agent: *\nAllow: /"); + expect(await robots.text()).toContain("User-Agent: GPTBot\nDisallow: /"); + const sitemap = await request.get("/sitemap.xml"); + expect(sitemap.status()).toBe(200); + expect(sitemap.headers()["content-type"]).toContain("xml"); + const xml = await sitemap.text(); + const locations = await page.evaluate((source) => { + const doc = new DOMParser().parseFromString(source, "application/xml"); + if (doc.querySelector("parsererror")) throw new Error("Invalid XML sitemap"); + if (doc.documentElement.namespaceURI !== "http://www.sitemaps.org/schemas/sitemap/0.9") throw new Error("Wrong sitemap namespace"); + return Array.from(doc.querySelectorAll("loc"), (node) => node.textContent); + }, xml); + for (const route of ["/", "/about", "/contact", "/privacy", "/faq"]) expect(locations).toContain(`${SITE_URL}${route}`); + expect(locations.every((loc) => loc?.startsWith(`${SITE_URL}/`))).toBe(true); +}); + +test("result and athlete metadata use the canonical production hostname", async ({ page }) => { + for (const [route, name] of [["/athlete/rootul-patel--us-m", /Rootul Patel/], ["/race/im-arizona-2025/result/0", /Menno Koolhaas/]] as const) { + await page.goto(route); + await expect(page.getByRole("heading", { level: 1 })).toContainText(name); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `${SITE_URL}${route}`); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute("content", `${SITE_URL}${route}`); + } +}); + + +test("race structured data identifies its canonical URL", async ({ page }) => { + await page.goto("/race/im-arizona-2025"); + const schema = JSON.parse(await page.locator('script[type="application/ld+json"]').first().innerText()); + const race = schema.find((item: { "@type": string }) => item["@type"] === "SportsEvent"); + expect(race.url).toBe(`${SITE_URL}/race/im-arizona-2025`); +}); + test("homepage content and heading hierarchy remain available without JavaScript", async ({ browser, baseURL }) => { const context = await browser.newContext({ javaScriptEnabled: false, baseURL }); diff --git a/site/package.json b/site/package.json index 3b5c85f..75f0fb8 100644 --- a/site/package.json +++ b/site/package.json @@ -24,7 +24,8 @@ "performance:report": "node scripts/summarize-performance.mjs", "verify:deployment": "node scripts/verify-deployment.mjs", "test:agents": "node --test --test-isolation=none tests/*.test.ts", - "verify:agents": "node scripts/verify-agents.mjs" + "verify:agents": "node scripts/verify-agents.mjs", + "verify:readiness": "node scripts/verify-readiness.mjs" }, "dependencies": { "@astrojs/preact": "^6.1.1", diff --git a/site/scripts/verify-readiness.mjs b/site/scripts/verify-readiness.mjs new file mode 100644 index 0000000..ee48971 --- /dev/null +++ b/site/scripts/verify-readiness.mjs @@ -0,0 +1,65 @@ +// Verify every published file through the static runtime, including decoded +// machine-readable files and JSON-LD. Run after build with READINESS_URL set. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { gunzipSync } from "node:zlib"; + +const base = new URL(process.env.READINESS_URL || "http://127.0.0.1:8787"); +assert(base.protocol === "https:" || ["localhost", "127.0.0.1"].includes(base.hostname), "Use HTTPS except for local verification"); +const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; +}); +const files = walk("dist").filter((file) => !["_headers", "_redirects"].includes(path.basename(file)) && file !== "dist/404.html"); +let next = 0; +let checked = 0; +let jsonLdEntities = 0; +let gzipFiles = 0; +const startedAt = new Date().toISOString(); +await Promise.all(Array.from({ length: 8 }, async () => { + while (next < files.length) { + const file = files[next++]; + const relative = file.slice(5); + const route = "/" + (relative === "index.html" ? "" : relative.replace(/\.html$/, "")); + const response = await fetch(new URL(route, base), { signal: AbortSignal.timeout(30000) }); + assert.equal(response.status, 200, route); + const bytes = Buffer.from(await response.arrayBuffer()); + assert.deepEqual(bytes, fs.readFileSync(file), `${route}: served bytes differ from the build`); + const type = response.headers.get("content-type") || ""; + if (file.endsWith(".html")) { + assert.match(type, /text\/html/, route); + const html = bytes.toString(); + const entities = [...html.matchAll(/]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)] + .flatMap((match) => JSON.parse(match[1])); + const organization = entities.filter((entity) => entity["@type"] === "Organization"); + assert.equal(organization.length, 1, `${route}: one Organization per page`); + assert(organization[0].description.length > 50, `${route}: Organization description`); + assert.equal(organization[0].url, "https://www.tritimes.org", `${route}: canonical organization URL`); + jsonLdEntities += entities.length; + } else if (file.endsWith(".gz")) { + assert.match(type, /application\/gzip/, route); + const decoded = gunzipSync(bytes).toString(); + if (file.endsWith(".json.gz")) JSON.parse(decoded); + else if (file.endsWith(".tsv.gz")) { + for (const line of decoded.trimEnd().split("\n")) assert(!line || line.includes("\t"), `${route}: invalid TSV row`); + } + gzipFiles++; + } else if (file.endsWith(".json")) JSON.parse(bytes.toString()); + else if (file.endsWith(".xml")) assert.match(type, /xml/, route); + else if (file.endsWith(".txt")) assert.match(type, /text\/plain/, route); + if (++checked % 2000 === 0) console.log(`Verified ${checked}/${files.length} endpoints`); + } +})); +for (const route of ["/athlete/rootul-patel--us-m", "/race/im-arizona-2025/result/0"]) { + const response = await fetch(new URL(route, base)); + assert.equal(response.status, 200, route); + assert.match(response.headers.get("content-type") || "", /text\/html/, route); +} +for (const route of ["/no-such-page", "/race/no-such-race", "/courses/no-such-course"]) { + assert.equal((await fetch(new URL(route, base))).status, 404, route); +} +const summary = { base: base.href, startedAt, finishedAt: new Date().toISOString(), endpoints: checked + 5, gzipFiles, jsonLdEntities, allPassed: true }; +fs.mkdirSync("artifacts", { recursive: true }); +fs.writeFileSync("artifacts/agent-readiness.json", JSON.stringify(summary, null, 2) + "\n"); +console.log(JSON.stringify(summary, null, 2)); diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index ffe1583..8aab1e6 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -1,7 +1,10 @@