Sitemap + Markdown Alternates for AI Citability
📝 TLDR
zapiszprzepis.pl ships no sitemap and no robots.txt today, and every page exists only as
rendered HTML — so search crawlers discover recipes only by following links, and an LLM that fetches a
recipe page gets the content buried in galleries, action bars and rails. This spec adds, as two
independently shippable phases: (1) a generated sitemap.xml + robots.txt covering every public
page, and (2) a Markdown alternate of each recipe and category page at a .md suffix URL, plus a
root llms.txt index — so an AI agent can fetch …/przepis/{slug}.md and get the recipe as clean,
quotable Markdown with a stable, citable URL. (Future behavior — none of this exists yet.)
📝 Problem Statement
- No discoverability surface.
find src -iname "*sitemap*" -o -iname "*robots*"returns nothing; there is noapp/sitemap.ts/app/robots.ts. Crawlers must walk links, with no freshness or canonical-URL signal. The project’s own platform research (context/foundation/infrastructure.md) scores platforms onllms.txtand per-page.mdavailability as an agent-friendliness signal the site does not yet emit itself. - HTML is a poor citation source.
src/app/przepis/[slug]/page.tsxwraps the content inRecipeGallery,RecipeActions,DishVersionsBar, breadcrumbs,LockedGalleryCTAs andSimilarRecipes. An LLM must strip all of it, and there is no “just the recipe as text” URL to cite. - The data is already clean, public and parsed. Recipes expose title,
ingredients/steps(Json), times, servings, tags,source_url/source_authorandyoutube_idthrough the guest-safepublic_recipesview, withparseIngredients(src/lib/ingredients.ts),parseSteps(src/lib/steps.ts) andbuildFallbackMetaDescription(src/lib/recipe-tags.ts) already in place. Only the delivery surface is missing.
📝 Proposed Solution
Two phases sharing one set of helpers (getSiteUrl, public_recipes, src/lib/routes.ts, and a new
src/lib/public-recipe.ts that owns the oldest-wins slug resolution — see Data Model):
Phase 1 — Discoverability. app/sitemap.ts lists all public URLs read from the DB + the static
set; app/robots.ts allows public routes, disallows authenticated/action/api paths, and points at the
sitemap. Both are standard Next App Router metadata routes (/sitemap.xml, /robots.txt).
Phase 2 — AI citability. A Markdown rendering of each recipe and category listing, served at the
canonical path + .md suffix (/przepis/{slug}.md, /przepisy/{kategoria}.md), advertised from the
HTML page head and an HTTP Link header, and indexed in a root llms.txt. The Markdown is assembled
from the same public_recipes read the HTML page uses, so it shows exactly what a guest/crawler sees.
Alternatives considered:
- Content negotiation on the canonical URL (switch on
Accept/user-agent) — rejected: not directly citable, needsVary: Acceptand fights Cloudflare edge caching. /md/…path prefix — rejected: a second URL shape divorced from the canonical path;.mdsuffix keeps the Marksown URL one.mdaway from the page a human is already looking at.llms-full.txt(whole catalog concatenated) — deferred: it duplicates the per-page.mdfiles and grows unbounded; the.mdfiles +llms.txtindex already give agents everything.
📝 Architecture
Takeaway: every new surface reads the same guest-safe public_recipes view and the same route
helpers the HTML pages use — no parallel data path, no new auth surface. The only genuinely new
mechanism is the .md-suffix routing, because Next’s dynamic segments don’t natively carry a file
extension.
.md routing mechanism. The existing src/middleware.ts rewrites a request whose path ends in
.md under a public content prefix to a Route Handler, stripping the suffix:
/przepis/{slug}.md → handler app/przepis/[slug]/md/route.ts;
/przepisy/{kategoria}.md → handler app/przepisy/[kategoria]/md/route.ts.
The rewrite is a cheap regex added to middleware (worker-size sensitive — see Risks); the handlers
return text/markdown; charset=utf-8. Keeping handlers colocated with their page keeps the data read
and the slug/category validation in one place and unit-testable.
Markdown assembly lives in a new pure helper (src/lib/recipe-markdown.ts, category-markdown.ts)
that takes a public_recipes row (or category + rows) and returns a Markdown string, so it is tested
without HTTP. It reuses parseIngredients, groupBySection, parseSteps, recipePath,
originalLinkLabel and buildFallbackMetaDescription.
📝 Data Model
No new tables or relations. One change to the existing public_recipes view: expose a
last_modified timestamp (map to recipes.updated_at, falling back to created_at) so the sitemap can
emit an accurate <lastmod>. The view currently Omits updated_at (see the PublicRecipe type in
src/app/przepis/[slug]/load-recipe.ts). Applied as an idempotent migration through the Supabase
Management API per the project’s migration convention; registered in schema_migrations.
Dedup — a single shared owner, used by both phases. public_recipes may still hold two rows
sharing a slug until the global-unique-slug migration lands. Every surface here MUST resolve a slug to
the same row — the oldest (created_at, then id), mirroring loadRecipe’s deterministic winner —
or a crawler could reach the .md of the losing duplicate while the sitemap lists the winning one.
To stop that logic being written twice and drifting, a new src/lib/public-recipe.ts owns it:
listCanonicalRecipes() (distinct-by-slug, oldest-wins, for the sitemap + llms.txt) and
canonicalRecipeBySlug(slug) (the .md handler). Both phases import it; neither reimplements dedup.
(No new entities/relations → data-model graph intentionally omitted.)
📝 API Contracts
New GET surfaces (all anonymous, all cacheable at the edge):
| URL | Produces | Body | Headers |
|---|---|---|---|
/sitemap.xml | application/xml | <urlset> of all public URLs with lastmod, changefreq, priority | Cache-Control: public, s-maxage=3600, stale-while-revalidate |
/robots.txt | text/plain | allow public, disallow /api/, /ulubione, /powiadomienia, /settings, /recipes, /dodaj, /witaj, /login, /signup, /forgot-password, /reset-password, /auth/, /share, *?* search params; Sitemap: line | static-ish |
/przepis/{slug}.md | text/markdown; charset=utf-8 | recipe as Markdown (see below); 404 → text/plain “not found” | X-Robots-Tag: noindex, follow; Link: <canonical>; rel="canonical"; Cache-Control: public, s-maxage=3600, stale-while-revalidate |
/przepisy/{kategoria}.md | text/markdown; charset=utf-8 | category: H1 + intro + bullet list of recipe titles each linking its .md | same as recipe .md |
/llms.txt | text/markdown; charset=utf-8 | H1 + summary blockquote + ## Przepisy / ## Kategorie link sections | Cache-Control: public, s-maxage=3600, stale-while-revalidate |
Recipe Markdown shape (stable contract; order fixed so citations are reproducible):
# {title} → one-line source/author/time meta → optional description → ## Składniki (grouped by
section, - bullets) → ## Przygotowanie (numbered steps) → footer line: canonical HTML URL +
Źródło: original source_url/YouTube. No owner-only data, no gallery (text only, so nothing locked
leaks). An invalid/unknown slug or category returns 404, not a partial document.
HTML head advertisement (added to recipe + category generateMetadata):
alternates.types['text/markdown'] = '{canonical}.md' → Next renders
<link rel="alternate" type="text/markdown" href="…">.
The sitemap deliberately does not encode .md files via <xhtml:link rel="alternate"> — that
element is for language alternates (hreflang), not media types; misusing it confuses crawlers. Markdown
discovery is via the head link, the Link header, and llms.txt only.
📝 UI/UX
No visible UI. The only user-perceivable change is an extra <link rel="alternate"> in <head> and the
three new text endpoints. Accessibility/contrast rules (the E01 design laws) do not apply to
machine-readable text endpoints. Single locale (pl) → no hreflang.
📝 Edge Cases & Failure Scenarios
- DB read fails during
/sitemap.xml→ return the static public URLs (home, 12 categories, legal) with a 200, rather than a 500 that would make Google drop the whole sitemap. Log the partial. - Large catalog → a single sitemap holds 50k URLs. Below that, one file. Design leaves a
generateSitemapsshard + sitemap-index seam (noted in the plan) to switch on when recipe count approaches ~10–20k; not built now (MVP volume is small). - Duplicate slug in
public_recipes→ dedup to the oldest row (above); never emit two URLs for one slug. - Recipe deleted/merged →
.md404s;merged-recipe-redirectalready 301s merged recipes, and the middleware rewrite runs after that redirect, so a merged slug’s.mdfollows the same 301. - Search results page →
/szukajbare landing is listed low-priority; parametrized/szukaj?q=…isDisallowed (thin/duplicate results), matched by the*?*rule. - Worker CPU (10 ms free tier) → DB I/O does not count; Markdown/XML string building for MVP volume
is well under budget. Edge caching (
s-maxage) means crawlers mostly hit cache, not the Worker. .mdof a locked/full-access recipe → the Markdown is the same guest-safe text the recipe page shows a guest; galleries are image-only and excluded, so there is nothing gated to leak.
📝 Risks & Impact Review
- Worker 3 MiB script limit (hard). The middleware comment in
src/middleware.tsdocuments that crossing it already broke production once (#119). The.mdrewrite adds a few lines of regex to middleware and small route handlers + two pure Markdown helpers — all tiny — but the plan’s final step MUST confirm the deployed bundle still uploads. Reversible: the whole feature is additive and can be reverted without touching existing routes. public_recipesview change is a public-contract change to a view other code reads. Additive (one new column); existingselect('*')consumers keep working. Applied idempotently; rollback = drop the column.- Deploy reality: Workers Builds deploys only from
master(no PR preview builds). These endpoints can only be verified on production after merge, or viapnpm exec opennextjs-cloudflare previewlocally — not from a dev server alone. noindexon.mdis deliberate: it prevents Google treating the Markdown as duplicate content while still letting LLM fetchers read it. If a future goal is to rank the.md, that decision reverses this header.
📋 Phasing
Phase 1 ships first. The two phases are each independently valuable and could in principle ship in
either order, but Phase 1 introduces the shared substrate Phase 2 reuses — the public_recipes
last_modified column, the PublicRecipe type widening, and src/lib/public-recipe.ts (the oldest-wins
selector). Shipping Phase 2 first would mean hoisting those pieces out of Phase 1; shipping Phase 1 first
(the natural order) avoids all cross-phase coordination.
- Phase 1 — Sitemap + robots (shippable on its own):
src/lib/public-recipe.ts,app/sitemap.ts,app/robots.ts, thelast_modifiedview column, tests. Ships discoverability with zero Markdown work. - Phase 2 — Markdown alternates + llms.txt (shippable once Phase 1’s shared pieces are in): Markdown
helpers, route handlers, middleware
.mdrewrite, head advertisement,llms.txt, tests. Importspublic-recipe.tsfor dedup rather than reimplementing it.
📋 Implementation Plan
Phase 1 — Sitemap + robots
last_modifiedonpublic_recipes. Write an idempotent migration exposingcoalesce(updated_at, created_at) as last_modified; apply via Management API; register inschema_migrations; addlast_modifiedto thePublicRecipetype. App still builds and runs.src/lib/public-recipe.ts(shared, used by both phases).listCanonicalRecipes()(distinct-by- slug, oldest-wins) andcanonicalRecipeBySlug(slug), reading thepublic_recipesview. Unit test the dedup winner against rows sharing a slug. Pure data module; nothing consumes it yet.app/robots.ts. Return allow/disallow rules +sitemap: {site}/sitemap.xml, host fromgetSiteUrl(). Unit test the rule set (public allowed, authenticated/api/search-params disallowed)./robots.txtserves.app/sitemap.ts. UselistCanonicalRecipes()+ distinct author profiles frompublic_recipes, add the static set (home, 12 categories viaRECIPE_CATEGORIES/categoryPath, 3 legal paths, bare/szukaj), emitloc/lastmod/changefreq/priority. DB-failure fallback to static set. Unit test URL set + fallback./sitemap.xmlserves and validates.- Cache headers + verify. Set
s-maxagerevalidation;pnpm typecheck && pnpm lint && pnpm test; confirm XML validates. Phase 1 shippable.
Phase 2 — Markdown alternates + llms.txt
src/lib/recipe-markdown.ts. PurerecipeToMarkdown(row)reusing the existing parse helpers; fixed section order per the contract. Unit test against fixture rows (grouped ingredients, numbered steps, missing description, YouTube source). Pure module, app unaffected.src/lib/category-markdown.ts. PurecategoryToMarkdown(category, rows)→ H1 + recipe list linking each recipe.md. Unit test. Pure module.- Recipe
.mdroute handlerapp/przepis/[slug]/md/route.ts: resolve the slug viacanonicalRecipeBySlug(step 2 — same oldest-wins row the sitemap lists), 404 on miss, returntext/markdownwithnoindex/canonicalLink/cache headers. Unit/integration test. New endpoint reachable at its rewrite target. - Category
.mdroute handlerapp/przepisy/[kategoria]/md/route.ts: validate category, read its recipes, return Markdown. Test. Endpoint reachable. - Middleware
.mdrewrite. Add a regex insrc/middleware.tsrewriting/przepis/{slug}.mdand/przepisy/{kategoria}.mdto their handlers, placed after the existing merged-recipe redirect and before auth gating; add the two.mdshapes toisPublicRoute. Extendsrc/middleware.test.ts..mdURLs resolve end to end. - Head advertisement. Add
alternates.types['text/markdown']to the recipe and categorygenerateMetadata. Test that the<link rel="alternate">renders with the right href. HTML now points to its Markdown. app/llms.txtroute (app/llms.txt/route.ts): H1 “Zapisz Przepis”, summary blockquote,## Kategorie(12 category.mdlinks) and## Przepisy(recipe.mdlinks vialistCanonicalRecipes(), newest first, capped with a note if large). Cache headers. Test the structure./llms.txtserves.- Full gate + bundle check.
pnpm typecheck && pnpm lint && pnpm test;opennextjs-cloudflare previewlocally to confirm the Worker bundle still uploads under 3 MiB and the.md/sitemap/robots endpoints respond. Phase 2 shippable.