https://learn-practice-work.vercel.app — the academy at the root, the portfolio at
/portfolio/, the ten decks at /ai-testing-lecture-N/. Every canonical, og:url,
hreflang, JSON-LD @id, sitemap.xml and robots.txt in the tree names that origin, and
tests/unit/hreflang.spec.ts holds them to it.
Vercel also serves each deployment on its own hostname —
https://learn-practice-work-73spot260-amielnoy-9725s-projects.vercel.app/ is the current
one. It is for inspecting a build, and it must never appear in a canonical, a sitemap or a
shared link: it is a second origin serving identical content, which is exactly the duplicate
a canonical exists to prevent, and it stops being production at the next deploy.
- Portfolio site (
artifacts/portfolio/) — public personal/professional marketing site for Amiel Peled - AI Testing Academy (
artifacts/ai-testing-academy/) — public educational/course landing page - AI Testing Lecture 1 (
artifacts/ai-testing-lecture-1/) — public presentation/slides
- API Server (
server/) — backend API, not a public-facing web page
- Hiring managers, engineering leaders, and teams looking for AI Test Automation & DevOps consultants (Portfolio)
- QA engineers, developers, and students learning AI-powered testing (AI Testing Academy)
- Portfolio: "AI Test Automation", "DevOps Tech Lead", "Playwright", "Quality Engineering", "Israel"
- AI Testing Academy: "AI Testing", "Test Automation", "DevOps", "Playwright course"
- Portfolio: React SPA (Wouter routing), served via Vite. All routes share one HTML shell — content is client-rendered.
- AI Testing Academy: React + TypeScript SPA (Vite) with an empty HTML shell; all content is JS-rendered.
- AI Testing Lecture 1: React SPA (Vite), single-page slides presentation.
Both public content sites are bilingual on a single set of URLs — ?lang= is the first thing
each site's i18n reads. Before this, nothing told Google a Hebrew version existed: the shell
was hardcoded to lang="en" dir="ltr", the sitemap listed one URL, and there was no hreflang
anywhere. The Hebrew half of the product was effectively unindexed, for an audience that
searches in Hebrew.
- Reciprocal
hreflangclusters (en,he,x-default) in the markup and repeated on every<url>in the sitemap. Reciprocity matters: a one-way or partial annotation is treated as unconfirmed and the whole cluster is dropped. - Self-referential canonicals. Each variant canonicalises to itself, otherwise the canonical contradicts the hreflang and the alternates are ignored. Only the query is rewritten — the origin stays whatever the document declares, which is the canonical home above.
- A head script that runs before React, setting
lang,dir, the title, the description and the canonical. It exists so a crawler that renders the page sees one consistent document, and so a Hebrew load has no flash of English/LTR. In the academy it mirrors the priority order insrc/lib/i18n.ts(query → stored → browser); an e2e test asserts the two agree. - Hebrew
<title>anddescription. An English title on the Hebrew URL ranks for nothing a Hebrew speaker would type. og:locale:alternateand a sitemap for the deck, which had neither a sitemap nor a robots.txt.
Guarded by tests/unit/hreflang.spec.ts (declarations and sitemap agree, reciprocity holds)
and tests/e2e/seoHead.spec.ts (each URL really serves that language, before hydration).
- Social previews of a Hebrew link show English. Facebook, WhatsApp and LinkedIn scrapers
do not run JS, so they only ever see the static
og:*tags. Fixing this needs path-based variants (/he/) rendered at build time — the same change that would letlang/dirbe correct in the raw HTML rather than applied by script. - Each lecture deck is declared on
/slide1, not on its root. At/the app renders the deck inside an<iframe src="/slide1">, so the root is a ~300-byte shell to a crawler, and the iframe drops?lang— which means/ai-testing-lecture-1/?lang=heshows English. That is a real bilingual defect, not just an SEO one, but the fix is inApp.tsx, which is marked a platform contract file ("do not restructure"). Declaring the root as a language variant would have pointed Google at three URLs that do not differ. This applies to all ten decks, every one of which is published now — it used to be only the first. - Deep links depend on the host's rewrite rules — resolved. A deck's routes (
/slide1,/allslides) are not files, so a static host has to rewrite them. GitHub Pages could not, and served them through each app's own404.html— the right page under a 404 status, on exactly the URLs thehreflangdeclarations nominate. That is what moved the deployment:deploy/vercel/config.jsonrewrites them at 200, one rule covering all ten decks, andtests/unit/vercelRoutes.spec.tsfails if that stops being true.
Both public sites loaded Google Fonts in a way that blocked the first paint, which puts LCP at
risk on mobile connections. Fixed in index.html for each site:
- Non-blocking load.
rel="preload" as="style"fetches the CSS early, and the stylesheet is applied through the print-media swap (media="print" onload="this.media='all'"), with a<noscript>fallback.display=swappaints text in the fallback face immediately. - Portfolio: removed a font it never rendered.
index.htmlrequested Inter while the page renders in Plus Jakarta Sans and Space Mono — a render-blocking round trip for nothing. - Portfolio: removed a remote CSS
@import. The real fonts were pulled in by@import url(https://fonts.googleapis.com/…)at the top ofsrc/index.css, which is worse than a blocking<link>: the browser only discovers it after the app stylesheet has downloaded and parsed, serialising HTML → app CSS → Google CSS → font files. Hoisted intoindex.html. - Trimmed the axes to what is rendered, verified in the browser across every route:
- Heebo
400;500;700;800→400;700;800(500 was never rendered). - Plus Jakarta Sans
ital,wght@0,300..800;1,300..800→wght@400..700(no italic anywhere in the source; a whole second variable file was being fetched). - Space Mono
ital,wght@0,400;0,700;1,400;1,700→wght@400;700(two unused italic files).
- Heebo
Net effect on the portfolio: two CSS requests down to one, and six font files down to three, with rendering byte-identical.
Guarded by tests/unit/fontLoading.spec.ts, which reads the shipped HTML and fails if a
blocking font stylesheet, a remote CSS @import, or a missing <noscript> fallback returns.
- Heebo 600 renders as 700. The academy's CSS asks for
font-weight:600on ~27 elements but 600 is not among the requested weights, so the browser matches the nearest available cut. Adding600to the URL costs one more font file; changing those rules to700costs nothing. Deliberately left alone — it is a typography decision, not a performance one.
- (None yet)