diff --git a/docs/operations.md b/docs/operations.md index 85cfa633f..6b05f8d13 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -50,7 +50,7 @@ Vercel builds and serves the frontend from the Git repository. A push to `main` - `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`; - Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header. -After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization and SoftwareApplication JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. +After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. 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. @@ -58,8 +58,12 @@ 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 `` 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 the `/tools` pages lacks its question H2 (`#short-answer`) as the first H2, 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//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. +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 `*`. + 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. Do not hardcode those strings in new code or copy. diff --git a/docs/testing.md b/docs/testing.md index 0d8150788..093d3cc47 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules 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 with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment. ## Production service-worker smoke diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index 8a0bc902d..8c8359a6f 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -32,7 +32,8 @@ test.describe('Hungarian parity', () => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await expectHungarianDocument(page); - await expect(page.getByText('MI-alapú vitafelkészülés, ami előnyt ad minden fordulóban.')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Készíts eseteket és gyakorolj fordulókat MI-vel' })).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(); } else { diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 46c1d2a86..8a13a2a83 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -9,14 +9,14 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page, await page.goto('/', { waitUntil: 'domcontentloaded' }); await jamRequest; - await expect(page.getByRole('heading', { name: /Prep Faster/ })).toBeVisible({ timeout: 2_000 }); + await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 }); await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); }); test('public routes keep the real UI after refresh', async ({ page }) => { await setLocale(page, 'en'); for (const [path, label] of [ - ['/', /Prep Faster/], + ['/', /Prepare Cases and Practice Rounds/], ['/login', 'Continue with Google'], ] as const) { await page.goto(path); diff --git a/index.html b/index.html index 8ad297185..36bf83a47 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ DebateLab - + @@ -21,11 +21,11 @@ - + - +