Content and seo
Skill andymai/gridfinity-layout-tool/.claude/skills/content-and-seo
SEO/content pages: adding a landing page end-to-end (content/*.md → scripts/build-content.ts → public/<slug>/index.html), the "⚠ ... chars" SERP title/description warnings, sitemap.xml + hreflang, regenerating OG cards and screenshots (pnpm run gen:seo-images, gen:example-thumbnails), /designer + /baseplate static heads (scripts/build-route-entries.ts), editing index.html meta/JSON-LD, and the phone-scan handoff endpoint api/scan-session.ts ("scan" PR scope).From its SKILL.md
npx -y skills add andymai/gridfinity-layout-tool --skill content-and-seoAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
11.2 KB, as published. Nobody here has run it
Content and SEO
When to use
- Adding or editing a marketing/landing page, FAQ, sitemap entry, or meta tags.
pnpm run build:contentprints⚠ ... charswarnings, orpnpm run buildfails inbuild-route-entrieswith "pattern not found".- Regenerating the committed screenshots/OG cards or gallery thumbnails.
- Touching
api/scan-session.ts/api/scan-session/[token].ts(the QR phone-scan handoff).
Mental model
content/<slug>.md(+ translated copies incontent/<locale>/<slug>.md) are compiled byscripts/build-content.tsinto staticpublic/<slug>/index.htmlpages,public/sitemap.xml, and a hashedpublic/content.<hash>.css. This runs as the first step of bothpnpm run devandpnpm run build(seepackage.json) — it is not a watcher.- All generated outputs are gitignored (see the "Generated content pages" block in
.gitignore) exceptpublic/privacy/index.htmlandpublic/terms/index.html, which are committed —build:contentstill regenerates them, so it can legitimately dirty your working tree. - Serving is duplicated in two places that must stay in sync:
vercel.jsonrewrites (production) andscripts/vite-plugin-content-routes.tsSLUGS/LOCALIZED_SLUGS(dev middleware). A page missing from one works in the other environment and 404s/falls into the SPA shell in the misconfigured one. - Content locales (9: en + de/fr/es/pt-BR/nl/sv/nb/uk, hardcoded in
build-content.ts) are NOT the app i18n locales —jahas UI translations but no content pages (CONTENT_LOCALESinsrc/shell/Sidebar/learnLinks.ts). hreflang links and the language switcher auto-derive from which locale.mdfiles actually exist per slug. - SERP guards in
build-content.tsare warn-only:MAX_TITLE_LEN = 55guards the unique title before the appended" | Gridfinity Layout Tool"suffix;MAX_DESCRIPTION_LEN = 155. Missingtitle/descriptionfrontmatter is a hardprocess.exit(1); so issoftwareApplicationwithoutname+applicationCategory. - The SPA routes
/designerand/baseplateget their own crawler-visible heads a different way:scripts/build-route-entries.tsruns aftervite buildand regex-swaps title/meta/JSON-LD/#seo-fallbackin copies ofdist/index.html. JS stays byte-identical so the CSP hashes invercel.jsonremain valid. - Per-page OG image resolution: frontmatter
ogImage→public/og/<slug>.pngif it exists (perPageOgImage) → site-wide/og-image.png. OG cards and landing screenshots are committed PNGs, regenerated only by hand.
Recipe: add a content/landing page end-to-end
- Write
content/<slug>.md. Frontmatter (Frontmatterinterface inscripts/build-content.ts):title(≤55 chars),description(≤155),keywords,schema: Article | HowTo, optionalfaqs(emits FAQPage JSON-LD; answers allow inline markdown),breadcrumbs,howTo,softwareApplication,navCta. Copycontent/gridfinity-calculator.mdas a template. Body conventions:[CTA: text](url)renders the orange CTA button;sets intrinsic dimensions (CLS); blockquotes render as callouts. - Register the slug in
SITEMAP_PAGESinscripts/build-content.ts— pages not listed there build fine but never entersitemap.xml(privacy/termsare deliberately absent). - Add the slug to the English rewrite group in
vercel.json(/:slug(what-is-gridfinity|guide|...)) and toSLUGSinscripts/vite-plugin-content-routes.ts. - Add
public/<slug>/to.gitignore(match the existing block). - Internal linking: add an entry to
LEARN_LINKSinsrc/shell/Sidebar/learnLinks.ts(localized: falsefor English-only) plus itssidebar.learn.*key — see the i18n-changes skill. Consider a footer link too:FOOTER_LINKSinbuild-content.tsis hardcoded per locale and the footer template lists each link explicitly. - Optional OG card: append to
OG_CARDSinscripts/gen-seo-images.ts, regenerate (recipe below), commitpublic/og/<slug>.png—build:contentpicks it up automatically. - Bump
CONTENT_LASTMODinbuild-content.ts(hardcoded on purpose;new Date()would advertise the whole sitemap as updated every build). - Translations later: drop
content/<locale>/<slug>.mdfiles, then add the slug toLOCALIZED_SLUGSinvite-plugin-content-routes.ts, the localized rewrite group invercel.json, and fliplocalized: trueinlearnLinks.ts.
Recipe: regenerate SEO images / thumbnails
Both scripts drive a real browser against a separately running dev server (WebGL + WASM kernels; never mock these).
pnpm run dev # terminal 1, leave running
pnpm run gen:seo-images # terminal 2; BASE_URL=... if not :5173
ONLY=og pnpm run gen:seo-images # steps: planner, renders, baseplate, og
Outputs are committed: public/images/landing/ (in-page screenshots) and public/og/ (1200x630 cards). OG cards embed the landing screenshots as insets, so when a screenshot changes run its step before ONLY=og. Designer renders use the dev-only ?devThumbnails=1 route (src/features/bin-designer/components/DevThumbnailRoute/).
Gallery thumbnails are the same workflow: pnpm run gen:example-thumbnails (filter with EXAMPLE_ID=<id>,<id>) writes committed PNGs and draco-compressed meshes into src/features/bin-designer/data/examples/thumbnails/ and meshes/.
Scan-session (why an API file lives in this skill's scope)
api/scan-session.ts has nothing to do with SEO — it shares the scan PR scope. It is the phone-scan handoff for the bin designer's cutout scan import: desktop POST /api/scan-session mints a UUID token, stores {status:'pending'} in Redis (SCAN_SESSION_TTL_SECONDS = 600 in api/lib/scanSession.ts), and renders /scan/<token> as a QR code. The phone opens that URL (SPA rewrite in vercel.json → src/features/scan-capture/components/ScanPage/), traces the object, and POSTs an SVG outline to api/scan-session/[token].ts; the desktop polls GET on the same route (useScanSession.ts under src/features/bin-designer/components/panel/CutoutsSection/scanImport/).
Invariants when touching it: the uploaded SVG is validated but never injected into a live DOM (documented in api/lib/scanSession.ts — keep it that way); GET delivery is idempotent until TTL expiry (consuming on read would lose the scan on a transient desktop error); uploads use Redis KEEPTTL so retries cannot extend a session; no Redis → 503 so the client falls back to manual upload. Rate limits (api/lib/rateLimit.ts): scan.create and scan.upload 30/min, scan.poll 240/min. For the shared Redis/rate-limit/local-API-dev setup, see the share-api-collab skill.
Verification
pnpm run build:content # per-page "✓ Generated ..." + any ⚠ SERP warnings
pnpm run dev # open http://localhost:5173/<slug> and /de/<slug>
pnpm run build # proves build-route-entries still matches index.html
pnpm vitest run scripts/check-csp-hashes.test.ts # after editing index.html inline scripts
pnpm vitest run api/lib/scanSession.test.ts # after scan-session changes
Traps
| Symptom | Cause | Fix |
|---|---|---|
| Content edit merged to main, production unchanged | scripts/vercel-ignore.sh deploys main only on release merges or [deploy] commits | Wait for the next scheduled release, or put [deploy] in the squash commit — see the release-and-ci skill |
| New page works in dev, 404s/SPA-shells in prod (or vice versa) | Slug added to only one of vercel.json / vite-plugin-content-routes.ts; vercel.json-only changes also never auto-deploy | Add to both; note the localized groups list slugs separately |
| Markdown edits invisible in a running dev server | build:content runs once at dev startup; no watch | Rerun pnpm run build:content, refresh — the dev middleware reads public/ from disk per request, no restart needed |
build-route-entries: pattern not found for <label> fails the build | A head tag in index.html that scripts/build-route-entries.ts regex-swaps was edited/removed | Keep those meta/link tags matchable, or update the corresponding replaceOnce pattern in the same PR |
git status shows public/privacy/index.html / terms modified after a build | Committed pages regenerated with a new CSS hash or footer year (new Date().getFullYear()) | Real diff — commit it |
Page renders but is missing from sitemap.xml | Not registered in SITEMAP_PAGES (APP_ROUTES covers only /designer, /baseplate) | Add the entry |
| Search engines never re-crawl an updated page | CONTENT_LASTMOD is hardcoded and was not bumped | Bump it in scripts/build-content.ts |
gen:seo-images / gen:example-thumbnails hang then time out | No dev server on the expected port | Start pnpm run dev first; set BASE_URL otherwise |
| Localized content page links its logo/CTA to a 404 | Someone "fixed" homeUrl to /{locale}/ — no localized SPA root exists | Keep it /; in-app i18n auto-detects language (comment in generateHtml) |
For where landing-page-adjacent UI lives inside the app shell, see the feature-slices skill.