From ef88cddcc79b5f2a3911693b24d670ea008c4556 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 17:05:26 +0000 Subject: [PATCH 1/3] Serve indexable Hungarian and Spanish pages with hreflang /hu and /es used to 302 to /?lang=, which served the English HTML with canonical /. No Hungarian or Spanish page could be indexed. Now the landing page and the five free tool pages are prerendered in Hungarian and Spanish at their own addresses (/hu, /es, /hu/tools/vita-idozito, /es/tools/cronometro-debate, ...). Each has , a self-canonical, and hreflang for en, hu, es and x-default. The sitemap carries the same alternates as xhtml:link, and check-site fails the build on hreflang that is one-sided or disagrees with . src/config/localized-routes.json is the one list of addresses. Opening a translated address chooses its language like a ?lang= link, so Start leads into the app in that language. Picking another language there moves to that language's address. The client keeps the canonical on the translated address after it takes over. Trial length and Pro price on these pages now come from site.json through i18next default variables, in all three languages. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 2 +- .../verify-debatelab/features/localization.md | 6 +- .../recipes/localization.spec.ts | 19 +- docs/operations.md | 8 +- e2e/localization.spec.ts | 20 +- public/llms.txt | 18 + scripts/check-site.mjs | 27 +- scripts/generate_seo_content.py | 18 + scripts/prerender.mjs | 10 +- scripts/sitemap.mjs | 19 +- src/App.tsx | 20 +- src/components/LocalizedRoute.tsx | 40 ++ src/components/free-tools/ToolPageLayout.tsx | 37 +- src/components/landing/Footer.tsx | 4 +- src/components/landing/ShortAnswer.tsx | 4 +- src/config/localized-routes.json | 8 + src/i18n.ts | 8 +- src/lib/route-modules.ts | 30 +- src/lib/seo/localized-routes.ts | 50 +++ src/lib/seo/tool-pages.ts | 60 ++- src/lib/seo/use-page-head.ts | 15 +- src/lib/url-language.ts | 15 +- src/locales/en.json | 16 +- src/locales/es.json | 18 +- src/locales/hu.json | 18 +- src/locales/tools/en.json | 2 +- src/locales/tools/es.json | 4 +- src/locales/tools/hu.json | 4 +- src/main.tsx | 4 +- .../tools/localized/LocalizedToolPages.tsx | 152 +++++++ src/pages/tools/localized/copy-es.ts | 348 ++++++++++++++++ src/pages/tools/localized/copy-hu.ts | 383 ++++++++++++++++++ src/pages/tools/localized/copy-types.ts | 45 ++ src/pages/tools/localized/es.tsx | 5 + src/pages/tools/localized/hu.tsx | 5 + src/pages/tools/localized/pages.ts | 16 + src/prerender/entry.tsx | 98 ++++- src/test/seo/page-head.test.tsx | 13 +- src/test/vercel-deployment-config.test.ts | 5 - vercel.json | 5 - 40 files changed, 1438 insertions(+), 141 deletions(-) create mode 100644 src/components/LocalizedRoute.tsx create mode 100644 src/config/localized-routes.json create mode 100644 src/lib/seo/localized-routes.ts create mode 100644 src/pages/tools/localized/LocalizedToolPages.tsx create mode 100644 src/pages/tools/localized/copy-es.ts create mode 100644 src/pages/tools/localized/copy-hu.ts create mode 100644 src/pages/tools/localized/copy-types.ts create mode 100644 src/pages/tools/localized/es.tsx create mode 100644 src/pages/tools/localized/hu.tsx create mode 100644 src/pages/tools/localized/pages.ts diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index b9a59cf77..89f8f6fcb 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -53,7 +53,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`). - [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`). - Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`). -- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`). +- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` pages (`recipes/localization.spec.ts`). - Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`). Not yet mapped in recipes: theme, the public landing and SEO pages beyond their titles, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`). diff --git a/.claude/skills/verify-debatelab/features/localization.md b/.claude/skills/verify-debatelab/features/localization.md index 5da559215..74ae95853 100644 --- a/.claude/skills/verify-debatelab/features/localization.md +++ b/.claude/skills/verify-debatelab/features/localization.md @@ -1,13 +1,13 @@ # Localization -The interface runs in English, Spanish and Hungarian. Every page names itself in the tab title in the interface language ("Generálás · DebateLab" in the app, "Felhasználási feltételek | DebateLab by Pi-Stack" on public pages), form validation and server refusals read in that language, the language switcher sits in every header, and `?lang=es|hu|en` links and the `/es` and `/hu` home pages open the app in that language. +The interface runs in English, Spanish and Hungarian. Every page names itself in the tab title in the interface language ("Generálás · DebateLab" in the app, "Felhasználási feltételek | DebateLab by Pi-Stack" on public pages), form validation and server refusals read in that language, the language switcher sits in every header, and `?lang=es|hu|en` links and the Hungarian and Spanish pages (`/hu`, `/es`, `/hu/tools/…`, `/es/tools/…`) open the app in that language. ## Sub-features - loc-titles: `document.title` on app and public routes, per language, and a switch retitling the page without a reload. App routes read ` · DebateLab` (`usePrivateRouteHead`); public pages use the `pageHead` catalog section (`usePageHead`), whose English text is also what the prerender writes. - loc-validation: an empty Attack submit shows `validation.caseTextRequired` in the interface language, and nothing shows before the first submit. Generate, Brainstorm and Factoids use the same keys. - loc-switcher: the switcher on `/login`, the 404 page, and the other public pages (`PublicPageShell`), besides the landing page and the app header. -- loc-links: `/?lang=hu` switches and saves the language, sets ``, and drops the parameter; `/es` and `/hu` land on `/` in that language (a client route here; `vercel.json` redirects them on the host). +- loc-links: `/?lang=hu` switches and saves the language, sets ``, and drops the parameter; `/es` and `/hu` are prerendered pages of their own that keep their address, and Start from `/hu` leads into the app in Hungarian. - loc-server-error: a refused promo code shows `serverErrors.promoInvalid` in the interface language while redeem-promo still answers in English beside its `code`. ## How to get to it (user POV) @@ -30,4 +30,4 @@ $H drive recipes/localization --project mobile-chromium # 375 px - The switcher's own name is translated (`langSwitcher.label`); match it in all three languages. - The recipe reads the catalogs from `src/locales/`, so its expectations follow the strings. Match on keys, never on copied text. - The promo field shows only for an account that would start a Checkout, so the recipe marks its Pro demo user expired first. -- `/es` and `/hu` on the host are a `vercel.json` redirect; the local stack proves only the client route. +- `/es` and `/hu` are files in `dist/` (`hu/index.html`), listed with their tool pages in `src/config/localized-routes.json`. Choosing another language with the switcher on one of them moves to that language's address. diff --git a/.claude/skills/verify-debatelab/recipes/localization.spec.ts b/.claude/skills/verify-debatelab/recipes/localization.spec.ts index 40175bdad..9f8d18b01 100644 --- a/.claude/skills/verify-debatelab/recipes/localization.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/localization.spec.ts @@ -9,7 +9,9 @@ const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${lan const en = catalog("en"); const es = catalog("es"); const hu = catalog("hu"); -const BRAND = "DebateLab by Pi-Stack"; +const site = JSON.parse(readFileSync("src/config/site.json", "utf8")); +const BRAND = site.brand; +const head = (text: string) => text.replace("{{brand}}", BRAND).replace("{{product}}", site.product); const LANGUAGE_NAMES = { en: "English", es: "Español", hu: "Magyar" } as const; /** The switcher's own name in whichever language is showing. */ const SWITCHER_NAME = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join("|")})$`); @@ -108,26 +110,29 @@ test("loc-switcher: sign-in and not-found pages carry the language switcher", as await evidence.shot("04-not-found-es"); }); -test("loc-links: ?lang= and the /es and /hu home pages open the landing page in that language", async ({ page, evidence }) => { +test("loc-links: ?lang= opens the landing page in that language, and /es and /hu are pages of their own", async ({ page, evidence }) => { await page.goto("/?lang=hu"); await expect(page.locator("html")).toHaveAttribute("lang", "hu"); - await expect(page).toHaveTitle(hu.pageHead.home.title.replace("{{brand}}", BRAND)); + await expect(page).toHaveTitle(head(hu.pageHead.home.title)); // The choice is kept and the address drops the parameter. await expect.poll(() => new URL(page.url()).search).toBe(""); await evidence.shot("01-landing-lang-hu"); await page.goto("/es"); - await expect.poll(() => new URL(page.url()).pathname).toBe("/"); await expect(page.locator("html")).toHaveAttribute("lang", "es"); - await expect(page).toHaveTitle(es.pageHead.home.title.replace("{{brand}}", BRAND)); + await expect(page).toHaveTitle(head(es.pageHead.home.title)); + expect(new URL(page.url()).pathname).toBe("/es"); await evidence.shot("02-landing-es"); await page.goto("/hu"); - await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); + // Start leads into the app in Hungarian, and the choice survives a reload. + await page.getByRole("link", { name: hu.nav.startTrial }).first().click(); + await expect.poll(() => new URL(page.url()).pathname).not.toBe("/hu"); await expect(page.locator("html")).toHaveAttribute("lang", "hu"); await page.reload(); await expect(page.locator("html")).toHaveAttribute("lang", "hu"); - await evidence.shot("03-landing-hu-after-reload"); + await evidence.shot("03-start-from-hu"); }); test("loc-server-error: a refused promo code reads in the interface language", async ({ signedIn: page, user, evidence }) => { diff --git a/docs/operations.md b/docs/operations.md index 5e3bf019d..673bc7b48 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -54,11 +54,13 @@ After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders 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. +The landing page and the free tools also exist in Hungarian and Spanish, each at its own address: `/hu`, `/es`, and the tools under translated slugs such as `/hu/tools/vita-idozito` and `/es/tools/cronometro-debate`. [`src/config/localized-routes.json`](../src/config/localized-routes.json) is the one list of those addresses; the router, the prerender, `check-site` and `llms.txt` read it. The prerender renders each one in its language, with ``, a self-canonical, and `hreflang` links for `en`, `hu`, `es` and `x-default` (English) on all three versions; pages without translations get no `hreflang`. Heads come from the `pageHead` catalog section in that language. The translated tool pages reuse the English widgets, take their copy from [`src/pages/tools/localized/`](../src/pages/tools/localized/), and set `dateModified` from that copy's `modified` date. Opening a translated address chooses its language, like a `?lang=` link, so Start leads into the app in that language; choosing another language there moves to that language's address. + `/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. -[`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/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. A page with `hreflang` links in its head is listed with the same alternates as `xhtml:link` entries. 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 `/expocit/sources` lacks its question H2 (`#short-answer`) as the first H2, when a page's first heading is not its H1 or a heading skips a level (H1 to H3), 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`, `/privacy`, and `/expocit/sources` lacks its question H2 (`#short-answer`) as the first H2, when a page's first heading is not its H1 or a heading skips a level (H1 to H3), when an image has no alt text, a button has no name or a form field has no label, or when a page's `hreflang` links do not resolve, leave out the page itself or `x-default`, disagree with ``, or differ from the set on a page they name. 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. Pages that show the shared `FAQS` are dated no earlier than `FAQS_DATE`, so move it when those questions change. @@ -66,7 +68,7 @@ Every guide opens with the reader's main question as its first H2 and an answer 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 and the Pro price with `{pro_price}`. The number of prep modes comes from the tool lists in [`tool-links.tsx`](../src/components/features/tools/tool-links.tsx): the app's copy reads `PREP_MODE_COUNT` and `UTILITY_COUNT`, and guide copy uses `{mode_count}`, `{mode_names}` and `{utility_names}`. Do not hardcode those strings in new code or copy. +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 and the Pro price with `{pro_price}`. The number of prep modes comes from the tool lists in [`tool-links.tsx`](../src/components/features/tools/tool-links.tsx): the app's copy reads `PREP_MODE_COUNT` and `UTILITY_COUNT`, and guide copy uses `{mode_count}`, `{mode_names}` and `{utility_names}`. The trial length and Pro price come from `pricing` in the same file: i18next passes `{{trialDays}}`, `{{proMonthly}}`, `{{product}}` and `{{brand}}` to every catalog string, so the English, Spanish and Hungarian pages state the same facts. Do not hardcode those strings in new code or copy. [`src/test/vercel-deployment-config.test.ts`](../src/test/vercel-deployment-config.test.ts) guards these rules. Update it together with `vercel.json`, and do not paste the full configuration into another guide. diff --git a/e2e/localization.spec.ts b/e2e/localization.spec.ts index 5fa792000..ebf92f9c8 100644 --- a/e2e/localization.spec.ts +++ b/e2e/localization.spec.ts @@ -12,10 +12,11 @@ const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${lan const en = catalog('en'); const es = catalog('es'); const hu = catalog('hu'); -const BRAND = 'DebateLab by Pi-Stack'; +const site = JSON.parse(readFileSync('src/config/site.json', 'utf8')); +const BRAND = site.brand; const LANGUAGES = { en: 'English', es: 'Español', hu: 'Magyar' } as const; const SWITCHER = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join('|')})$`); -const brand = (text: string) => text.replace('{{brand}}', BRAND); +const brand = (text: string) => text.replace('{{brand}}', BRAND).replace('{{product}}', site.product); const APP_ROUTES = ['/app', '/app/generate', '/app/attack', '/app/history', '/app/settings']; const PUBLIC_ROUTES = ['/terms', '/privacy', '/tools', '/no-such-page']; @@ -118,7 +119,7 @@ test('sign-in and not-found pages carry the language switcher', async ({ page }) await shot(page, '04-not-found-es'); }); -test('?lang= and the /hu and /es home pages open the landing page in that language', async ({ page }) => { +test('?lang= opens the landing page in that language, and /hu and /es are pages of their own', async ({ page }) => { await page.goto('/?lang=hu'); await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); await expect(page).toHaveTitle(brand(hu.pageHead.home.title)); @@ -126,18 +127,23 @@ test('?lang= and the /hu and /es home pages open the landing page in that langua await shot(page, '01-landing-lang-hu'); await page.goto('/es'); - await expect.poll(() => new URL(page.url()).pathname).toBe('/'); await expect(page.locator('html')).toHaveAttribute('lang', 'es'); await expect(page).toHaveTitle(brand(es.pageHead.home.title)); + // Still /es after the app takes over: the address and canonical stay Spanish. + expect(new URL(page.url()).pathname).toBe('/es'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${site.origin}/es`); await shot(page, '02-landing-es'); await page.goto('/hu'); - await expect.poll(() => new URL(page.url()).pathname).toBe('/'); await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); - // The choice is kept, not just shown once. + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${site.origin}/hu`); + // The choice is kept, not just shown once: Start leads into the app in Hungarian. + await page.getByRole('link', { name: hu.nav.startTrial }).first().click(); + await expect.poll(() => new URL(page.url()).pathname).not.toBe('/hu'); + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); await page.reload(); await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); - await shot(page, '03-landing-hu-after-reload'); + await shot(page, '03-start-from-hu'); }); test('a server refusal with a code reads in the interface language', async ({ page }) => { diff --git a/public/llms.txt b/public/llms.txt index c4a900185..eaa679138 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -31,6 +31,24 @@ Free to use, no account needed, and they run in the browser. - [Debate flow sheet](https://debate.pi-stack.org/tools/debate-flow-sheet): A printable one-page flow with a column for every speech in your format, on A4 or Letter. - [Speech time calculator](https://debate.pi-stack.org/tools/speech-time-calculator): Paste a speech to see how long it takes to deliver and which speech slots it fits. +## Magyar + +- [DebateLab – MI-alapú vitafelkészítés és gyakorlás diákoknak](https://debate.pi-stack.org/hu): A DebateLab MI-alapú app vitára készülő diákoknak: esetépítés, cáfolatok, bizonyítékok, coaching és időzített gyakorlókörök egy MI-ellenféllel szemben. +- [Ingyenes vitaeszközök: időmérő, indítványok, jegyzetlapok | DebateLab by Pi-Stack](https://debate.pi-stack.org/hu/tools): Ingyenes, fiók nélkül használható vitaeszközök: formátumhoz igazodó időmérő, indítványgenerátor, nyomtatható jegyzetlapok és beszédidő-kalkulátor. +- [Ingyenes vitaidőmérő PF, LD, Policy, BP és World Schools formátumhoz | DebateLab by Pi-Stack](https://debate.pi-stack.org/hu/tools/vita-idozito): Ingyenes vitaidőmérő PF, LD, Policy, BP, World Schools és Asian Parliamentary beállításokkal: védett idő, POI-jelzés és felkészülési idő. +- [Indítványgenerátor: 300 gyakorló vitaindítvány | DebateLab by Pi-Stack](https://debate.pi-stack.org/hu/tools/vitatema-generator): Húzz egy véletlen vitaindítványt, vagy hármat a vétógyakorláshoz, 300 eredeti gyakorló indítványból, téma, nehézség és típus szerint rendezve. +- [Nyomtatható vitajegyzetlap-generátor | DebateLab by Pi-Stack](https://debate.pi-stack.org/hu/tools/flow-lap): Egyoldalas, nyomtatható vitajegyzetlap minden beszédhez külön oszloppal PF, LD, Policy, BP, World Schools vagy Asian Parliamentary formátumhoz. +- [Beszédidő-kalkulátor vitabeszédekhez | DebateLab by Pi-Stack](https://debate.pi-stack.org/hu/tools/beszedido-kalkulator): Illessz be egy beszédet, és megmutatjuk a szószámát, hogy lassú, átlagos és gyors tempóban mennyi ideig tart, és mely vitabeszéd-idősávokba fér bele. + +## Español + +- [DebateLab – Preparación y práctica de debate con IA para estudiantes](https://debate.pi-stack.org/es): DebateLab es una app con IA para preparar y practicar debates: casos, refutaciones, evidencia, coaching y rondas cronometradas contra un oponente de IA. +- [Herramientas de debate gratis: cronómetro, mociones, hojas de flujo | DebateLab by Pi-Stack](https://debate.pi-stack.org/es/tools): Herramientas de debate gratis y sin cuenta: cronómetro por formato, generador de mociones, hojas de flujo imprimibles y calculadora de tiempo de discurso. +- [Cronómetro de debate gratis para PF, LD, Policy, BP y World Schools | DebateLab by Pi-Stack](https://debate.pi-stack.org/es/tools/cronometro-debate): Cronómetro de debate gratis con ajustes para PF, LD, Policy, BP, World Schools y Asian Parliamentary: tiempo protegido, avisos de POI y preparación. +- [Generador de mociones de debate: 300 mociones de práctica | DebateLab by Pi-Stack](https://debate.pi-stack.org/es/tools/generador-mociones): Saca una moción de debate al azar, o tres para practicar el veto, de entre 300 mociones originales ordenadas por tema, dificultad y tipo. +- [Generador de hojas de flujo de debate imprimibles | DebateLab by Pi-Stack](https://debate.pi-stack.org/es/tools/hoja-de-flujo): Imprime una hoja de flujo de una página con una columna por discurso en PF, LD, Policy, BP, World Schools o Asian Parliamentary, en A4 o Carta. +- [Calculadora de tiempo para discursos de debate | DebateLab by Pi-Stack](https://debate.pi-stack.org/es/tools/calculadora-tiempo-discurso): Pega un discurso para ver cuántas palabras tiene, cuánto dura a ritmo lento, medio o rápido y en qué turnos de debate cabe. Funciona en tu navegador. + ## Guides - [AI Debate Practice for Stronger Cases, Rebuttals, and Speeches](https://debate.pi-stack.org/ai-debate-practice): Practice debate online with DebateLab: AI opponents, targeted drills, rebuttal feedback, evidence research, argument flowcharts, and tournament-ready prep tools. diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index 89079f3f6..bbc8a36e9 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -6,7 +6,9 @@ // - JSON-LD that does not parse or carries HTML entities into its text; // - an indexed page without its question H2 (#short-answer) as the first H2; // - a page whose first heading is not its H1, or a heading that skips a level (H1 to H3); -// - an image without alt text, a button without a name, or a form field without a label. +// - an image without alt text, a button without a name, or a form field without a label; +// - hreflang links that do not resolve, leave out the page itself or x-default, +// disagree with , or are not the same set on every page they name. import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; @@ -117,11 +119,25 @@ function namedByAria(attributes, html) { }); } +// Canonical URL -> the page's hreflang set, checked for agreement after the loop. +const hreflangSets = new Map(); + for (const file of htmlFiles(DIST)) { const where = relative(DIST, file); const html = readFileSync(file, 'utf8'); const body = html.slice(html.indexOf('/g)]; + if (alternates.length) { + const set = Object.fromEntries(alternates.map(([, hreflang, href]) => [hreflang, href])); + const canonical = html.match(/]*\shref="([^"]*)"/g)) { if (/^(mailto:|tel:|javascript:)/i.test(href)) continue; checkUrl(href.replaceAll('&', '&'), where, file); @@ -167,6 +183,15 @@ for (const file of htmlFiles(DIST)) { } } +const sameSet = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); +for (const { where, set } of hreflangSets.values()) { + for (const [hreflang, href] of Object.entries(set)) { + const other = hreflangSets.get(href); + if (!other) report(where, `hreflang="${hreflang}" names ${href}, which lists no hreflang set back`); + else if (!sameSet(set, other.set)) report(where, `hreflang set differs from the one on ${other.where}`); + } +} + const sitemap = readFileSync(join(DIST, 'sitemap.xml'), 'utf8'); for (const [, loc] of sitemap.matchAll(/([^<]+)<\/loc>/g)) checkUrl(loc, 'sitemap.xml'); diff --git a/scripts/generate_seo_content.py b/scripts/generate_seo_content.py index b7e36a198..825e6c2b8 100644 --- a/scripts/generate_seo_content.py +++ b/scripts/generate_seo_content.py @@ -308,6 +308,22 @@ def render_page(page): ''' +# The Hungarian and Spanish pages (src/config/localized-routes.json), named by their own titles. +LOCALIZED_ROUTES = json.loads((ROOT / "src" / "config" / "localized-routes.json").read_text()) +PAGE_HEAD_IDS = {"/": "home", "/tools": "tools", "/tools/debate-timer": "debateTimer", "/tools/debate-motion-generator": "motionGenerator", "/tools/debate-flow-sheet": "flowSheet", "/tools/speech-time-calculator": "speechTimeCalculator"} + +def localized_sections(): + sections = [] + for language, heading in (("hu", "Magyar"), ("es", "Español")): + heads = json.loads((ROOT / "src" / "locales" / f"{language}.json").read_text())["pageHead"] + lines = [] + for route, paths in LOCALIZED_ROUTES.items(): + head = heads[PAGE_HEAD_IDS[route]] + title, description = (head[key].replace("{{brand}}", BRAND).replace("{{product}}", PRODUCT) for key in ("title", "description")) + lines.append(f"- [{title}]({BASE}{paths[language]}): {description}") + sections.append(f"## {heading}\n\n" + "\n".join(lines)) + return "\n\n".join(sections) + def llms_txt(): """public/llms.txt: a plain summary for AI agents, from the same facts as the pages.""" guides = "\n".join(f'- [{p["h1"]}]({page_url(p["slug"])}): {p["description"]}' for p in PAGES) @@ -342,6 +358,8 @@ def llms_txt(): - [All free debate tools]({BASE}/tools): every tool below on one page {tools} +{localized_sections()} + ## Guides {guides} diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 510ad6f7b..51718f3db 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -1,6 +1,7 @@ // Runs after `vite build`. Writes real HTML for the public pages into dist/: -// index.html (the landing page), terms/index.html, privacy/index.html and -// 404.html, each with its own head. The unchanged build of index.html becomes +// index.html (the landing page), terms/index.html, privacy/index.html, the +// free tools, the Hungarian and Spanish pages (hu/…, es/…) and 404.html, each +// with its own head. The unchanged build of index.html becomes // app-shell.html, which vercel.json serves for app routes. import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; @@ -66,6 +67,7 @@ function pageAssets(manifest, module, shell) { function pageHtml(shell, page, assets) { const { head } = page; let html = shell; + html = replaceOnce(html, //, ``, ''); html = replaceOnce(html, /[^<]*<\/title>/, `<title>${escapeText(head.title)}`, 'title'); for (const [pattern, value, label] of [ [//, ``, 'description'], @@ -86,6 +88,10 @@ function pageHtml(shell, page, assets) { const url = `${SITE_ORIGIN}${head.canonicalPath}`; added.push(``, ``); } + // A page that exists in English, Hungarian and Spanish names all three, itself included. + for (const { hreflang, href } of page.alternates ?? []) { + added.push(``); + } for (const href of assets.styles) added.push(``); for (const href of assets.scripts) added.push(``); if (page.structuredData) { diff --git a/scripts/sitemap.mjs b/scripts/sitemap.mjs index 9d0e20922..ca485cd0e 100644 --- a/scripts/sitemap.mjs +++ b/scripts/sitemap.mjs @@ -1,7 +1,8 @@ // Runs after scripts/prerender.mjs. Writes dist/sitemap.xml from the HTML pages // in dist/: every page that names itself as canonical and is not noindex. Each // is the dateModified the page itself declares in its JSON-LD; a page -// without one is listed without a lastmod rather than with a made-up date. +// without one is listed without a lastmod rather than with a made-up date. A +// page with hreflang links in its head gets the same alternates as xhtml:link. import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; @@ -34,15 +35,23 @@ for (const file of htmlPages(DIST)) { } const lastmod = html.match(/"dateModified":"(\d{4}-\d{2}-\d{2})"/)?.[1]; if (!lastmod) console.log(`sitemap: ${route} declares no dateModified; listed without lastmod`); - urls.push({ url, lastmod }); + const alternates = [...html.matchAll(//g)] + .map(([, hreflang, href]) => ({ hreflang, href })); + urls.push({ url, lastmod, alternates }); } urls.sort((a, b) => (a.url === `${SITE_ORIGIN}/` ? -1 : b.url === `${SITE_ORIGIN}/` ? 1 : a.url.localeCompare(b.url))); const body = urls - .map(({ url, lastmod }) => ` \n ${url}\n${lastmod ? ` ${lastmod}\n` : ''} `) + .map(({ url, lastmod, alternates }) => [ + ' ', + ` ${url}`, + ...(lastmod ? [` ${lastmod}`] : []), + ...alternates.map(({ hreflang, href }) => ` `), + ' ', + ].join('\n')) .join('\n'); writeFileSync( join(DIST, 'sitemap.xml'), - `\n\n${body}\n\n`, + `\n\n${body}\n\n`, ); -console.log(`sitemap: ${urls.length} URLs, ${urls.filter((entry) => entry.lastmod).length} with lastmod`); +console.log(`sitemap: ${urls.length} URLs, ${urls.filter((entry) => entry.lastmod).length} with lastmod, ${urls.filter((entry) => entry.alternates.length).length} with hreflang`); diff --git a/src/App.tsx b/src/App.tsx index e4de6d57a..7716d118f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import { hasCompletedOnboarding, useOnboardingStore } from "@/hooks/use-onboardi import { OfflineIndicator } from "@/components/OfflineIndicator"; import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; -import { appLayoutLoader, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules"; +import { appLayoutLoader, localizedRouteLoaders, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules"; import type { PrerenderedRoute } from "@/lib/seo/pages"; import { PageLabelProvider, usePrivateRouteHead } from "@/lib/seo/use-page-head"; import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton"; @@ -23,6 +23,8 @@ import { useTranslation } from "react-i18next"; import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config"; import { AUTH_CALLBACK_PATH } from "@/lib/auth-callback"; import { chooseUrlLanguage, searchWithoutLanguage } from "@/lib/url-language"; +import { LOCALIZED_PATHS } from "@/lib/seo/localized-routes"; +import { LocalizedRoute } from "@/components/LocalizedRoute"; /** * A page the build prerenders. mountApp loads its module before the first @@ -30,7 +32,10 @@ import { chooseUrlLanguage, searchWithoutLanguage } from "@/lib/url-language"; * instead of showing a route skeleton while the chunk resolves. */ function prerenderedPage(route: PrerenderedRoute) { - const loader: RouteLoader<{ default: ComponentType }> = routeLoaders[route]; + return prerenderedModule(routeLoaders[route]); +} + +function prerenderedModule(loader: RouteLoader<{ default: ComponentType }>) { const LazyPage = lazy(loader); return function PrerenderedPage() { const [Page] = useState(() => loader.loaded()?.default ?? LazyPage); @@ -42,6 +47,8 @@ function prerenderedPage(route: PrerenderedRoute) { const LandingPage = prerenderedPage("/"); const TermsPage = prerenderedPage("/terms"); const PrivacyPage = prerenderedPage("/privacy"); +// The Hungarian and Spanish addresses in src/config/localized-routes.json. +const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) })); const HomePage = lazy(routeLoaders["/app"]); const CurrentDebatePage = lazy(routeLoaders["/app/debate"]); const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]); @@ -320,7 +327,7 @@ function ScrollToTopOnNavigate(): null { } /** - * A ?lang= reached inside the app (the /es and /hu redirects): switch to it and + * A ?lang= reached inside the app (a link from elsewhere in it): switch to it and * drop it from the address. mountApp handles the one a page opens with. */ export function UrlLanguage(): null { @@ -339,9 +346,10 @@ function AppRoutes() { {/* Public Routes */} } /> - {/* Language home pages; vercel.json redirects them on a full load too. */} - } /> - } /> + {/* /hu, /es and their free tools: the same pages in that language, each at its own address. */} + {LOCALIZED_PAGES.map(({ path, route, language, Page }) => ( + } /> + ))} } /> } /> } /> diff --git a/src/components/LocalizedRoute.tsx b/src/components/LocalizedRoute.tsx new file mode 100644 index 000000000..5747516ce --- /dev/null +++ b/src/components/LocalizedRoute.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSettingsStore } from '@/lib/stores/settings-store'; +import { localizedPath, RouteLanguageProvider, type LocalizedRoute as EnglishRoute } from '@/lib/seo/localized-routes'; + +interface LocalizedRouteProps { + /** The English page this address translates. */ + route: EnglishRoute; + language: 'hu' | 'es'; + children: ReactNode; +} + +/** + * A Hungarian or Spanish address (/hu, /es/tools/…). Opening it chooses its + * language like a ?lang= link, so Start leads into the app in that language; + * mountApp already did that on a full page load. Picking another language + * with the switcher afterwards moves to that language's address, so the + * page and its URL never disagree. + */ +export function LocalizedRoute({ route, language, children }: LocalizedRouteProps) { + const chosen = useSettingsStore((state) => state.language); + const navigate = useNavigate(); + // The language this address last showed; React keeps this component between /hu and /es. + const shown = useRef(null); + + useEffect(() => { + if (useSettingsStore.getState().language !== language) useSettingsStore.getState().chooseLinkedLanguage(language); + }, [language]); + + useEffect(() => { + if (chosen === language) { + shown.current = language; + } else if (shown.current === language) { + // Only a change after the page showed its own language: arriving from another page passes through here first. + navigate(localizedPath(route, chosen), { replace: true }); + } + }, [chosen, language, navigate, route]); + + return {children}; +} diff --git a/src/components/free-tools/ToolPageLayout.tsx b/src/components/free-tools/ToolPageLayout.tsx index 271138e55..812add79b 100644 --- a/src/components/free-tools/ToolPageLayout.tsx +++ b/src/components/free-tools/ToolPageLayout.tsx @@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Footer from '@/components/landing/Footer'; import { usePageHead } from '@/lib/seo/use-page-head'; -import { jsonLd, type FaqEntry, type ToolRoute } from '@/lib/seo/tool-pages'; +import { jsonLd, type FaqEntry, type ToolPageRoute, type ToolRoute } from '@/lib/seo/tool-pages'; +import { localizedPath, useRouteLanguage } from '@/lib/seo/localized-routes'; +import site from '@/config/site.json'; import { cn } from '@/lib/utils'; import { MAIN_CONTENT_ID, SkipLink } from '@/components/layout/SkipLink'; @@ -14,6 +16,9 @@ export const FREE_TOOLS = [ { route: '/tools/speech-time-calculator', name: 'Speech time calculator', blurb: 'Paste a speech to see how long it takes to deliver and which speech slots it fits.' }, ] as const; +/** A free tool's English route, with its name and blurb in the page's language. */ +export type FreeTool = { route: ToolPageRoute; name: string; blurb: string }; + /** Long-form copy under a tool: a readable measure and plain headings. */ export function ToolArticle({ children, className }: { children: ReactNode; className?: string }) { return ( @@ -59,17 +64,18 @@ export function ToolCta({ title, children }: { title: string; children: ReactNod ); } -function OtherTools({ current }: { current: ToolRoute }) { +function OtherTools({ current, tools }: { current: ToolRoute; tools: readonly FreeTool[] }) { const { t } = useTranslation('tools'); - const others = FREE_TOOLS.filter((tool) => tool.route !== current); - if (others.length === FREE_TOOLS.length) return null; + const language = useRouteLanguage(); + const others = tools.filter((tool) => tool.route !== current); + if (others.length === tools.length) return null; return (