From b7fe94601c1702291b4166c79398a6102bb1a0c9 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 20 Oct 2026 16:10:16 +0000 Subject: [PATCH 1/4] 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
, 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 & and ' 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 --- docs/operations.md | 6 +- docs/testing.md | 2 +- e2e/hungarian-parity.spec.ts | 3 +- e2e/loading-performance.spec.ts | 4 +- index.html | 6 +- package-lock.json | 62 ------- package.json | 3 +- public/ai-debate-case-generator/index.html | 29 ++-- public/ai-debate-coach/index.html | 29 ++-- public/ai-debate-practice/index.html | 29 ++-- public/ai-rebuttal-generator/index.html | 29 ++-- .../ai-vs-traditional-debate-prep/index.html | 29 ++-- public/argument-flowchart/index.html | 29 ++-- public/best-ai-debate-tools/index.html | 29 ++-- .../british-parliamentary-debate/index.html | 29 ++-- public/congress-debate/index.html | 29 ++-- public/debate-block-file-guide/index.html | 29 ++-- public/debate-clash-points/index.html | 29 ++-- public/debate-collapsing-strategy/index.html | 29 ++-- public/debate-evidence-finder/index.html | 29 ++-- public/debate-fallacies/index.html | 29 ++-- public/debate-judge-adaptation/index.html | 29 ++-- public/debate-motion-analysis/index.html | 29 ++-- public/debate-prep-time-management/index.html | 29 ++-- .../index.html | 29 ++-- public/debate-rebuttal-practice/index.html | 29 ++-- public/debate-speech-structure/index.html | 29 ++-- public/debate-topics-for-students/index.html | 29 ++-- public/debatelab-pricing/index.html | 29 ++-- public/debatelab-vs-chatgpt/index.html | 29 ++-- public/debatelab-vs-debateclub/index.html | 29 ++-- public/debatelab-vs-windebate/index.html | 29 ++-- .../how-to-cross-examine-in-debate/index.html | 29 ++-- public/how-to-flow-in-debate/index.html | 29 ++-- public/how-to-practice-rebuttals/index.html | 29 ++-- .../index.html | 29 ++-- public/how-to-weigh-in-debate/index.html | 29 ++-- public/how-to-write-a-debate-case/index.html | 29 ++-- public/lincoln-douglas-debate/index.html | 29 ++-- public/llms.txt | 66 +++++++- public/online-debate-timer/index.html | 29 ++-- public/policy-debate/index.html | 29 ++-- public/public-forum-debate/index.html | 29 ++-- public/robots.txt | 34 ++++ public/seo-pages.css | 2 +- public/world-schools-debate/index.html | 29 ++-- scripts/check-site.mjs | 140 ++++++++++++++++ scripts/generate_seo_content.py | 143 ++++++++++++++-- scripts/seo_answers.py | 155 ++++++++++++++++++ scripts/seo_guides.py | 108 ++++++++++++ src/components/landing/DeepDive.tsx | 12 +- src/components/landing/FAQ.tsx | 38 ++--- src/components/landing/Features.tsx | 2 +- src/components/landing/FinalCTA.tsx | 4 +- src/components/landing/Footer.tsx | 11 +- src/components/landing/Hero.tsx | 8 +- src/components/landing/HowItWorks.tsx | 2 +- src/components/landing/Navbar.tsx | 6 +- src/components/landing/Pricing.tsx | 15 +- src/components/landing/ShortAnswer.tsx | 35 ++++ src/components/landing/UseCases.tsx | 2 +- src/components/landing/WhatsNew.tsx | 2 +- src/components/ui/accordion.tsx | 52 ------ src/config/site.json | 9 +- src/lib/seo/pages.ts | 11 +- src/locales/en.json | 23 ++- src/locales/es.json | 23 ++- src/locales/hu.json | 23 ++- src/pages/LandingPage.tsx | 6 +- src/prerender/entry.tsx | 52 +++++- src/test/vercel-deployment-config.test.ts | 2 +- tailwind.config.ts | 22 --- 72 files changed, 1534 insertions(+), 604 deletions(-) create mode 100644 scripts/check-site.mjs create mode 100644 scripts/seo_answers.py create mode 100644 src/components/landing/ShortAnswer.tsx delete mode 100644 src/components/ui/accordion.tsx diff --git a/docs/operations.md b/docs/operations.md index 58abd5e2e..d085d21dd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -49,12 +49,16 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main` - `noindex` headers for `/app`, `/login`, and `/share` routes; - Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. -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. +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. [`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 `` 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, and the landing page's last source commit. A page without a date is listed without ``. +[`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` and `/privacy` 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. + The guide pages in `public//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. +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 `*`. + To retire a guide, delete its `PAGES` entry and its `public//` 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. 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. diff --git a/docs/testing.md b/docs/testing.md index 0a808b87b..891a8195d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -102,7 +102,7 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi npm run build ``` -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. +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. ## Production service-worker smoke diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index 8a0bc902d..8c8359a6f 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -32,7 +32,8 @@ test.describe('Hungarian parity', () => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await expectHungarianDocument(page); - await expect(page.getByText('MI-alapú vitafelkészülés, ami előnyt ad minden fordulóban.')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Készíts eseteket és gyakorolj fordulókat MI-vel' })).toBeVisible(); + await expect(page.getByText(/^Attól függ, mire van szükséged\. 2026\. október állapot szerint/)).toBeAttached(); if ((page.viewportSize()?.width ?? 0) < 768) { await expect(page.getByRole('button', { name: 'Menü megnyitása' })).toBeVisible(); } else { diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 46c1d2a86..8a13a2a83 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -9,14 +9,14 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page, await page.goto('/', { waitUntil: 'domcontentloaded' }); await jamRequest; - await expect(page.getByRole('heading', { name: /Prep Faster/ })).toBeVisible({ timeout: 2_000 }); + await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 }); await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); }); test('public routes keep the real UI after refresh', async ({ page }) => { await setLocale(page, 'en'); for (const [path, label] of [ - ['/', /Prep Faster/], + ['/', /Prepare Cases and Practice Rounds/], ['/login', 'Continue with Google'], ] as const) { await page.goto(path); diff --git a/index.html b/index.html index 8ad297185..36bf83a47 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ DebateLab - + @@ -21,11 +21,11 @@ - + - +