{"_id":"@bigin-io/renderer-nuxt","_rev":"2-28653d53170c0f38c557859ecb538966","name":"@bigin-io/renderer-nuxt","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@bigin-io/renderer-nuxt","version":"1.0.0","license":"UNLICENSED","_id":"@bigin-io/renderer-nuxt@1.0.0","maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"homepage":"https://github.com/bigin-io/ssg-site-factory#readme","bugs":{"url":"https://github.com/bigin-io/ssg-site-factory/issues"},"dist":{"shasum":"10ffa8bdf71c91cc4547c27f1ce959a14927d4a3","tarball":"https://registry.npmjs.org/@bigin-io/renderer-nuxt/-/renderer-nuxt-1.0.0.tgz","fileCount":304,"integrity":"sha512-EvY1sVp2L9Y3lGc0mNOTDCAciklTQoUoyYJTmdWXpQY6wHUeH4QO7MgKOUsoSW9UTnwk8cs1t4kxjsa4ES0Umg==","signatures":[{"sig":"MEYCIQCafXmZJNAd4+/8aE3VeobUOtG/YC3LQcC+sawvj2Vg8QIhAMBMCA6LZULJb1mwylg4Fzd9ekJf+DkKJK4n44SoBBSU","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":663117},"main":"./nuxt.config.ts","type":"module","_from":"file:C:/Users/Admin/AppData/Local/Temp/sf-publish/bigin-io-renderer-nuxt-1.0.0.tgz","exports":{".":"./nuxt.config.ts","./theme":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./module":{"types":"./dist/module.d.ts","default":"./dist/module.js"},"./package.json":"./package.json"},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","typecheck":"tsc -p tsconfig.json --noEmit && vue-tsc -p tsconfig.vue.json --noEmit"},"_npmUser":{"name":"maichitam","email":"tammai.it@gmail.com"},"_resolved":"C:\\Users\\Admin\\AppData\\Local\\Temp\\sf-publish\\bigin-io-renderer-nuxt-1.0.0.tgz","_integrity":"sha512-EvY1sVp2L9Y3lGc0mNOTDCAciklTQoUoyYJTmdWXpQY6wHUeH4QO7MgKOUsoSW9UTnwk8cs1t4kxjsa4ES0Umg==","repository":{"url":"git+https://github.com/bigin-io/ssg-site-factory.git","type":"git","directory":"packages/renderer-nuxt"},"_npmVersion":"11.2.0","description":"Nuxt 4 layer that renders a site from @bigin-io/site-contract","directories":{},"_nodeVersion":"22.14.0","dependencies":{"jiti":"^2.7.0","@nuxt/kit":"^4.5.2","@nuxt/content":"^3.16.0","@bigin-io/site-worker":"^1.0.0","@bigin-io/renderer-core":"^1.0.0","@bigin-io/site-contract":"^1.0.0"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"vue":"^3.5.22","nuxt":"^4.5.2","jsdom":"^30.0.1","vue-tsc":"3.0.7","axe-core":"^4.13.0","@nuxt/schema":"^4.5.2","@types/jsdom":"^30.0.0"},"peerDependencies":{"nuxt":"^4.5.0"},"_npmOperationalInternal":{"tmp":"tmp/renderer-nuxt_1.0.0_1788517250795_0.3384479029443703","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"_id":"@bigin-io/renderer-nuxt@1.0.1","bugs":{"url":"https://github.com/bigin-io/ssg-site-factory/issues"},"dist":{"shasum":"25a09d214bb3536295315ca53d76f9e546a4a375","tarball":"https://registry.npmjs.org/@bigin-io/renderer-nuxt/-/renderer-nuxt-1.0.1.tgz","fileCount":304,"integrity":"sha512-nPgL4RV0VjhcE8NLyicnnaCtpUscKl50Sy8PlRT7ogP4J3z1xdC6DemsAqXEFlBjN8KR8vAAeHn7E4b1lps5UA==","signatures":[{"sig":"MEYCIQDFG8Qfy0p0e8rGh7MFqhuzeGaFK9OkNH3Qc4pOqsV5xwIhAJRT+Lyr/sUZDmj+JKjdrJ3poKceET7aIyoyi0ovI7DN","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIC32DyRO0VNj+HG8/UOw7mzGE1dkYE+MOiUUEVMyCufVAiBHe9zdILUHoaR9ZmmiSxgbUBmSMMHq7Wr5SITgJmmB9g=="}],"unpackedSize":663117},"main":"./nuxt.config.ts","name":"@bigin-io/renderer-nuxt","type":"module","_from":"file:C:/Users/Admin/AppData/Local/Temp/sf-publish/bigin-io-renderer-nuxt-1.0.1.tgz","exports":{".":"./nuxt.config.ts","./theme":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./module":{"types":"./dist/module.d.ts","default":"./dist/module.js"},"./package.json":"./package.json"},"license":"UNLICENSED","scripts":{"build":"tsc -p tsconfig.build.json","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","typecheck":"tsc -p tsconfig.json --noEmit && vue-tsc -p tsconfig.vue.json --noEmit"},"version":"1.0.1","_npmUser":{"name":"maichitam","email":"tammai.it@gmail.com"},"homepage":"https://github.com/bigin-io/ssg-site-factory#readme","_resolved":"C:\\Users\\Admin\\AppData\\Local\\Temp\\sf-publish\\bigin-io-renderer-nuxt-1.0.1.tgz","_integrity":"sha512-nPgL4RV0VjhcE8NLyicnnaCtpUscKl50Sy8PlRT7ogP4J3z1xdC6DemsAqXEFlBjN8KR8vAAeHn7E4b1lps5UA==","repository":{"url":"git+https://github.com/bigin-io/ssg-site-factory.git","type":"git","directory":"packages/renderer-nuxt"},"_npmVersion":"11.2.0","description":"Nuxt 4 layer that renders a site from @bigin-io/site-contract","directories":{},"maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"_nodeVersion":"22.14.0","dependencies":{"jiti":"^2.7.0","@nuxt/kit":"^4.5.2","@nuxt/content":"^3.16.0","@bigin-io/site-worker":"^1.0.1","@bigin-io/renderer-core":"^1.0.1","@bigin-io/site-contract":"^1.0.1"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"vue":"^3.5.22","nuxt":"^4.5.2","jsdom":"^30.0.1","vue-tsc":"3.0.7","axe-core":"^4.13.0","@nuxt/schema":"^4.5.2","@types/jsdom":"^30.0.0"},"peerDependencies":{"nuxt":"^4.5.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/renderer-nuxt_1.0.1_1788517806997_0.7908447930361353"}}},"time":{"created":"2026-09-04T10:20:50.518Z","modified":"2026-09-04T10:30:07.263Z","1.0.0":"2026-09-04T10:20:50.945Z","1.0.1":"2026-09-04T10:30:07.108Z"},"bugs":{"url":"https://github.com/bigin-io/ssg-site-factory/issues"},"license":"UNLICENSED","homepage":"https://github.com/bigin-io/ssg-site-factory#readme","repository":{"url":"git+https://github.com/bigin-io/ssg-site-factory.git","type":"git","directory":"packages/renderer-nuxt"},"description":"Nuxt 4 layer that renders a site from @bigin-io/site-contract","maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"readme":"# @bigin-io/renderer-nuxt\r\n\r\n> **Story 1.11 moved most of this package into `@bigin-io/renderer-core`.**\r\n> 58 of its 70 modules — theme derivation, section chrome, SEO, i18n routes, the\r\n> image-variant planner and its encoder, media transform URLs, markdown, icons,\r\n> fonts, and the form/analytics/search mounts — are now there, and this package\r\n> is the Nuxt *binding*: twelve modules of `@nuxt/kit` wiring, virtual-module\r\n> generators and Nuxt Content plumbing. It re-exports **nothing** from the core;\r\n> import from `@bigin-io/renderer-core` directly. The sections below describe\r\n> behaviour that is unchanged, wherever the code now lives.\r\n\r\nNuxt 4 layer that renders a site from [`@bigin-io/site-contract`](../site-contract).\r\n**The renderer** since story 7.0a: `@bigin-io/renderer-next` was withdrawn on\r\n2026-09-03, its last release is `0.7.0` and nothing further will be published for it, and\r\nNFR-7's renderer parity went with it.\r\n\r\nStory 1.2 landed the design-token pipeline: config loading, `--sf-*` CSS\r\nvariables, self-hosted `@font-face` rules, and a token-only base stylesheet.\r\nSections, content, SEO, i18n, images, analytics, and forms arrive in 1.3–1.9.\r\n\r\n## Install\r\n\r\n```bash\r\npnpm add @bigin-io/renderer-nuxt\r\n```\r\n\r\n| Consumer | How it resolves |\r\n| --- | --- |\r\n| A package inside this monorepo | `\"@bigin-io/renderer-nuxt\": \"workspace:^\"` |\r\n| A client site repo | a pinned semver range from npm (public since ADR-18; GitHub Packages before it) |\r\n\r\nNuxt is a **peer** dependency (`^4.5.0`), so the site repo owns the version.\r\n\r\n## Use\r\n\r\n```ts\r\n// site repo — nuxt.config.ts\r\nexport default defineNuxtConfig({\r\n  extends: ['@bigin-io/renderer-nuxt'],\r\n})\r\n```\r\n\r\nThe layer reads `site.config.ts` from the project root, validates it against\r\nthe contract, and fails the build with a path-annotated report if it does not\r\nparse. Nothing else is required.\r\n\r\n### Options\r\n\r\nSet under the `siteFactory` key.\r\n\r\n| Option | Default | What it does |\r\n| --- | --- | --- |\r\n| `configPath` | probes `site.config.ts`, `site.config.mjs`, `site.config.js` | Path to the site config, relative to the project root or absolute |\r\n\r\n```ts\r\nexport default defineNuxtConfig({\r\n  extends: ['@bigin-io/renderer-nuxt'],\r\n  siteFactory: { configPath: 'config/site.ts' },\r\n})\r\n```\r\n\r\nA config whose `renderer` is not `'nuxt'` fails the build — at the contract's\r\nown enum since story 7.0a, with a message naming the withdrawal, rather than at\r\na renderer check in this layer.\r\n\r\n### Reading the config\r\n\r\n```vue\r\n<script setup lang=\"ts\">\r\nconst site = useSiteConfig()\r\n</script>\r\n```\r\n\r\n`useSiteConfig()` is auto-imported from a **build-time** virtual module, not\r\nfrom `runtimeConfig.public` — that path is serialised into every page's payload,\r\nso nav, pages, collections, and the whole token set would ship to the browser on\r\nevery route (NFR-1). The flip side: calling it from code that runs in the\r\nbrowser pulls the config into the client bundle. Read it in `setup()` or in\r\nbuild-time code, which is where a full-static site needs it anyway (ADR-10).\r\n\r\n## The theme\r\n\r\nThe layer generates one stylesheet at build time and registers it as the first\r\nentry in `nuxt.options.css`, so the bundler emits it as a real CSS asset linked\r\nfrom the document head:\r\n\r\n1. `@font-face` rules for every declared self-hosted font file.\r\n2. `:root { --sf-*: … }` from the contract's own `toCssVars`.\r\n3. `:root { … }` for the derived values below.\r\n4. A generated `@media (min-width: <md>)` block that steps the section rhythm up.\r\n5. The base stylesheet — reset, element baseline, focus ring, layout\r\n   primitives, and `prefers-reduced-motion`.\r\n\r\nA **stylesheet asset, not an `app.head.style` entry**: Nuxt hands head entries\r\nto the client head manager as well as to the server renderer, so a `<style>`\r\nentry ships the whole theme a second time inside a JS chunk. As CSS it is\r\nbundled once, cached across every route, and costs the JS budget nothing\r\n(NFR-1). Nothing about theming runs in the browser — a test asserts no emitted\r\nchunk so much as contains the string `--sf-`.\r\n\r\nIt goes **first** in `nuxt.options.css`, so a site's own stylesheet still wins.\r\n\r\n### Fonts are bundled, not asked for\r\n\r\nThe platform's families are fixed (`docs/04-design-directions.md` § Typefaces):\r\n**Google Sans Flex** for heading and body, **Fraunces** for D-B's display serif,\r\n**IBM Plex Mono** for mono. The layer ships them itself, from the installed\r\n`@fontsource*` packages, into your build output at the URLs the presets declare.\r\nA site repo puts nothing in `public/fonts/`.\r\n\r\nThree subsets travel with every family — `latin`, `latin-ext` and\r\n**`vietnamese`** — each with the `unicode-range` fontsource built it with, so a\r\nbrowser fetches only what a page needs. The vietnamese subset is not optional:\r\nthe `latin` subset alone is missing five of the eighteen codepoints in\r\n\"Chào mừng bạn đến với BigIn\", and a page that falls back on `ế` or `ữ` looks\r\nbroken to the audience it was built for. `bundled-fonts.test.ts` decodes the\r\nshipped `woff2` and checks the glyphs are really there.\r\n\r\n**Zero third-party requests.** Not a font CDN, not an icon CDN, not a runtime\r\nfetch — for performance (NFR-1) and because remote Google Fonts loading has GDPR\r\ncase law against it. The contract rejects a CDN URL at parse time, and\r\n`no-external-origins.test.ts` asserts the absence over the source, the generated\r\nCSS and the built `dist`.\r\n\r\nA file the site puts at the same URL still wins; the layer never overwrites one.\r\nA family the layer does **not** bundle — a brand override — keeps the old\r\ncontract: declare it, ship it in `public/fonts/`, and a declared file with\r\nnothing behind it is a build **warning**, because a pre-upload skeleton is a\r\nlegitimate state and the G5 link check is where a 404 becomes fatal.\r\n\r\n### Icons\r\n\r\n**Lucide**, at stroke **1.5**, inlined as SVG at build time from the locally\r\ninstalled `@iconify-json/lucide`. No icon module is installed and no icon font\r\nis used: `@nuxt/icon` and its equivalents resolve over an icon API by default\r\nand fall back to one when a collection is missing, which is the third-party\r\nrequest the bundling rule forbids. Not installing that path is stronger than\r\nasserting its absence.\r\n\r\n`sections[].items[].icon` takes a Lucide name (aliases included). Names are\r\ncollected from the markdown sources declared in `site.config` `pages[]`, and\r\n**an unresolved name fails the build**, naming the file and the item — never a\r\nsilent gap in the page. A section assembled in a hand-written `.vue` page is\r\noutside that scan and fails at prerender with the same message.\r\n\r\nStroke normalisation is one exported pure function, `normaliseIconStroke`. It\r\n**rewrites** an existing `stroke-width` and does not add one; every non-hidden\r\nLucide icon carries `stroke-width=\"2\"`, and a test pins that against the\r\ninstalled collection so a future release cannot change it quietly. Story 5.2\r\nmust call this function rather than write the regex again (NFR-7).\r\n\r\n### Derived values\r\n\r\n`toCssVars` emits `--sf-section-rhythm` and `--sf-section-density` as\r\n*keywords* (`airy`, `compact`) because the contract describes intent, not\r\nlengths. No CSS property can consume a keyword as a length, so the renderer\r\nderives these. **Story 5.2 must produce the identical values** or the parity\r\nsuite has nothing to hold the two renderers to — import them from\r\n`@bigin-io/renderer-nuxt/theme` (`deriveThemeVars`,\r\n`deriveResponsiveThemeVars`), which is why the derivation is a pure function\r\nwith no Nuxt import. The bare package name resolves to the layer's\r\n`nuxt.config.ts`, as Nuxt layer resolution requires.\r\n\r\nSection rhythm is mobile-first: the base value applies below the `md`\r\nbreakpoint, and the generated media query steps it up from `md`.\r\n\r\n| Variable | `tight` | `normal` | `airy` |\r\n| --- | --- | --- | --- |\r\n| `--sf-section-padding-block` | `space.6` → `space.8` | `space.8` → `space.12` | `space.12` → `space.24` |\r\n| `--sf-section-gap` | `space.4` → `space.6` | `space.6` → `space.8` | `space.8` → `space.12` |\r\n\r\n| Variable | `compact` | `comfortable` |\r\n| --- | --- | --- |\r\n| `--sf-stack-gap` | `space.3` | `space.4` |\r\n| `--sf-card-padding` | `space.4` | `space.6` |\r\n\r\n| Variable | Value |\r\n| --- | --- |\r\n| `--sf-content-measure` | `min(100% - (var(--sf-gutter) * 2), var(--sf-container-max-width))` |\r\n\r\nA rhythm or density that references a space step the token set does not define\r\nis a build error naming the step.\r\n\r\n`sectionStyle.alternateBackgrounds` is a boolean, not a value: read it from\r\n`useSiteConfig().design.tokens.sectionStyle`, not from a CSS variable.\r\n\r\n### Layout primitives\r\n\r\nThe base stylesheet ships four classes built from the derived values, so\r\nsections do not re-derive spacing: `.sf-container`, `.sf-section`, `.sf-stack`,\r\n`.sf-card`.\r\n\r\n## The section library\r\n\r\nNine components, one per member of the contract's `sections[]` union, plus the\r\ndispatcher that renders a page's `sections[]` array. They are auto-registered,\r\nso a site repo uses them without importing anything:\r\n\r\n```vue\r\n<template>\r\n  <main>\r\n    <SfSections :sections=\"page.sections\" :alternate-backgrounds=\"true\" />\r\n  </main>\r\n</template>\r\n```\r\n\r\n| Component | Contract type | Reads |\r\n| --- | --- | --- |\r\n| `SfHero` | `hero` | `headline`, `subheadline?`, `media?`, `ctas` (≤ 2) |\r\n| `SfFeatures` | `features` | `headline?`, `items[]` of `{ title, body, icon?, media? }` — `icon` is a Lucide name, inlined at build |\r\n| `SfTestimonials` | `testimonials` | `headline?`, `items[]` of `{ quote, author, role?, company?, avatar? }` |\r\n| `SfPricing` | `pricing` | `headline?`, `plans[]` of `{ name, price, period?, features[], cta?, featured }` |\r\n| `SfCta` | `cta` | `headline`, `body?`, `cta` |\r\n| `SfFaq` | `faq` | `headline?`, `items[]` of `{ question, answer }` |\r\n| `SfTeam` | `team` | `headline?`, `members[]` of `{ name, role, photo?, bio? }` |\r\n| `SfLogoWall` | `logoWall` | `headline?`, `logos[]` of `{ name, image, href? }` |\r\n| `SfProse` | `prose` | `headline?`, `body` |\r\n\r\nEvery visible string, link, and image comes from frontmatter. The library\r\noriginates exactly one string of its own — the featured-plan badge in\r\n`SECTION_STRINGS`, because `featured` is a boolean with no label in the contract\r\nand marking it by colour alone would fail AA.\r\n\r\n### Chrome\r\n\r\nApplied once by a shared wrapper, not repeated nine times:\r\n\r\n- **Anchor** — a section's optional `id` becomes the element id.\r\n- **Background** — an explicit `background` always wins. Otherwise, with\r\n  `alternateBackgrounds`, a section takes the opposite of whatever the previous\r\n  one *rendered*, so an explicit `inverse` in the middle never leaves two\r\n  identical neighbours touching. On `inverse`, plain links inherit the text\r\n  colour rather than using the accent, whose contrast is only guaranteed against\r\n  `accent.base`.\r\n- **Headings** — the first section that renders a headline gets the `h1`; every\r\n  later one gets `h2`, and item titles sit one level below. Computed over the\r\n  whole page, so a page without a hero still has one `h1` and an outline that\r\n  never skips a level.\r\n\r\n### Sections ship no JavaScript\r\n\r\nNo event handlers, no lifecycle hooks, no observers, no scroll-reveal. The one\r\ninteractive section, the FAQ, is a native `<details>`/`<summary>` disclosure —\r\nkeyboard-operable and correctly announced with nothing loaded. A test scans the\r\ncomponent sources and fails on a handler or an observer.\r\n\r\nComponents carry **no `<style>` blocks**. All section CSS is generated in\r\n`src/sections/styles.ts` and appended to the theme stylesheet, because a media\r\nquery needs a real length in its condition and a `.vue` style block cannot read\r\n`tokens.breakpoints`.\r\n\r\n### The two seams\r\n\r\n`SfImage` and `SfMarkdown` are deliberately unfinished, with their markup\r\ncontracts pinned by tests so completing them changes nothing a section emits:\r\n\r\n| Seam | Today | Completed by |\r\n| --- | --- | --- |\r\n| `SfImage` | a plain `<img>` with the alt, dimension, and priority rules | 1.7 (build-time variants) and 3.2 (Cloudflare transform URLs) |\r\n| `SfMarkdown` | splits on blank lines into paragraphs; **does not parse markdown** | 1.4 (Nuxt Content) |\r\n\r\n`SfMarkdown` not parsing markdown is a decision, not an omission: the platform\r\ngets one markdown pipeline, it arrives with Nuxt Content, and a second different\r\none here is exactly the renderer drift ADR-1 exists to prevent.\r\n\r\n## Collections\r\n\r\nDeclare a collection in `site.config.ts` and drop markdown in the directory it\r\nnames. There is no per-collection code: the contract compiles the declaration's\r\nfield list into an entry schema, and the layer turns the declaration into routes.\r\n\r\n```ts\r\ncollections: [\r\n  {\r\n    name: 'blog',\r\n    kind: 'blog',\r\n    label: 'Blog',\r\n    path: 'content/blog',\r\n    route: '/blog',\r\n    itemRoute: '/blog/:slug',\r\n    taxonomies: [{ name: 'tags', route: '/blog/tag' }],\r\n    rss: true,\r\n    fields: [],\r\n  },\r\n]\r\n```\r\n\r\nThat produces:\r\n\r\n| Route | From |\r\n| --- | --- |\r\n| `/blog` | `route` |\r\n| `/blog/<slug>` | `itemRoute`, one per published entry; the slug is the file name |\r\n| `/blog/tag/<value>` | one per taxonomy value some published entry actually carries |\r\n| `/blog/rss.xml` | `rss: true`. The contract has no path for it, so this is the convention |\r\n\r\nA `draft: true` entry is left out of the build entirely — no route, no listing,\r\nno taxonomy count, no feed item. Entries sort newest first, with the slug\r\nbreaking a date tie so a build repeats. Collections exist on the content tier\r\nonly; on the simple tier the layer adds no Content module, no routes, and no\r\nfeed.\r\n\r\nEntry frontmatter is validated against the contract's schema at build time, and\r\na failure names the file, the field, and the rule. A collection's own declared\r\nfields come through to its entry template with no code — the template lists\r\nwhatever the declaration named.\r\n\r\nCollection names are slugs in the contract but must be JavaScript identifiers in\r\nNuxt Content, so `case-studies` becomes the `case_studies` collection. That\r\nmapping is internal; routes and labels always use the name you declared.\r\n\r\n### Content is queried at build time only\r\n\r\nADR-10, and it is not a preference:\r\n\r\n- Every listing, entry, and taxonomy page is prerendered. Pagefind (stories\r\n  2.1/2.2) is the only client-side search.\r\n- The per-collection SQL dump Nuxt Content prerenders for browser queries is\r\n  turned off, and the browser SQLite build it lazily imports is aliased to a\r\n  stub that throws. A build ships no `.wasm`, no dump, and no database.\r\n- **Never parse content inside app code.** Bundling the contract's schemas into\r\n  the app puts Zod's internal `process` helper in the same module as Nitro's\r\n  `node:process` banner, and the prerender dies on a duplicate declaration.\r\n  Validation belongs to the build.\r\n\r\nNuxt Content runs on Node's built-in SQLite rather than the `better-sqlite3`\r\nnative addon, so **Node 22.5 or newer** is required and no C++ toolchain is.\r\n\r\n## SEO\r\n\r\nA page supplies its own title and description; everything else is derived.\r\n\r\n```vue\r\n<script setup lang=\"ts\">\r\nuseSfSeo({ title: 'Contact', description: 'Talk to us about a pilot.' })\r\n</script>\r\n```\r\n\r\nThat emits the templated `<title>`, the description, an absolute canonical, the\r\nfull OG and Twitter card, and the JSON-LD each `seo.structuredData` flag asks\r\nfor. A collection entry passes `article: { … }` as well, which makes it an\r\n`Article` in the structured data and an `article` OG type, and publishes the\r\ndates the sitemap reads back. Frontmatter `seo` overrides title, description,\r\ncanonical, and `noindex`; a `noindex` page carries the robots meta **and** is\r\nleft out of the sitemap.\r\n\r\n`sitemap.xml` and `robots.txt` are written into `dist` after the last route is\r\nprerendered, from the routes the build **actually generated** — a sitemap kept\r\nbeside the build is one that lies as soon as somebody adds a route and forgets.\r\nThe facts each entry needs come from the rendered page itself, so there is no\r\nsecond channel to disagree with it.\r\n\r\n### OG images\r\n\r\nA page's `og:image` comes from its own `ogImage`, else from\r\n`seo.ogImage.fallback`, else it is not emitted at all — better than pointing at\r\na card that does not exist. `og:image:width` and `og:image:height` accompany it,\r\nand `twitter:card` follows `seo.twitter`.\r\n\r\n**The build drew its own cards until story 7.0b, and no site ever received\r\none.** `seo.ogImage.mode: 'generated'` asked satori and sharp for a 1200×630\r\ncard per indexable route, in the site's own colours and heading font. satori\r\nparses the font itself and **cannot read woff2**, which is what all three design\r\npresets declare because it is the right format for a browser — so the gate in\r\nfront of the generator was `false` for every preset from the day it shipped. It\r\nwas withdrawn rather than fixed with a `.woff` face: a feature the default\r\nconfiguration cannot use is a feature nobody has. Removing it changed no built\r\nbyte, measured across 44 documents.\r\n\r\n## Locales\r\n\r\nEvery route is locale-prefixed, on a single-language site as much as a\r\nmultilingual one:\r\n\r\n```text\r\n/en                     /vi\r\n/en/blog                /vi/blog\r\n/en/blog/first-post     /vi/blog/first-post\r\n```\r\n\r\n**There is no switch to turn this off**, and the contract is where that was\r\ndecided (FR-S8). The reason is migration cost: a site launched on `/blog/x` and\r\ntranslated later has to move every URL and keep every redirect forever, while a\r\nsite launched on `/en/blog/x` adds a language by adding a directory.\r\n\r\n| Thing | Rule |\r\n| --- | --- |\r\n| `/` | Redirects to the default locale. It is never a second copy of the home page |\r\n| Canonical | Locale-specific; a page is canonical for itself and never for a translation |\r\n| `hreflang` | One alternate per locale the page exists in, plus `x-default` at the default locale |\r\n| Feed | One per locale, at `/<locale><collection.route>/rss.xml` |\r\n| Sitemap | Every locale, each `<url>` carrying `xhtml:link` alternates for its siblings |\r\n\r\nA page declares its locale in frontmatter; a collection entry is built under the\r\nlocale its own `locale` field names. A site's own `pages/` route has no\r\nfrontmatter, so it is built under every declared locale — a page that exists in\r\none language only is expressed by content, not by routing.\r\n\r\nLinks that content authored stay logical. A CTA points at `/contact`, and the\r\nrenderer puts it in the reader's locale; an absolute URL, a `mailto:`, an\r\nin-page anchor, and an href that already names a locale are left exactly as\r\nwritten.\r\n\r\n### This is routing, not translation\r\n\r\nThe layer translates nothing. There is no message catalogue, no locale-aware\r\nformatting, and no locale switcher, and the layer takes on no i18n dependency to\r\navoid shipping a runtime for a problem it does not have (NFR-1). Copy comes from\r\nyour content and your pages, per locale, as it already does.\r\n\r\nTwo consequences worth knowing. A collection's `label` and a taxonomy value are\r\nsingle strings in the contract, so `/en/blog` and `/vi/blog` share a title —\r\nwhich is what `hreflang` exists to explain, and why the duplicate-title check\r\nis scoped **within** a locale rather than across the build. And the handful of\r\nwords the library does originate (`SECTION_STRINGS`) are still English only.\r\n\r\n## Images\r\n\r\nEvery raster image under your `public/` directory is optimised at build time\r\nwith sharp: a variant per width in `images.widths`, in every format in\r\n`images.formats`, at `images.quality`. The original of an image your pages\r\nactually use does not ship.\r\n\r\n```ts\r\nimages: {\r\n  widths: [320, 640, 960, 1280, 1920],\r\n  formats: ['avif', 'webp'],\r\n  quality: 75,\r\n}\r\n```\r\n\r\n`SfImage` renders a `<picture>` with AVIF, then WebP, then the source format —\r\nthe order a browser resolves them in — each with a `srcset` across the generated\r\nwidths. Intrinsic `width` and `height` are always emitted so nothing shifts, read\r\nfrom the source when the content did not declare them. `sizes` defaults to the\r\ncontainer measure rather than the full viewport, because almost nothing here is\r\nfull-bleed and a `sizes` that lies makes the whole `srcset` pointless; pass your\r\nown when a layout differs.\r\n\r\nVariants are **content-addressed**: the filename carries a hash of the source\r\nbytes and the encode settings, so a CDN can cache one forever and a changed\r\nsource can never be served stale. A rebuild with unchanged sources produces\r\nbyte-identical output.\r\n\r\n| Rule | Behaviour |\r\n| --- | --- |\r\n| No upscaling | A 900 px source emits 320, 640 and 900 — never a blurry 1920 |\r\n| SVG | Passed through unprocessed. It is already resolution-independent |\r\n| Unreadable | Warns naming the file and ships it unchanged. It never fails a build |\r\n| Non-images | Untouched: a PDF in `public/downloads` is not this pipeline's business |\r\n| Unreferenced | Variants are written, but the original stays — see below |\r\n\r\nAn original is removed only when the built output points at its variants. After\r\nthe last route is prerendered the build reads the HTML, CSS, XML and JSON it\r\nwrote and looks for the variant URLs; a raster nothing asked for keeps its\r\noriginal, because `public/` is also where a downloadable press image lives and\r\ndeleting one would be a 404 on someone's link. Compiled JS is deliberately not\r\nread: the plan map is bundled whole, so every variant URL appears there whether\r\na page renders it or not. The bias is toward keeping a file — an unused original\r\ncosts bytes in `dist`, an over-eager delete costs a broken link.\r\n\r\nThree referenced originals **stay at their own URL**, because what fetches them\r\ncannot negotiate a `<picture>`: `brand.logo.favicon` (the browser's icon\r\nloader), `seo.ogImage.fallback` (social scrapers, many of which still reject\r\nAVIF and WebP), and `seo.organization.logo` (search engines reading JSON-LD).\r\n\r\nThe build reports how many sources it read, how many variants it wrote, and the\r\ndifference in bytes.\r\n\r\n### The other half: editorial media\r\n\r\n`SfImage` resolves by source, and a `media.<domain>` URL takes the other branch.\r\nEditorial imagery lives in R2 behind your media zone (ADR-4), and the renderer\r\nrewrites an original into transform URLs the **edge** fulfils:\r\n\r\n```text\r\nhttps://media.<domain>/cdn-cgi/image/width=960,fit=scale-down,quality=78,format=avif/<key>\r\n```\r\n\r\n| | `public/` image | `media.<domain>` image |\r\n| --- | --- | --- |\r\n| Owned by | code, in the repo | content, in R2 |\r\n| Sized by | sharp, at build | the edge, on request |\r\n| Config | `images` | `media.transform` |\r\n| Both tiers? | yes | content tier only (ADR-11) |\r\n\r\nBoth emit the same `<picture>`: AVIF, then WebP, then the source format, each\r\nwith a `srcset`. One component, two URL builders — a reader cannot tell which\r\npipeline an image came through, which is the point.\r\n\r\nThe URL is **absolute**, on the media zone, because transformation runs there\r\nand nowhere else; a relative `/cdn-cgi/image/` URL works on production and\r\nbreaks in dev, on a preview, and on a site whose DNS is off Cloudflare (ADR-9).\r\n`fit=scale-down` means the edge never enlarges a source — the same no-upscale\r\nrule the build applies to `public/` images, by the only mechanism available when\r\nthe build has never seen the bytes.\r\n\r\n`media.devFallback: true` renders the original URL unchanged, for offline work\r\nand pre-upload skeletons.\r\n\r\n**Declare `width` and `height` on media images.** The build has never seen an R2\r\nobject, so those numbers can only come from the content — and without them the\r\nimage reserves no space and the page jumps as it loads (CLS). The build warns,\r\nnaming the entry and the image, rather than shipping it quietly.\r\n\r\nThe principle both halves follow: **build creates URLs, serve creates images.**\r\n\r\nA note for anyone reconciling this with the upload tooling: a `public/` SVG is\r\npassed through here, while an SVG destined for R2 is refused there. Both are\r\nright. The file type is the same and the trust boundary is not — a `public/`\r\nSVG is repo-owned and arrived through review, while an uploaded one would be\r\nserved from the client's own origin, where a script-carrying SVG is stored XSS.\r\n\r\n## Analytics\r\n\r\nOne field decides what a site loads.\r\n\r\n```ts\r\nanalytics: { provider: 'none' }\r\nanalytics: { provider: 'cloudflare', token: '<public beacon token>' }\r\nanalytics: {\r\n  provider: 'ga4',\r\n  measurementId: 'G-XXXXXXXX',\r\n  consentBanner: { policyPath: '/privacy' },  // must equal legal.pages.privacy\r\n}\r\n```\r\n\r\n| Provider | What ships | Consent |\r\n| --- | --- | --- |\r\n| `cloudflare` | a deferred beacon in every page's head | none needed — it sets no cookies |\r\n| `ga4` | a consent banner, and the tag only after an accept | required, and not optional |\r\n| `none` | nothing | — |\r\n\r\n**Cloudflare Web Analytics** is the default and the cheap one: a single\r\n`defer`red external script in the prerendered head. No bundle, no module, and\r\nnothing on the JS budget that belongs to this library.\r\n\r\n**GA4 loads nothing before consent.** Not a script, not a `dataLayer`, not a\r\ncookie. Google's own recommendation — Consent Mode v2, which loads `gtag.js`\r\nimmediately in a denied state — is deliberately not used: that is still a\r\nrequest to Google before anyone agreed to it. The tag is injected on accept and\r\nnever otherwise.\r\n\r\nThe banner it needs mounts itself from a client plugin, so there is no component\r\nto place and none to forget, and it is registered **only** on a GA4 site — a\r\ncookieless site ships no consent code at all. It renders after mount, fixed to\r\nthe bottom of the viewport, so it shifts nothing (CLS). It is a labelled region\r\nrather than a modal: no focus trap, no scroll lock, no stolen focus, and\r\ndeclining is the same size and in the same place as accepting.\r\n\r\nThe choice is stored in `localStorage` under `sf-consent-v1` as `granted` or\r\n`denied`, and a visitor who has decided is never asked again in that browser. To\r\nlet someone change their mind, clear that key — a preferences control is not\r\nsomething the contract has a surface for yet. A browser that refuses storage\r\ndegrades to asking again next load and loading nothing in the meantime.\r\n\r\n**Global Privacy Control is honoured.** A visitor whose browser sends\r\n`navigator.globalPrivacyControl` is treated as having declined: no banner is\r\nshown, nothing is stored, and GA4 never loads. It is a recognised opt-out in\r\nseveral jurisdictions, and someone who has set it has already answered. There is\r\nno config field to turn this off.\r\n\r\nThe banner's privacy link is localised like every other href: the contract holds\r\nthe logical `/privacy` and a reader on `/vi/…` gets `/vi/privacy`.\r\n\r\nIts copy — one sentence, two buttons and a link label — is English only, and\r\nlives in one module beside the section library's strings. Localised UI strings\r\nhave no home in the contract yet; this is the second place that wants them.\r\n\r\n## Migration, headers, and the pages this layer owns\r\n\r\nFour things a site cannot launch without, all read from `site.config`.\r\n\r\n### `_redirects`\r\n\r\nEvery rule in `redirects` reaches the built file in declaration order, followed\r\nby the root rule that sends `/` to the default locale. A site's own rules\r\ntherefore always decide first.\r\n\r\n```ts\r\nredirects: [\r\n  { from: '/products', to: '/product', status: 301 },\r\n  { from: '/news', to: 'https://blog.example/news', status: 302 },\r\n]\r\n```\r\n\r\n`to` is a *logical* path, so `/product` is written as `/en/product` — the URL\r\nthe built site actually serves. An absolute URL is written exactly as given.\r\n`from` is never localised: it is a URL from the site you are replacing.\r\n\r\n### `_headers`\r\n\r\n```\r\n/*\r\n  X-Content-Type-Options: nosniff\r\n  Referrer-Policy: strict-origin-when-cross-origin\r\n  Content-Security-Policy: …\r\n  Strict-Transport-Security: max-age=31536000; includeSubDomains\r\n\r\n/_nuxt/*\r\n  Cache-Control: public, max-age=31536000, immutable\r\n\r\n/_sf/img/*\r\n  Cache-Control: public, max-age=31536000, immutable\r\n```\r\n\r\nThe CSP is **the contract's**, not this package's: `deriveCspSources` already\r\nknows about the analytics provider, the Turnstile widget, the media zone and\r\nyour own `security.csp.extra*` additions. The renderer never widens it — if\r\nsomething it ships needs a source the contract does not derive, that is a\r\ncontract change. `security.csp.mode: 'report-only'` switches the header name;\r\n`security.hsts` decides `max-age` and whether `includeSubDomains` and `preload`\r\nappear at all.\r\n\r\nOnly the content-addressed paths are cached forever, because only they carry a\r\nhash in the filename. HTML deliberately gets no long cache: a stale page is a\r\nsite you cannot fix by deploying.\r\n\r\nWhat the package can prove is the file it wrote. Whether a live response\r\ncarries these headers is the deploy gate's job — the wrangler configuration\r\nthat serves them is not in this package.\r\n\r\n### The 404\r\n\r\n`notFound.path` (default `/404`) renders per locale from your own tokens, links\r\nhome in the reader's language, and asks not to be indexed. The default locale's\r\ncopy is also written to `/404.html`, because a request for a path that never\r\nexisted has no locale to negotiate from.\r\n\r\n### Legal pages\r\n\r\nA `pages[]` entry with `kind: 'legal'` and a `source` is rendered by the layer —\r\nno `privacy.vue` in your repo:\r\n\r\n```ts\r\npages: [\r\n  { path: '/privacy', title: 'Privacy policy', kind: 'legal', source: 'content/pages/privacy.md' },\r\n]\r\n```\r\n\r\nThe file's **frontmatter** is the contract's page schema — a title, a\r\ndescription, a locale, and `sections[]` — so a legal page is section-library\r\ncontent like any other. For a multilingual site, put each language's copy in a\r\nlocale directory (`content/pages/vi/privacy.md`); without one the declared file\r\nis rendered for that locale and the build says so.\r\n\r\nA declared page with no source, a source with no file, and frontmatter that does\r\nnot match the schema are all build errors naming the file. A legal page that\r\nsilently renders blank is worse than a build that stops.\r\n\r\n### Two markdown paths, and why\r\n\r\nCollection bodies are rendered by Nuxt Content. Prose bodies — which is what a\r\nlegal page is made of — are rendered here, by `micromark` at build time.\r\n\r\nThat is a deliberate duplication, for a reason worth writing down: Content\r\nparses *files*, not strings, and it is a content-tier module, while every tier\r\nneeds a privacy policy. One shared pure function that both renderers call cannot\r\ndrift; two frameworks each reaching for their own content system certainly\r\nwould.\r\n\r\nRaw HTML in a prose body is **escaped, not passed through**, and a\r\n`javascript:` URL never becomes an href. That matters because legal copy is\r\ndrafted by an agent rather than reviewed line by line, and markdown that emits\r\nHTML into a page is otherwise an injection surface. The same trust boundary\r\nreasoning as the SVG rule above.\r\n\r\nA prose section handed straight to `SfMarkdown` — one built in a `.vue` page\r\nrather than prepared by the build — still renders as paragraphs of literal\r\ntext, because nothing parses markdown in a browser.\r\n\r\n## Search\r\n\r\nA content-tier site gets a search page at `/search` under every locale\r\n(`/en/search`, `/vi/search`), rendered by the layer. A simple-tier site gets\r\nnothing — the contract forbids `search` there, and there is no index to read.\r\n\r\n```ts\r\nsearch: { excludeRoutes: ['/privacy', '/terms'] }   // content tier only\r\n```\r\n\r\nThe page works before any JavaScript runs: a labelled input and a submit, in a\r\nreal `<form method=\"get\">`. Submitting without script puts the query in the URL\r\nas `?q=…`, and the page runs it as soon as it can.\r\n\r\n**The index is built after the site is.** It is not part of `nuxt generate`:\r\n\r\n```text\r\nnuxt generate   ->   build-search   ->   wrangler deploy\r\n```\r\n\r\n`build-search` is the platform's own step (in `scripts`). It writes a Pagefind\r\nbundle into the built site, which the page then loads from\r\n`/pagefind/pagefind.js` — **on the first keystroke, never on page load**.\r\nA reader who does not search pays nothing, and no chunk in the site contains\r\nPagefind's code: the import is deliberately a runtime one.\r\n\r\nIf the index is not there — `nuxt dev`, or a preview built before that step ran\r\n— the page says **search is not available on this build**. That is a different\r\nsentence from \"nothing matched\", on purpose: one is a broken deploy, the other\r\nis a fact about the query.\r\n\r\n### Filters\r\n\r\nResults can be narrowed by collection. Every collection entry page carries\r\n\r\n```html\r\ndata-pagefind-filter=\"collection:<name>\"\r\n```\r\n\r\nkeyed by the collection's contract **name**, not its label — a label is display\r\ncopy a client renames, and a saved filter URL should survive that. The UI reads\r\nthe filters from the index and shows each collection's label beside its count.\r\n\r\n### What the site declares about itself\r\n\r\nEvery page declares its own language on `<html lang>`, which two separate things\r\ndepend on: WCAG 2.1 3.1.1 (Level A), and Pagefind, which splits its index by\r\nlanguage — without it a Vietnamese reader's search returns English pages.\r\n\r\nExcluded routes are the indexer's business, not the UI's: `search.excludeRoutes`\r\nholds *logical* routes and one entry excludes that route **under every configured\r\nlocale**. An excluded page is absent from the index, so it is absent from\r\nresults. Nothing here re-derives that rule.\r\n\r\n## Forms\r\n\r\nTwo components, placed by the site where it wants them:\r\n\r\n```vue\r\n<SfContactForm />\r\n<SfNewsletterForm />\r\n```\r\n\r\nThey render from the config and post to the Worker's own endpoints\r\n(`/api/contact`, `/api/subscribe`, ADR-5). A disabled form — `forms.contact.enabled: false`,\r\nor no `newsletter.enabled` — renders nothing at all rather than something that\r\nfails on submit.\r\n\r\nThe error codes, the Turnstile field name and the field length caps come from\r\n`@bigin-io/site-worker` itself, re-exported through one file —\r\n`src/forms/worker-contract.ts` — so nothing in this package describes the API a\r\nsecond time. **Import values from that file, never from the Worker's package\r\nroot directly**, and never take a value out of the Worker's router: `API_PREFIX`\r\nand `ROUTES` sit beside the handler table, and importing either as a value puts\r\nZod, the contact validator and the Turnstile verifier into the browser bundle\r\n(83.9 KB raw / 23.8 KB gzipped, measured). Types and `typeof` imports are free;\r\n`test/sections.test.ts` fails if any of it reaches a chunk.\r\n\r\n```ts\r\nforms: {\r\n  turnstile: { siteKey: '0x4AAAA…' },        // public; the secret is a Worker secret\r\n  contact: {\r\n    enabled: true,\r\n    recipients: ['sales@example.com'],       // where submissions go — never rendered\r\n    fields: [                                // 1–4 fields; this is the whole form\r\n      { name: 'name', label: 'Your name', type: 'text' },\r\n      { name: 'email', label: 'Work email', type: 'email' },\r\n      { name: 'message', label: 'How can we help?', type: 'textarea' },\r\n    ],\r\n  },\r\n}\r\n```\r\n\r\nThe fields are the config's: name, label, type (`text`, `email`, `tel`,\r\n`textarea`, `select`), and `required`. A field the config does not declare is\r\nnot rendered — the same rule the Worker applies on the way in — and each input\r\nis capped at the length the Worker will accept, so a visitor is stopped by the\r\ninput rather than by a rejection after writing six paragraphs.\r\n\r\n### What happens on submit\r\n\r\n| State | What a visitor sees |\r\n| --- | --- |\r\n| idle | the form |\r\n| submitting | \"Sending…\", the button disabled |\r\n| succeeded | a confirmation; the fields are put away so nothing invites a second send |\r\n| failed | one sentence for the error, field messages beside their inputs, and a summary that takes focus |\r\n\r\nEvery failure the Worker can return has its own sentence, chosen by the\r\nresponse's `code` and never by its `message` — the Worker's `message` is English\r\nfor developers and logs, and printing it would be shipping a Worker's wording\r\ninto a page.\r\n\r\n**Turnstile loads on the first interaction with the form**, not on page load.\r\nA visitor who never touches it pays nothing, and the challenge is ready by the\r\ntime a human has typed a name. A failed check resets the widget, because\r\nTurnstile tokens are single-use.\r\n\r\n**The honeypot never reaches the network.** A filled trap is told exactly what a\r\nsuccessful send is told and no request is made — the Worker rejects fields the\r\nconfig never declared, so posting one would be a validation error rather than a\r\nquiet drop.\r\n\r\n**Without JavaScript the form does not pretend to work.** The anti-spam check\r\nneeds it and the endpoint takes JSON, so a `<noscript>` says so and offers\r\n`legal.entity.email` — the address already published on the imprint. It never\r\nrenders `forms.contact.recipients`: that is where submissions are *routed*, an\r\ninternal address nobody chose to publish, and putting it in HTML would hand it\r\nto every scraper that visits.\r\n\r\nThe copy is English only, like every other string this library originates. The\r\nerror sentences are keyed by the Worker's own codes, so a catalogue can replace\r\nthem wholesale the day localised UI strings have a home.\r\n\r\n## Rules for code in this package\r\n\r\n**Read variables, never values.** No component, stylesheet, or CSS-emitting\r\nmodule may spell out a colour, a font stack, a size, or a duration. Every one\r\nresolves through a `--sf-*` variable. This is what makes a palette, type, or\r\nspacing swap a config edit rather than a component edit, and a test scans\r\n`src/**` and `runtime/**` and fails on a literal.\r\n\r\n**Media queries are generated, never hand-written.** A custom property cannot\r\nappear in a media *condition* — `@media (min-width: var(--sf-breakpoint-md))` is\r\ninvalid and silently never matches. Use `atBreakpoint(tokens.breakpoints, name,\r\nbody)`; the same guard fails on a hand-written breakpoint.\r\n\r\n**Never edit `@bigin-io/site-contract` from here.** It is frozen; changes go\r\nthrough PR + review and every lane rebases.\r\n\r\n## Versioning\r\n\r\nReleased together with the other `@bigin-io` packages at one version\r\n(changesets, ADR-8). A `packages/**` change needs a changeset or CI fails the PR.\r\n","readmeFilename":"README.md"}