Skip to content

Commit 12eaab1

Browse files
authored
Merge pull request #236 from Programcode/seo/hu-es-pages
Indexable Hungarian and Spanish pages with hreflang (SEO spec 3)
2 parents 92d7113 + 6d20371 commit 12eaab1

41 files changed

Lines changed: 1534 additions & 157 deletions

Some content is hidden

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

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
5353
- 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`).
5454
- [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`).
5555
- 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`).
56-
- [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`).
56+
- [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`).
5757
- 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`).
5858

5959
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`).

‎.claude/skills/verify-debatelab/features/localization.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# Localization
22

3-
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.
3+
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.
44

55
## Sub-features
66

77
- 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.
88
- 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.
99
- loc-switcher: the switcher on `/login`, the 404 page, and the other public pages (`PublicPageShell`), besides the landing page and the app header.
10-
- 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).
10+
- 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.
1111
- loc-server-error: a refused promo code shows `serverErrors.promoInvalid` in the interface language while redeem-promo still answers in English beside its `code`.
1212

1313
## How to get to it (user POV)
@@ -30,4 +30,4 @@ $H drive recipes/localization --project mobile-chromium # 375 px
3030
- The switcher's own name is translated (`langSwitcher.label`); match it in all three languages.
3131
- The recipe reads the catalogs from `src/locales/`, so its expectations follow the strings. Match on keys, never on copied text.
3232
- The promo field shows only for an account that would start a Checkout, so the recipe marks its Pro demo user expired first.
33-
- `/es` and `/hu` on the host are a `vercel.json` redirect; the local stack proves only the client route.
33+
- `/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.

‎.claude/skills/verify-debatelab/recipes/localization.spec.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${lan
99
const en = catalog("en");
1010
const es = catalog("es");
1111
const hu = catalog("hu");
12-
const BRAND = "DebateLab by Pi-Stack";
12+
const site = JSON.parse(readFileSync("src/config/site.json", "utf8"));
13+
const BRAND = site.brand;
14+
const head = (text: string) => text.replace("{{brand}}", BRAND).replace("{{product}}", site.product);
1315
const LANGUAGE_NAMES = { en: "English", es: "Español", hu: "Magyar" } as const;
1416
/** The switcher's own name in whichever language is showing. */
1517
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
108110
await evidence.shot("04-not-found-es");
109111
});
110112

111-
test("loc-links: ?lang= and the /es and /hu home pages open the landing page in that language", async ({ page, evidence }) => {
113+
test("loc-links: ?lang= opens the landing page in that language, and /es and /hu are pages of their own", async ({ page, evidence }) => {
112114
await page.goto("/?lang=hu");
113115
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
114-
await expect(page).toHaveTitle(hu.pageHead.home.title.replace("{{brand}}", BRAND));
116+
await expect(page).toHaveTitle(head(hu.pageHead.home.title));
115117
// The choice is kept and the address drops the parameter.
116118
await expect.poll(() => new URL(page.url()).search).toBe("");
117119
await evidence.shot("01-landing-lang-hu");
118120

119121
await page.goto("/es");
120-
await expect.poll(() => new URL(page.url()).pathname).toBe("/");
121122
await expect(page.locator("html")).toHaveAttribute("lang", "es");
122-
await expect(page).toHaveTitle(es.pageHead.home.title.replace("{{brand}}", BRAND));
123+
await expect(page).toHaveTitle(head(es.pageHead.home.title));
124+
expect(new URL(page.url()).pathname).toBe("/es");
123125
await evidence.shot("02-landing-es");
124126

125127
await page.goto("/hu");
126-
await expect.poll(() => new URL(page.url()).pathname).toBe("/");
128+
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
129+
// Start leads into the app in Hungarian, and the choice survives a reload.
130+
await page.getByRole("link", { name: hu.nav.startTrial }).first().click();
131+
await expect.poll(() => new URL(page.url()).pathname).not.toBe("/hu");
127132
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
128133
await page.reload();
129134
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
130-
await evidence.shot("03-landing-hu-after-reload");
135+
await evidence.shot("03-start-from-hu");
131136
});
132137

133138
test("loc-server-error: a refused promo code reads in the interface language", async ({ signedIn: page, user, evidence }) => {

‎docs/operations.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders
5454

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

57+
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.
58+
5759
`/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.
5860

59-
[`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>`.
61+
[`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>`.
6062

61-
[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when 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.
63+
[`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.
6264

6365
[`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).
6466

@@ -68,7 +70,7 @@ Every guide opens with the reader's main question as its first H2 and an answer
6870

6971
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.
7072

71-
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.
73+
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.
7274

7375
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.
7476

‎e2e/localization.spec.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${lan
1212
const en = catalog('en');
1313
const es = catalog('es');
1414
const hu = catalog('hu');
15-
const BRAND = 'DebateLab by Pi-Stack';
15+
const site = JSON.parse(readFileSync('src/config/site.json', 'utf8'));
16+
const BRAND = site.brand;
1617
const LANGUAGES = { en: 'English', es: 'Español', hu: 'Magyar' } as const;
1718
const SWITCHER = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join('|')})$`);
18-
const brand = (text: string) => text.replace('{{brand}}', BRAND);
19+
const brand = (text: string) => text.replace('{{brand}}', BRAND).replace('{{product}}', site.product);
1920

2021
const APP_ROUTES = ['/app', '/app/generate', '/app/attack', '/app/history', '/app/settings'];
2122
const PUBLIC_ROUTES = ['/terms', '/privacy', '/tools', '/no-such-page'];
@@ -118,26 +119,31 @@ test('sign-in and not-found pages carry the language switcher', async ({ page })
118119
await shot(page, '04-not-found-es');
119120
});
120121

121-
test('?lang= and the /hu and /es home pages open the landing page in that language', async ({ page }) => {
122+
test('?lang= opens the landing page in that language, and /hu and /es are pages of their own', async ({ page }) => {
122123
await page.goto('/?lang=hu');
123124
await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
124125
await expect(page).toHaveTitle(brand(hu.pageHead.home.title));
125126
await expect.poll(() => new URL(page.url()).search).toBe('');
126127
await shot(page, '01-landing-lang-hu');
127128

128129
await page.goto('/es');
129-
await expect.poll(() => new URL(page.url()).pathname).toBe('/');
130130
await expect(page.locator('html')).toHaveAttribute('lang', 'es');
131131
await expect(page).toHaveTitle(brand(es.pageHead.home.title));
132+
// Still /es after the app takes over: the address and canonical stay Spanish.
133+
expect(new URL(page.url()).pathname).toBe('/es');
134+
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${site.origin}/es`);
132135
await shot(page, '02-landing-es');
133136

134137
await page.goto('/hu');
135-
await expect.poll(() => new URL(page.url()).pathname).toBe('/');
136138
await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
137-
// The choice is kept, not just shown once.
139+
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${site.origin}/hu`);
140+
// The choice is kept, not just shown once: Start leads into the app in Hungarian.
141+
await page.getByRole('link', { name: hu.nav.startTrial }).first().click();
142+
await expect.poll(() => new URL(page.url()).pathname).not.toBe('/hu');
143+
await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
138144
await page.reload();
139145
await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
140-
await shot(page, '03-landing-hu-after-reload');
146+
await shot(page, '03-start-from-hu');
141147
});
142148

143149
test('a server refusal with a code reads in the interface language', async ({ page }) => {

0 commit comments

Comments
 (0)