From 98fdc379e1b8399f7a02d2374abdec05ce5ec596 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 16:24:19 +0000
Subject: [PATCH] Give the /tools pages a #short-answer section and drop their
exemption
Each free tool page and the /tools hub now opens with a question H2
(#short-answer) and a short, direct answer, in English, Spanish and
Hungarian. On the four tool pages that answer also leads the FAQPage
JSON-LD, the same way the guide pages do.
With every indexed page answering a question, the temporary /tools
exemption in scripts/check-site.mjs and its note in docs/operations.md
are gone.
Co-Authored-By: Claude Opus 5.5
---
docs/operations.md | 2 +-
scripts/check-site.mjs | 8 ++-----
src/components/free-tools/ToolPageLayout.tsx | 9 +++++++-
src/locales/tools/en.json | 22 +++++++++++++++++++
src/locales/tools/es.json | 22 +++++++++++++++++++
src/locales/tools/hu.json | 22 +++++++++++++++++++
src/pages/tools/DebateTimerToolPage.tsx | 5 ++++-
src/pages/tools/FlowSheetToolPage.tsx | 6 ++++-
src/pages/tools/MotionGeneratorToolPage.tsx | 6 ++++-
.../tools/SpeechTimeCalculatorToolPage.tsx | 6 ++++-
src/pages/tools/ToolsHubPage.tsx | 3 +++
11 files changed, 99 insertions(+), 12 deletions(-)
diff --git a/docs/operations.md b/docs/operations.md
index 6b05f8d13..ae0c82359 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -58,7 +58,7 @@ The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are pr
[`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, the tool pages' `TOOL_PAGES_MODIFIED`, 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`, `/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.
+[`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.
diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs
index 6867134af..1f1258218 100644
--- a/scripts/check-site.mjs
+++ b/scripts/check-site.mjs
@@ -12,12 +12,8 @@ import { join, relative, sep } from 'node:path';
const DIST = 'dist';
const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin;
const vercel = JSON.parse(readFileSync('vercel.json', 'utf8'));
-// Indexed pages that answer no question of their own. The /tools pages lead
-// with the interactive tool itself; they have no #short-answer section yet.
+// Indexed pages that answer no question of their own.
const NO_SHORT_ANSWER = new Set(['/terms', '/privacy']);
-const NO_SHORT_ANSWER_PREFIX = '/tools';
-const answersNoQuestion = (route) =>
- NO_SHORT_ANSWER.has(route) || route === NO_SHORT_ANSWER_PREFIX || route.startsWith(`${NO_SHORT_ANSWER_PREFIX}/`);
const problems = [];
const report = (where, message) => problems.push(`${where}: ${message}`);
@@ -141,7 +137,7 @@ for (const file of htmlFiles(DIST)) {
const robots = html.match(/]*)>([\s\S]*?)<\/h2>/);
if (!firstH2 || !/\sid="short-answer"/.test(firstH2[1])) report(where, 'the first H2 is not the #short-answer question');
else if (!textOf(firstH2[2]).endsWith('?')) report(where, 'the #short-answer H2 is not a question');
diff --git a/src/components/free-tools/ToolPageLayout.tsx b/src/components/free-tools/ToolPageLayout.tsx
index d2abf652e..8295e01a2 100644
--- a/src/components/free-tools/ToolPageLayout.tsx
+++ b/src/components/free-tools/ToolPageLayout.tsx
@@ -82,6 +82,8 @@ interface ToolPageLayoutProps {
/** The visible h1. */
title: string;
intro: ReactNode;
+ /** The reader's question and a direct answer: the first H2, which answer engines quote. */
+ shortAnswer: FaqEntry;
/** The working tool, shown right under the intro. */
tool?: ReactNode;
/** Long-form sections, FAQ and CTA. */
@@ -89,7 +91,7 @@ interface ToolPageLayoutProps {
structuredData: object;
}
-export function ToolPageLayout({ route, title, intro, tool, children, structuredData }: ToolPageLayoutProps) {
+export function ToolPageLayout({ route, title, intro, shortAnswer, tool, children, structuredData }: ToolPageLayoutProps) {
const { t } = useTranslation('tools');
usePageHead(route);
const crumb = FREE_TOOLS.find((entry) => entry.route === route)?.name;
@@ -128,6 +130,11 @@ export function ToolPageLayout({ route, title, intro, tool, children, structured
{title}
{intro}
+ {/* Before the tool: the timer, calculator and hub cards carry H2s of their own. */}
+
+ {shortAnswer.question}
+ {shortAnswer.answer}
+
{tool && {tool}
}
diff --git a/src/locales/tools/en.json b/src/locales/tools/en.json
index 164eadeff..f1279c1d5 100644
--- a/src/locales/tools/en.json
+++ b/src/locales/tools/en.json
@@ -10,6 +10,28 @@
"ctaPricing": "Start with a 3-day free trial, no card needed. After that DebateLab costs $5 a month plus tax.",
"ctaButton": "Start the free trial"
},
+ "shortAnswer": {
+ "hub": {
+ "question": "What free debate tools can I use without an account?",
+ "answer": "Four, all free and all usable without signing up: a round timer that knows the speech order and times of seven formats, a motion generator that draws from {{total}} original practice motions, a printable flow sheet with a column for every speech, and a speech time calculator. They run in your browser and save your rounds and drafts there, not on a server."
+ },
+ "timer": {
+ "question": "Is there a free debate timer that knows each format's speech times?",
+ "answer": "Yes, this one. Pick Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary or the school 4-6-6-3 format, and it loads the speech order and lengths, shows the protected time when points of information are not allowed, and gives each side a prep bank in Public Forum, Lincoln-Douglas and Policy. It is free, needs no account, and keeps a running round through a page reload."
+ },
+ "motions": {
+ "question": "Where can I get random debate motions for practice?",
+ "answer": "Draw them here. The generator picks one motion, or three for a veto drill, from {{total}} original practice motions written for this site, filtered by topic, difficulty and motion type, in This House or Resolved wording. It is free, needs no account, and lists every motion by topic further down the page."
+ },
+ "flow": {
+ "question": "How do I make a debate flow sheet?",
+ "answer": "Choose your format and paper size here, then print. The sheet has a column for every speech in speaking order, labelled by speaker and coloured by side, and a strip along the bottom for cross-examination or point-of-information notes. For a PDF, choose Save as PDF in the print dialog. It is free and needs no account."
+ },
+ "calc": {
+ "question": "How long will my speech take to deliver?",
+ "answer": "Divide its word count by your speaking rate. At an average 130 words per minute, a 520-word speech takes about 4 minutes; at a fast 160, about 3 minutes 15 seconds. Paste the speech into the calculator below to get the time at slow, average, fast and your own pace, and to see which debate speech slots it fits. The text never leaves your browser."
+ }
+ },
"timer": {
"region": "{{format}} round timer",
"format": "Format",
diff --git a/src/locales/tools/es.json b/src/locales/tools/es.json
index 8f92131b7..f68c051af 100644
--- a/src/locales/tools/es.json
+++ b/src/locales/tools/es.json
@@ -10,6 +10,28 @@
"ctaPricing": "Empieza con 3 días de prueba gratis, sin tarjeta. Después, DebateLab cuesta 5 $ al mes más impuestos.",
"ctaButton": "Empezar la prueba gratis"
},
+ "shortAnswer": {
+ "hub": {
+ "question": "¿Qué herramientas de debate gratis puedo usar sin crear una cuenta?",
+ "answer": "Cuatro, todas gratis y sin registro: un cronómetro de rondas que conoce el orden y la duración de los discursos de siete formatos, un generador que saca mociones de un banco de {{total}} mociones de práctica originales, una hoja de flujo imprimible con una columna por discurso y una calculadora de duración de discursos. Funcionan en tu navegador y guardan ahí tus rondas y borradores, no en un servidor."
+ },
+ "timer": {
+ "question": "¿Hay algún cronómetro de debate gratis que conozca los tiempos de cada formato?",
+ "answer": "Sí, este. Elige Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary o el formato escolar 4-6-6-3, y carga el orden y la duración de los discursos, muestra el tiempo protegido en que no se admiten puntos de información y, en Public Forum, Lincoln-Douglas y Policy, da a cada lado su reserva de tiempo de preparación. Es gratis, no necesita cuenta y conserva la ronda en curso aunque recargues la página."
+ },
+ "motions": {
+ "question": "¿Dónde consigo mociones de debate al azar para practicar?",
+ "answer": "Aquí. El generador saca una moción, o tres para un ejercicio de veto, de {{total}} mociones de práctica originales escritas para este sitio, con filtros de tema, dificultad y tipo de moción, y redacción «Esta Casa» o «Resolved». Es gratis, no necesita cuenta, y más abajo están todas las mociones ordenadas por tema."
+ },
+ "flow": {
+ "question": "¿Cómo hago una hoja de flujo para un debate?",
+ "answer": "Elige aquí tu formato y el tamaño del papel, e imprime. La hoja tiene una columna por discurso en orden de intervención, rotulada con el turno de cada orador y coloreada según el lado, y una franja abajo para notas de preguntas cruzadas o puntos de información. Para obtener un PDF, elige Guardar como PDF en el cuadro de impresión. Es gratis y no necesita cuenta."
+ },
+ "calc": {
+ "question": "¿Cuánto tardaré en pronunciar mi discurso?",
+ "answer": "Divide el número de palabras entre tu ritmo de habla. A un ritmo medio de 130 palabras por minuto, un discurso de 520 palabras dura unos 4 minutos; a un ritmo rápido de 160, unos 3 minutos y 15 segundos. Pega el discurso en la calculadora de abajo para ver la duración a ritmo lento, medio, rápido y al tuyo, y en qué turnos de debate cabe. El texto nunca sale de tu navegador."
+ }
+ },
"timer": {
"region": "Cronómetro de ronda de {{format}}",
"format": "Formato",
diff --git a/src/locales/tools/hu.json b/src/locales/tools/hu.json
index 17bfbe31b..481a9d242 100644
--- a/src/locales/tools/hu.json
+++ b/src/locales/tools/hu.json
@@ -10,6 +10,28 @@
"ctaPricing": "Kezdd 3 napos ingyenes próbával, bankkártya nélkül. Utána a DebateLab havi 5 dollár plusz adó.",
"ctaButton": "Ingyenes próba indítása"
},
+ "shortAnswer": {
+ "hub": {
+ "question": "Milyen ingyenes vitaeszközöket használhatok regisztráció nélkül?",
+ "answer": "Négyet, mind ingyenes, és egyikhez sem kell fiók: egy fordulóidőmérőt, amely hét formátum beszédsorrendjét és beszédidejét ismeri, egy tételgenerátort, amely {{total}} eredeti, gyakorlásra írt vitatételből húz, egy nyomtatható jegyzetlapot, amelyen minden beszédnek saját oszlopa van, és egy beszédidő-kalkulátort. A böngésződben futnak, és a fordulóidat és piszkozataidat ott tárolják, nem szerveren."
+ },
+ "timer": {
+ "question": "Van olyan ingyenes vitaidőmérő, amely ismeri az egyes formátumok beszédidejét?",
+ "answer": "Igen, ez. Válaszd ki a Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary vagy az iskolai 4-6-6-3 formátumot, és az időmérő betölti a beszédek sorrendjét és hosszát, mutatja a védett időt, amikor nem lehet közbekérdezni, a Public Forum, Lincoln-Douglas és Policy formátumban pedig mindkét oldalnak saját felkészülési időkeretet ad. Ingyenes, nem kell hozzá fiók, és a futó forduló az oldal újratöltése után is megmarad."
+ },
+ "motions": {
+ "question": "Hol találok véletlenszerű vitatételeket gyakorláshoz?",
+ "answer": "Itt. A generátor egy tételt húz, vétógyakorlathoz hármat, {{total}} eredeti, ehhez az oldalhoz írt gyakorló tételből. Szűrhetsz téma, nehézség és tételtípus szerint, a megfogalmazás pedig lehet This House vagy Resolved. Ingyenes, nem kell hozzá fiók, és lejjebb az összes tétel megtalálható téma szerint."
+ },
+ "flow": {
+ "question": "Hogyan készíthetek jegyzetlapot egy vitafordulóhoz?",
+ "answer": "Válaszd ki itt a formátumot és a papírméretet, majd nyomtasd ki. A lapon minden beszédnek saját oszlopa van, elhangzási sorrendben, a beszélő szerepével felcímkézve és az oldal színével jelölve, alul pedig egy sáv a keresztkérdésekhez vagy a közbekérdezésekhez. PDF-hez a nyomtatási ablakban válaszd a Mentés PDF-ként lehetőséget. Ingyenes, és nem kell hozzá fiók."
+ },
+ "calc": {
+ "question": "Mennyi ideig tart elmondani a beszédemet?",
+ "answer": "Oszd el a szavak számát a beszédtempóddal. Átlagos, percenként 130 szavas tempóban egy 520 szavas beszéd nagyjából 4 perc, gyors, 160 szavas tempóban körülbelül 3 perc 15 másodperc. Illeszd be a beszédet a lenti kalkulátorba, és megmutatja az időt lassú, átlagos, gyors és saját tempóban, valamint azt, hogy mely vitabeszéd-időkbe fér bele. A szöveg nem hagyja el a böngésződet."
+ }
+ },
"timer": {
"region": "{{format}} vitaidőmérő",
"format": "Formátum",
diff --git a/src/pages/tools/DebateTimerToolPage.tsx b/src/pages/tools/DebateTimerToolPage.tsx
index d12a6b863..93305a15c 100644
--- a/src/pages/tools/DebateTimerToolPage.tsx
+++ b/src/pages/tools/DebateTimerToolPage.tsx
@@ -79,12 +79,15 @@ function EmbedSnippets() {
}
export default function DebateTimerToolPage() {
+ const { t } = useTranslation('tools');
+ const shortAnswer = { question: t('shortAnswer.timer.question'), answer: t('shortAnswer.timer.answer') };
return (
Choose a format and the timer already knows the speech order, the protected minutes and each side's prep time. It needs no account, runs entirely in this tab, and keeps your round if the page reloads.
}
- structuredData={toolStructuredData('/tools/debate-timer', NAME, FAQ)}
+ shortAnswer={shortAnswer}
+ structuredData={toolStructuredData('/tools/debate-timer', NAME, [shortAnswer, ...FAQ])}
tool={(
Pick your format and paper, and print a one-page flow with a column for every speech, labelled by speaker and coloured by side, plus a strip for cross-examination notes. Nothing to install; your browser's print dialog makes the PDF.}
- structuredData={toolStructuredData('/tools/debate-flow-sheet', NAME, FAQ)}
+ shortAnswer={shortAnswer}
+ structuredData={toolStructuredData('/tools/debate-flow-sheet', NAME, [shortAnswer, ...FAQ])}
tool={}
>
diff --git a/src/pages/tools/MotionGeneratorToolPage.tsx b/src/pages/tools/MotionGeneratorToolPage.tsx
index 035228174..1908b0fe2 100644
--- a/src/pages/tools/MotionGeneratorToolPage.tsx
+++ b/src/pages/tools/MotionGeneratorToolPage.tsx
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { MotionGenerator } from '@/components/free-tools/MotionGenerator';
import { ToolCta, ToolFaq, ToolPageLayout } from '@/components/free-tools/ToolPageLayout';
import { PRACTICE_MOTIONS } from '@/lib/free-tools/practice-motions';
@@ -61,12 +62,15 @@ function MotionList() {
}
export default function MotionGeneratorToolPage() {
+ const { t } = useTranslation('tools');
+ const shortAnswer = { question: t('shortAnswer.motions.question'), answer: t('shortAnswer.motions.answer', { total: PRACTICE_MOTIONS.length }) };
return (
Draw a practice motion, or three for a veto drill, from {PRACTICE_MOTIONS.length} original motions across twelve topics. Filter by difficulty and motion type, and switch between "This House" wording for BP and World Schools and "Resolved" wording for US formats.}
- structuredData={toolStructuredData('/tools/debate-motion-generator', NAME, FAQ)}
+ shortAnswer={shortAnswer}
+ structuredData={toolStructuredData('/tools/debate-motion-generator', NAME, [shortAnswer, ...FAQ])}
tool={}
>
diff --git a/src/pages/tools/SpeechTimeCalculatorToolPage.tsx b/src/pages/tools/SpeechTimeCalculatorToolPage.tsx
index e17b51665..da91ade04 100644
--- a/src/pages/tools/SpeechTimeCalculatorToolPage.tsx
+++ b/src/pages/tools/SpeechTimeCalculatorToolPage.tsx
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { SpeechTimeCalculator } from '@/components/free-tools/SpeechTimeCalculator';
import { ToolCta, ToolFaq, ToolPageLayout } from '@/components/free-tools/ToolPageLayout';
import { BUILT_IN_FORMATS, formatDuration, segmentRole, speakingSegments } from '@/lib/free-tools/formats';
@@ -67,12 +68,15 @@ function TargetTable() {
}
export default function SpeechTimeCalculatorToolPage() {
+ const { t } = useTranslation('tools');
+ const shortAnswer = { question: t('shortAnswer.calc.question'), answer: t('shortAnswer.calc.answer') };
return (
Paste a speech to see its word count, how long it takes to deliver at a slow, average or fast pace, and which debate speech slots it fits. Everything runs in your browser; the text never leaves this page.}
- structuredData={toolStructuredData('/tools/speech-time-calculator', NAME, FAQ)}
+ shortAnswer={shortAnswer}
+ structuredData={toolStructuredData('/tools/speech-time-calculator', NAME, [shortAnswer, ...FAQ])}
tool={}
>
diff --git a/src/pages/tools/ToolsHubPage.tsx b/src/pages/tools/ToolsHubPage.tsx
index 3c0458de8..b568f6927 100644
--- a/src/pages/tools/ToolsHubPage.tsx
+++ b/src/pages/tools/ToolsHubPage.tsx
@@ -1,14 +1,17 @@
import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { FREE_TOOLS, ToolCta, ToolPageLayout } from '@/components/free-tools/ToolPageLayout';
import { PRACTICE_MOTIONS } from '@/lib/free-tools/practice-motions';
import { hubStructuredData } from '@/lib/seo/tool-pages';
export default function ToolsHubPage() {
+ const { t } = useTranslation('tools');
return (
Tools for debate practice that work in any browser and need no account. Your rounds and drafts are saved in your browser, not on a server. Use them in a club session, on a projector in class, or embedded on your own site.}
+ shortAnswer={{ question: t('shortAnswer.hub.question'), answer: t('shortAnswer.hub.answer', { total: PRACTICE_MOTIONS.length }) }}
structuredData={hubStructuredData(FREE_TOOLS.map(({ route, name }) => ({ route, name })))}
tool={(