Skip to content

Commit 71c083e

Browse files
Programcodeclaude
andauthored
Make the public pages readable for AI answer engines (AEO) (#156)
* Make the public pages readable for AI answer engines Implements the 11 answer-engine recommendations across the landing page, the 36 guide pages, robots.txt and llms.txt. - Every indexed page except /terms and /privacy opens with the reader's main question as its first H2 (#short-answer) and an answer of at most 100 words that says when another tool fits better. Guide answers live in scripts/seo_answers.py and use only facts their page already states. - The landing H1 slogan becomes "Prepare Cases and Practice Rounds with AI", and the subheadline is the definition: "DebateLab is an AI debate prep and practice app for students and competitive debaters that ...". The same sentence closes every guide and opens llms.txt. - Prices, plan facts and competitor facts carry "As of October 2026", driven by factsCheckedOn in src/config/site.json. - The landing FAQ uses native <details>, so its answers are in the prerendered HTML. Radix rendered closed answers as empty, which kept them out of the raw HTML; the accordion and its dependency are removed. - The pricing, WinDebate and DebateClub pages are rewritten from facts checked on the vendors' own sites. DebateClub turns out to target workplace conversations, not competitive debate, and the page now says so. - Guides get header/main/article/aside/footer landmarks, a breadcrumb nav, visible Published and Updated dates that match their JSON-LD, datePublished, and the lead Q&A in FAQPage. JSON-LD no longer passes through html.escape, which had put &amp; and &#x27; into two pages' data. - Landing JSON-LD adds both offers (trial and Pro), featureList, inLanguage and a FAQPage built from the visible questions. - robots.txt names meta-webindexer and the other AI crawlers in their own group, which repeats the private-route rules because a named group replaces *. llms.txt is now generated with the key facts and every guide. - scripts/check-site.mjs runs in the build and fails on broken internal links, fragments, sitemap or llms.txt URLs (applying the vercel.json redirects), bad JSON-LD, a missing question H2, images without alt, unnamed buttons and unlabelled form fields. - Decorative landing icons are aria-hidden; the mock-UI title in the hero preview is no longer an H3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Accept implicit labels and bare wildcard prefixes in check-site Codex review r1 on #156 found three checker gaps: - a field nested in its <label> was reported as unlabelled; - /share/:path* did not match /share, unlike Vercel; - any non-empty aria-labelledby counted as a name, even pointing at no element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Resolve accessible names in check-site the way browsers do Codex review r2 on #156: - labelledby text now includes nested elements, not just text before the first closing tag; - a labelledby list names the element if any referenced element has text; - implicit labels count by field position, so an identical unlabelled input elsewhere on the page is still caught; - aria-label="" no longer passes by matching into the next attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exempt the /tools pages from the #short-answer check until they get one --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 311ee3e commit 71c083e

72 files changed

Lines changed: 1571 additions & 604 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/operations.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,20 @@ Vercel builds and serves the frontend from the Git repository. A push to `main`
5050
- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`;
5151
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header.
5252

53-
After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization and SoftwareApplication JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404.
53+
After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404.
5454

5555
The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are prerendered the same way, listed in [`src/prerender/tool-pages.tsx`](../src/prerender/tool-pages.tsx) with heads in [`src/lib/seo/tool-pages.ts`](../src/lib/seo/tool-pages.ts). They render their own JSON-LD in the page body, so the dev server and the prerendered HTML carry the same structured data, and their `dateModified` is `TOOL_PAGES_MODIFIED` in that file: bump it with a substantive content change. Their speech times come from [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), which cites a league source per format.
5656

5757
`/embed/debate-timer` is a second Vite HTML entry ([`embed/debate-timer/index.html`](../embed/debate-timer/index.html)), not an app route: it loads only the timer, never the app shell, Jam, analytics or the service worker, so a site that frames it sees no third-party request. It is `noindex` with its canonical URL on `/tools/debate-timer`, and the service worker leaves `/embed/` alone.
5858

5959
[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `<lastmod>` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without `<lastmod>`.
6060

61+
[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when an indexed page other than `/terms`, `/privacy` and the `/tools` pages lacks its question H2 (`#short-answer`) as the first H2, or when an image has no alt text, a button has no name or a form field has no label.
62+
6163
The guide pages in `public/<slug>/index.html` are generated, not hand-edited: run `python3 scripts/generate_seo_content.py` and commit its output. Short pages live in its `PAGES` list; pages with long-form copy, their own highlight-card descriptions, and their own FAQs (which also feed the page's FAQPage JSON-LD) live in [`scripts/seo_guides.py`](../scripts/seo_guides.py). Keep `PAGES` in order, because each page's related-guide links are picked by position. A guide entry can set its own `modified` date, which overrides `CONTENT_DATE` for that page alone.
6264

65+
Every guide opens with the reader's main question as its first H2 and an answer of at most 100 words, kept in [`scripts/seo_answers.py`](../scripts/seo_answers.py); the generator refuses a page without one or with a longer answer. Each guide shows visible "Published" and "Updated" dates (`PUBLISHED_DATE` or a `published` key, and the `modified` date) that match its Article JSON-LD. Prices and plan facts are stated "as of" the month in `factsCheckedOn` in `src/config/site.json`; move that date when you recheck them, and the generated About line on every guide, `llms.txt` and the landing page follow. Copy in `seo_guides.py` and `seo_answers.py` that names the month itself (prices and competitor facts) needs the same edit: search those files for "As of". The generator also writes `public/llms.txt`, the plain summary for AI agents. `public/robots.txt` allows AI crawlers by name in their own group, which repeats the private-route rules because a crawler that matches a named group ignores `*`.
66+
6367
To retire a guide, delete its `PAGES` entry and its `public/<slug>/` folder (the generator never deletes), add its redirects to `vercel.json`, and repoint any link to it. Two guides that answer the same search should become one page, not two thin ones.
6468

6569
The site origin, product name, brand line, and organization name live in [`src/config/site.json`](../src/config/site.json). The app's head tags, the prerender and sitemap scripts, and the guide generator all read it, and guide copy names the product with a `{product}` token. Do not hardcode those strings in new code or copy.

‎docs/testing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi
102102
npm run build
103103
```
104104

105-
This proves the Vite production bundle compiles and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
105+
This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
106106

107107
## Production service-worker smoke
108108

‎e2e/hungarian-parity.spec.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ test.describe('Hungarian parity', () => {
3232
await page.goto('/', { waitUntil: 'domcontentloaded' });
3333

3434
await expectHungarianDocument(page);
35-
await expect(page.getByText('MI-alapú vitafelkészülés, ami előnyt ad minden fordulóban.')).toBeVisible();
35+
await expect(page.getByRole('heading', { level: 1, name: 'Készíts eseteket és gyakorolj fordulókat MI-vel' })).toBeVisible();
36+
await expect(page.getByText(/^Attól függ, mire van szükséged\. 2026\. október állapot szerint/)).toBeAttached();
3637
if ((page.viewportSize()?.width ?? 0) < 768) {
3738
await expect(page.getByRole('button', { name: 'Menü megnyitása' })).toBeVisible();
3839
} else {

‎e2e/loading-performance.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page,
99
await page.goto('/', { waitUntil: 'domcontentloaded' });
1010

1111
await jamRequest;
12-
await expect(page.getByRole('heading', { name: /Prep Faster/ })).toBeVisible({ timeout: 2_000 });
12+
await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 });
1313
await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0);
1414
});
1515

1616
test('public routes keep the real UI after refresh', async ({ page }) => {
1717
await setLocale(page, 'en');
1818
for (const [path, label] of [
19-
['/', /Prep Faster/],
19+
['/', /Prepare Cases and Practice Rounds/],
2020
['/login', 'Continue with Google'],
2121
] as const) {
2222
await page.goto(path);

‎index.html‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<!-- The shell for app routes. scripts/prerender.mjs writes the public pages
1010
from it with their own title, description, canonical URL and JSON-LD. -->
1111
<title>DebateLab</title>
12-
<meta name="description" content="DebateLab by Pi-Stack helps students and competitive debaters practice with AI opponents, case generation, rebuttal drills, evidence leads, coaching, timers, and argument flowcharts." />
12+
<meta name="description" content="DebateLab is an AI debate prep and practice app for students and competitive debaters: cases, rebuttals, evidence, coaching and timed practice rounds against an AI opponent." />
1313
<meta name="keywords" content="DebateLab, debate lab, AI debate practice, debate practice platform, debate drills, rebuttal practice, argumentation training, debate coach, debate preparation" />
1414
<meta name="author" content="DebateLab by Pi-Stack" />
1515
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
@@ -21,11 +21,11 @@
2121
<meta property="og:type" content="website" />
2222
<meta property="og:site_name" content="DebateLab by Pi-Stack" />
2323
<meta property="og:title" content="DebateLab by Pi-Stack | AI Debate Prep, Cases & Rebuttals" />
24-
<meta property="og:description" content="Practice debate with AI opponents, case generation, rebuttal drills, evidence leads, coaching, timers, and argument flowcharts built for competitive debaters." />
24+
<meta property="og:description" content="DebateLab is an AI debate prep and practice app for students and competitive debaters: cases, rebuttals, evidence, coaching and timed practice rounds against an AI opponent." />
2525
<meta property="og:image" content="https://debate.pi-stack.org/hero-visual.png" />
2626
<meta name="twitter:card" content="summary_large_image" />
2727
<meta name="twitter:title" content="DebateLab by Pi-Stack | AI Debate Prep" />
28-
<meta name="twitter:description" content="Practice debate with AI opponents, case generation, rebuttal drills, evidence leads, coaching, timers, and argument flowcharts." />
28+
<meta name="twitter:description" content="DebateLab is an AI debate prep and practice app for students and competitive debaters: cases, rebuttals, evidence, coaching and timed practice rounds against an AI opponent." />
2929
<meta name="twitter:image" content="https://debate.pi-stack.org/hero-visual.png" />
3030
<script src="/theme-init.js"></script>
3131
<style>

‎package-lock.json‎

Lines changed: 0 additions & 62 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"docs:check": "node scripts/check-docs.mjs",
1616
"backend:status": "node scripts/backend-status.mjs",
1717
"ci:failures": "node scripts/ci-failures.mjs",
18-
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-build-boundaries.mjs",
18+
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-site.mjs && node scripts/check-build-boundaries.mjs",
1919
"build:dev": "vite build --mode development",
2020
"lint": "eslint . && npm run lint:repo",
2121
"lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs",
@@ -42,7 +42,6 @@
4242
"@hocuspocus/provider": "4.7.0",
4343
"@hocuspocus/server": "4.7.0",
4444
"@hookform/resolvers": "5.4.0",
45-
"@radix-ui/react-accordion": "^1.2.11",
4645
"@radix-ui/react-alert-dialog": "^1.1.14",
4746
"@radix-ui/react-checkbox": "^1.3.2",
4847
"@radix-ui/react-dialog": "^1.1.14",

0 commit comments

Comments
 (0)