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
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
6 changes: 3 additions & 3 deletions .claude/skills/verify-debatelab/features/localization.md
Original file line number Diff line number Diff line change
@@ -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 `<page> · 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 `<html lang>`, 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 `<html lang>`, 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)
Expand All @@ -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.
19 changes: 12 additions & 7 deletions .claude/skills/verify-debatelab/recipes/localization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("|")})$`);
Expand Down Expand Up @@ -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 }) => {
Expand Down
8 changes: 5 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/vitaidomero` 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 `<html lang>`, 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. The old homepage links `/?lang=hu` and `/?lang=es` get a `vercel.json` 301 to `/hu` and `/es`. Vercel appends the original query, so the browser arrives at `/hu?lang=hu`, which is canonical to `/hu`; the client drops `?lang=` on load. `?lang=` on any other page (`/app?lang=hu`) is not redirected.

`/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 `<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>`.
[`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 `<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>`.

[`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 a FAQPage question is not visible on its page, when the home page's entity graph has an unresolved `@id`, a `WebSite` or `SoftwareApplication` not named for the product, or no `Organization.sameAs`, when a page's `publisher` or `about` names an `@id` the home page does not define, 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 a FAQPage question is not visible on its page, when the home page's entity graph has an unresolved `@id`, a `WebSite` or `SoftwareApplication` not named for the product, or no `Organization.sameAs`, when a page's `publisher` or `about` names an `@id` the home page does not define, 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 `<html lang>`, or differ from the set on a page they name.

[`scripts/check-links.mjs`](../scripts/check-links.mjs) runs after it and fails the build when a sitemap page other than `/terms`, `/privacy` and `/expocit/sources` has fewer than 3 inbound links from other sitemap pages, or when the home page links to fewer than 15 distinct sitemap pages. `node scripts/check-links.mjs --report` prints every count after a build. The home page reaches the guides and tools through [`GuideHub.tsx`](../src/components/landing/GuideHub.tsx).

Expand All @@ -68,7 +70,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, its `CLUSTERS` and `LINK_PHRASES` entries, 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.

The site origin, product name, brand line, organization name and the organization's `sameAs` profiles 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. App strings name the product with `{{product}}`, `{{brand}}` and `{{host}}`, which [`src/i18n.ts`](../src/i18n.ts) fills from `site.json` for every key.
The site origin, product name, brand line, organization name and the organization's `sameAs` profiles 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}}`, `{{brand}}` and `{{host}}` 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. App strings name the product with `{{product}}`, `{{brand}}` and `{{host}}`, which [`src/i18n.ts`](../src/i18n.ts) fills from `site.json` for every key.

After a production deploy, and only with Adam's approval, `npm run seo:indexnow` sends every URL in the sitemap to IndexNow in one request to `api.indexnow.org`, which shares it with Bing, Yandex, Seznam, Naver and the other engines that take part (not Google). It reads `dist/sitemap.xml` when a build is present and the live sitemap otherwise. `-- --only /tools /` sends just those pages, and `-- --dry-run` prints the request without sending it. The key file is `public/<key>.txt`. It is not wired into CI.

Expand Down
20 changes: 13 additions & 7 deletions e2e/localization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -118,26 +119,31 @@ 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));
await expect.poll(() => new URL(page.url()).search).toBe('');
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 }) => {
Expand Down
Loading
Loading