Skip to content

Commit 6d20371

Browse files
Programcodeclaude
andcommitted
Merge origin/main (#233, #235, #234) into seo/hu-es-pages
Conflicts resolved: - i18n.ts: keep both default variable sets, product/brand/host from main and trialDays/proMonthly from this branch. - en/es/hu.json: main's new hero headline and subheadline, this branch's {{trialDays}} trial badge. - prerender/entry.tsx, check-site.mjs, docs/operations.md: keep both sides (main's entity graph and FAQ checks, this branch's localized pages and hreflang check). GuideHub on /hu and /es now shows only the free tools at their localized addresses, plus one link to the English guides on the home page ("Angol nyelvű útmutatók", "Guías en inglés"). The Hungarian tool labels use this branch's names (Vitaidőmérő, Vitatétel-generátor, Jegyzetlap), and "cáfolást" follows the review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents fe00edc + 92d7113 commit 6d20371

71 files changed

Lines changed: 2011 additions & 919 deletions

File tree

Some content is hidden

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

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ coverage/
2828

2929
# TypeScript / Vite cache
3030
*.tsbuildinfo
31+
32+
# Python bytecode from the scripts/ generators
33+
__pycache__/
34+
*.pyc
3135
.vite/
3236
.playwright-cli/
3337
.playwright-mcp/

‎docs/operations.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,19 @@ The landing page and the free tools also exist in Hungarian and Spanish, each at
6060

6161
[`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>`.
6262

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 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.
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.
6464

65-
The guide pages in `public/<slug>/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.
65+
[`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).
6666

67-
Every guide opens with the reader's main question as its first H2 and an answer of at most 100 words, kept in [`scripts/seo_answers.py`](../scripts/seo_answers.py); the generator refuses a page without one or with a longer answer. Each guide shows visible "Published" and "Updated" dates (`PUBLISHED_DATE` or a `published` key, and the `modified` date) that match its Article JSON-LD. Prices and plan facts are stated "as of" the month in `factsCheckedOn` in `src/config/site.json`; move that date when you recheck them, and the generated About line on every guide, `llms.txt` and the landing page follow. Copy in `seo_guides.py` and `seo_answers.py` that names the month itself (prices and competitor facts) needs the same edit: search those files for "As of". The generator also writes `public/llms.txt`, the plain summary for AI agents. `public/robots.txt` allows AI crawlers by name in their own group, which repeats the private-route rules because a crawler that matches a named group ignores `*`.
67+
The guide pages in `public/<slug>/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). Each page's "Related" block shows up to six pages from its own topic cluster and one from each of two other clusters, picked from `CLUSTERS`; every page in `PAGES` must sit in exactly one cluster, and the generator stops when one does not. Paragraphs also get up to three in-body links from `LINK_PHRASES`, specific phrases first; `python3 scripts/generate_seo_content.py --links` lists the links each page got. A guide entry can set a later `modified` date for that page alone; no page is dated earlier than `CONTENT_DATE`, which covers the copy every guide shares, including the related and in-body links, so move it when `CLUSTERS` or `LINK_PHRASES` change. Sections hold paragraphs and `h3`, `steps`, `list`, `table` and `example` blocks; paragraphs, list items and table cells can hold Markdown-style links to a site path or, for a primary source, an `https://` URL. Every guide needs FAQs of its own in `seo_guides.py`; the generator refuses a page without them, so no two pages share boilerplate questions. [`src/test/seo/guide-content.test.ts`](../src/test/seo/guide-content.test.ts) fails when a guide has fewer than 700 words outside its related and About blocks, or when a sentence longer than 60 characters appears on more than three guides.
6868

69-
To retire a guide, delete its `PAGES` entry 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.
69+
Every guide opens with the reader's main question as its first H2 and an answer of at most 100 words, kept in [`scripts/seo_answers.py`](../scripts/seo_answers.py); the generator refuses a page without one or with a longer answer. Each guide shows visible "Published" and "Updated" dates (`PUBLISHED_DATE` or a `published` key, and the `modified` date) that match its Article JSON-LD. Prices and plan facts are stated "as of" the month in `factsCheckedOn` in `src/config/site.json`; move that date when you recheck them, and the pricing page's About block, `llms.txt` and the landing page follow. Every other guide has a one-line About that links to the landing page and pricing instead of repeating the plan facts. Copy in `seo_guides.py` and `seo_answers.py` that names the month itself (prices and competitor facts) needs the same edit: search those files for "As of". The generator also writes `public/llms.txt`, the plain summary for AI agents. `public/robots.txt` allows AI crawlers by name in their own group, which repeats the private-route rules because a crawler that matches a named group ignores `*`.
7070

71-
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.
71+
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.
72+
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.
74+
75+
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.
7276

7377
[`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.
7478

‎docs/testing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi
102102
npm run build
103103
```
104104

105-
This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility (including heading order) with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules, the Spanish and Hungarian catalogs, and the signed-in layout from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
105+
This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility (including heading order) with `scripts/check-site.mjs`, checks that every sitemap page has at least 3 inbound links and the home page links to at least 15 with `scripts/check-links.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules, the Spanish and Hungarian catalogs, and the signed-in layout from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
106106

107107
## Production service-worker smoke
108108

‎e2e/hungarian-parity.spec.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { readFileSync } from 'node:fs';
12
import { expect, test, type Page } from '@playwright/test';
23
import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth';
34

5+
const site = JSON.parse(readFileSync('src/config/site.json', 'utf8')) as { product: string };
6+
47
function collectRuntimeErrors(page: Page) {
58
const errors: string[] = [];
69
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
@@ -32,7 +35,7 @@ test.describe('Hungarian parity', () => {
3235
await page.goto('/', { waitUntil: 'domcontentloaded' });
3336

3437
await expectHungarianDocument(page);
35-
await expect(page.getByRole('heading', { level: 1, name: 'Készíts eseteket és gyakorolj fordulókat MI-vel' })).toBeVisible();
38+
await expect(page.getByRole('heading', { level: 1, name: `${site.product}: MI-alapú vitafelkészítés és gyakorlás` })).toBeVisible();
3639
await expect(page.getByText(/^Attól függ, mire van szükséged\. 2026\. október állapot szerint/)).toBeAttached();
3740
if ((page.viewportSize()?.width ?? 0) < 768) {
3841
await expect(page.getByRole('button', { name: 'Menü megnyitása' })).toBeVisible();

‎e2e/loading-performance.spec.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page,
1212
await page.goto('/', { waitUntil: 'domcontentloaded' });
1313

1414
// Jam waits for an interaction, so the landing page is up before any recorder request.
15-
await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 });
15+
await expect(page.getByRole('heading', { name: /: AI debate prep and practice$/ })).toBeVisible({ timeout: 2_000 });
1616
expect(jamRequests).toEqual([]);
1717

1818
const jamRequest = page.waitForRequest('https://js.jam.dev/**');
1919
await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
2020
await jamRequest;
21-
await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible();
21+
await expect(page.getByRole('heading', { name: /: AI debate prep and practice$/ })).toBeVisible();
2222
await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0);
2323
});
2424

@@ -27,7 +27,7 @@ test('pagehide removes the Jam frame and a persisted pageshow does not re-add it
2727
await page.route('https://js.jam.dev/**', (route) => route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }));
2828

2929
await page.goto('/', { waitUntil: 'load' });
30-
const heading = page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ });
30+
const heading = page.getByRole('heading', { name: /: AI debate prep and practice$/ });
3131
await expect(heading).toBeVisible();
3232
const jamRequest = page.waitForRequest('https://js.jam.dev/**');
3333
await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
@@ -50,7 +50,7 @@ test('pagehide removes the Jam frame and a persisted pageshow does not re-add it
5050
test('public routes keep the real UI after refresh', async ({ page }) => {
5151
await setLocale(page, 'en');
5252
for (const [path, label] of [
53-
['/', /Prepare Cases and Practice Rounds/],
53+
['/', /: AI debate prep and practice$/],
5454
['/login', 'Continue with Google'],
5555
] as const) {
5656
await page.goto(path);

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
"docs:check": "node scripts/check-docs.mjs",
1616
"backend:status": "node scripts/backend-status.mjs",
1717
"ci:failures": "node scripts/ci-failures.mjs",
18-
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-site.mjs && node scripts/check-build-boundaries.mjs",
18+
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-site.mjs && node scripts/check-links.mjs && node scripts/check-build-boundaries.mjs",
1919
"build:dev": "vite build --mode development",
2020
"lint": "eslint . && npm run lint:repo",
2121
"lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs",
2222
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json && tsc --noEmit -p packages/core/tsconfig.json",
2323
"preview": "vite preview",
2424
"smoke:jev": "node scripts/smoke-jev.mjs",
25+
"seo:indexnow": "python3 scripts/indexnow_submit.py",
2526
"test": "vitest run",
2627
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
2728
"start:collaboration": "tsx services/collaboration/src/index.ts",

0 commit comments

Comments
 (0)