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
12 changes: 8 additions & 4 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,19 @@ The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are pr

[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `<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 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), or when an image has no alt text, a button has no name or a form field has no label.

The guide pages in `public/<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 a later `modified` date for that page alone; no page is dated earlier than `CONTENT_DATE`, which covers the copy every guide shares. 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.
[`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).

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.

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 `*`.

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

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

Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi
npm run build
```

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

## Production service-worker smoke

Expand Down
5 changes: 4 additions & 1 deletion e2e/hungarian-parity.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test';
import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth';

const site = JSON.parse(readFileSync('src/config/site.json', 'utf8')) as { product: string };

function collectRuntimeErrors(page: Page) {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
Expand Down Expand Up @@ -32,7 +35,7 @@ test.describe('Hungarian parity', () => {
await page.goto('/', { waitUntil: 'domcontentloaded' });

await expectHungarianDocument(page);
await expect(page.getByRole('heading', { level: 1, name: 'Készíts eseteket és gyakorolj fordulókat MI-vel' })).toBeVisible();
await expect(page.getByRole('heading', { level: 1, name: `${site.product}: MI-alapú vitafelkészítés és gyakorlás` })).toBeVisible();
await expect(page.getByText(/^Attól függ, mire van szükséged\. 2026\. október állapot szerint/)).toBeAttached();
if ((page.viewportSize()?.width ?? 0) < 768) {
await expect(page.getByRole('button', { name: 'Menü megnyitása' })).toBeVisible();
Expand Down
8 changes: 4 additions & 4 deletions e2e/loading-performance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page,
await page.goto('/', { waitUntil: 'domcontentloaded' });

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

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

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

await page.goto('/', { waitUntil: 'load' });
const heading = page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ });
const heading = page.getByRole('heading', { name: /: AI debate prep and practice$/ });
await expect(heading).toBeVisible();
const jamRequest = page.waitForRequest('https://js.jam.dev/**');
await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
Expand All @@ -50,7 +50,7 @@ test('pagehide removes the Jam frame and a persisted pageshow does not re-add it
test('public routes keep the real UI after refresh', async ({ page }) => {
await setLocale(page, 'en');
for (const [path, label] of [
['/', /Prepare Cases and Practice Rounds/],
['/', /: AI debate prep and practice$/],
['/login', 'Continue with Google'],
] as const) {
await page.goto(path);
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
"docs:check": "node scripts/check-docs.mjs",
"backend:status": "node scripts/backend-status.mjs",
"ci:failures": "node scripts/ci-failures.mjs",
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-site.mjs && node scripts/check-build-boundaries.mjs",
"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",
"build:dev": "vite build --mode development",
"lint": "eslint . && npm run lint:repo",
"lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json && tsc --noEmit -p packages/core/tsconfig.json",
"preview": "vite preview",
"smoke:jev": "node scripts/smoke-jev.mjs",
"seo:indexnow": "python3 scripts/indexnow_submit.py",
"test": "vitest run",
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
"start:collaboration": "tsx services/collaboration/src/index.ts",
Expand Down
Loading
Loading