Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
81 changes: 81 additions & 0 deletions site/e2e/agent-readiness.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions site/scripts/verify-readiness.mjs
Original file line number Diff line number Diff line change
@@ -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(/<script[^>]*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));
5 changes: 4 additions & 1 deletion site/src/components/Footer.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<footer class="border-t border-line py-8 mt-auto">
<div class="max-w-6xl mx-auto px-4 flex flex-col sm:flex-row sm:items-center justify-between gap-5">
<div><a class="font-semibold tracking-tight" href="/">TriTimes</a><p class="text-sm text-muted mt-1">A clearer picture of every finish.</p></div>
<nav aria-label="Footer navigation" class="flex items-center gap-6">
<nav aria-label="Footer navigation" class="flex flex-wrap items-center gap-x-6 gap-y-3">
<a href="/about" class="text-sm text-secondary hover:text-foreground transition-colors">About</a>
<a href="/contact" class="text-sm text-secondary hover:text-foreground transition-colors">Contact</a>
<a href="/privacy" class="text-sm text-secondary hover:text-foreground transition-colors">Privacy</a>
<a href="/stats" class="text-sm text-secondary hover:text-foreground transition-colors">Stats</a>
<a href="/faq" class="text-sm text-secondary hover:text-foreground transition-colors">FAQ</a>
<a
Expand Down
5 changes: 3 additions & 2 deletions site/src/components/NotFoundContent.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SITE_URL } from "@/lib/site";
import GlobalSearchBar from "./GlobalSearchBar";

/** Body of the 404 page; also shown by the result/athlete pages for unknown URLs. */
Expand Down Expand Up @@ -49,12 +50,12 @@ export function setPageMeta(title: string, description: string, canonical: strin
set('meta[name="description"]', "content", description);
set('meta[property="og:title"]', "content", title);
set('meta[property="og:description"]', "content", description);
set('meta[property="og:url"]', "content", `https://tritimes.org${canonical}`);
set('meta[property="og:url"]', "content", `${SITE_URL}${canonical}`);
let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = document.createElement("link");
link.rel = "canonical";
document.head.appendChild(link);
}
link.href = `https://tritimes.org${canonical}`;
link.href = `${SITE_URL}${canonical}`;
}
11 changes: 11 additions & 0 deletions site/src/components/TrustPage.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
import Base from "@/layouts/Base.astro";
interface Props { title: string; description: string; canonical: string }
const { title, description, canonical } = Astro.props;
---
<Base title={title} description={description} canonical={canonical}>
<main id="main-content" class="max-w-3xl w-full mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-foreground mb-8">{title}</h1>
<div class="space-y-6 text-secondary leading-relaxed"><slot /></div>
</main>
</Base>
5 changes: 3 additions & 2 deletions site/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import "@/styles/global.css";
import Header from "@/components/Header.astro";
import Footer from "@/components/Footer.astro";
import { SITE_URL } from "@/lib/site";
import { organizationJsonLd } from "@/lib/organization";

interface Props {
/** Page title; the site name is appended. Omit for the default title. */
Expand All @@ -14,7 +16,6 @@ interface Props {
noindex?: boolean;
}

const SITE_URL = "https://tritimes.org";
const DEFAULT_TITLE = "TriTimes — IRONMAN & 70.3 Triathlon Results";
const DEFAULT_DESCRIPTION =
"Look up IRONMAN and IRONMAN 70.3 race results with full-field time distributions. See your percentile for swim, bike, run, and overall across 1,400+ races since 2002.";
Expand All @@ -26,7 +27,7 @@ const ogUrl = canonical ? `${SITE_URL}${canonical}` : SITE_URL;
const googleVerification = import.meta.env.GOOGLE_SITE_VERIFICATION;
const beaconToken = import.meta.env.PUBLIC_CF_BEACON_TOKEN;
// Escape "<" so data in JSON-LD can never close the script tag early.
const jsonLdHtml = jsonLd ? JSON.stringify(jsonLd).replace(/</g, "\\u003c") : null;
const jsonLdHtml = JSON.stringify([organizationJsonLd(import.meta.env.PUBLIC_CONTACT_EMAIL), ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? [jsonLd] : [])]).replace(/</g, "\\u003c");
---

<!doctype html>
Expand Down
12 changes: 4 additions & 8 deletions site/src/lib/organization.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { ORGANIZATION } from "./site.ts";

/** Publish only verified contact information; a postal address is intentionally omitted. */
export function organizationJsonLd(email?: string) {
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error("PUBLIC_CONTACT_EMAIL must be a working public email address");
}
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://tritimes.org/#organization",
name: "TriTimes",
url: "https://tritimes.org",
...ORGANIZATION,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
url: "https://docs.google.com/forms/d/e/1FAIpQLScavYEE5U8S1Cx2OyLxFpDMPgNh5gcZh_VsbLawOBNkdYtNOQ/viewform",
...ORGANIZATION.contactPoint,
...(email ? { email } : {}),
},
};
Expand Down
20 changes: 20 additions & 0 deletions site/src/lib/site.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Public URLs follow the existing apex → www production redirect. */
export const SITE_URL = "https://www.tritimes.org";
export const FEEDBACK_URL = "https://docs.google.com/forms/d/e/1FAIpQLScavYEE5U8S1Cx2OyLxFpDMPgNh5gcZh_VsbLawOBNkdYtNOQ/viewform";
export const SITE_DESCRIPTION = "TriTimes is an independent triathlon results viewer for IRONMAN and IRONMAN 70.3 races, with athlete histories, full-field time distributions, and swim, bike, run, and overall percentiles.";

// The operator chose not to publish a phone number or postal address.
// Add a direct email only once its mailbox or forwarding route is verified.
export const ORGANIZATION = {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: "TriTimes",
url: SITE_URL,
description: SITE_DESCRIPTION,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
url: FEEDBACK_URL,
},
};
Loading
Loading