@evinvest/kitstart 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +245 -0
  2. package/dist/cli/kitstart-size.d.ts +2 -0
  3. package/dist/cli/kitstart-size.js +11 -0
  4. package/dist/cli/size.d.ts +31 -0
  5. package/dist/cli/size.js +99 -0
  6. package/dist/core/accept.d.ts +49 -0
  7. package/dist/core/accept.js +78 -0
  8. package/dist/core/analytics.d.ts +43 -0
  9. package/dist/core/analytics.js +42 -0
  10. package/dist/core/antispam.d.ts +60 -0
  11. package/dist/core/antispam.js +90 -0
  12. package/dist/core/content.d.ts +82 -0
  13. package/dist/core/content.js +12 -0
  14. package/dist/core/lead.d.ts +88 -0
  15. package/dist/core/lead.js +42 -0
  16. package/dist/core/memo.d.ts +8 -0
  17. package/dist/core/memo.js +19 -0
  18. package/dist/core/place/index.d.ts +5 -0
  19. package/dist/core/place/index.js +5 -0
  20. package/dist/core/place/live.d.ts +30 -0
  21. package/dist/core/place/live.js +132 -0
  22. package/dist/core/place/publication.d.ts +29 -0
  23. package/dist/core/place/publication.js +42 -0
  24. package/dist/core/place/rating.d.ts +16 -0
  25. package/dist/core/place/rating.js +33 -0
  26. package/dist/core/place/types.d.ts +91 -0
  27. package/dist/core/place/types.js +27 -0
  28. package/dist/core/place/view.d.ts +56 -0
  29. package/dist/core/place/view.js +43 -0
  30. package/dist/core/routing.d.ts +121 -0
  31. package/dist/core/routing.js +198 -0
  32. package/dist/core/seo/ld.d.ts +58 -0
  33. package/dist/core/seo/ld.js +161 -0
  34. package/dist/core/seo/sitemap.d.ts +52 -0
  35. package/dist/core/seo/sitemap.js +60 -0
  36. package/dist/core/site.d.ts +118 -0
  37. package/dist/core/site.js +84 -0
  38. package/dist/core/status.d.ts +47 -0
  39. package/dist/core/status.js +62 -0
  40. package/dist/index.d.ts +19 -0
  41. package/dist/index.js +20 -0
  42. package/dist/next/config/build-env.d.ts +32 -0
  43. package/dist/next/config/build-env.js +101 -0
  44. package/dist/next/config/index.d.ts +30 -0
  45. package/dist/next/config/index.js +38 -0
  46. package/dist/next/index.d.ts +12 -0
  47. package/dist/next/index.js +12 -0
  48. package/dist/next/load.d.ts +24 -0
  49. package/dist/next/load.js +36 -0
  50. package/dist/next/metadata.d.ts +31 -0
  51. package/dist/next/metadata.js +66 -0
  52. package/dist/next/og-palette.d.ts +14 -0
  53. package/dist/next/og-palette.js +14 -0
  54. package/dist/next/og.d.ts +31 -0
  55. package/dist/next/og.js +42 -0
  56. package/dist/next/quote-route.d.ts +36 -0
  57. package/dist/next/quote-route.js +124 -0
  58. package/dist/next/routes.d.ts +15 -0
  59. package/dist/next/routes.js +50 -0
  60. package/dist/proxy/index.d.ts +31 -0
  61. package/dist/proxy/index.js +108 -0
  62. package/dist/react/AnalyticsBoundary.d.ts +14 -0
  63. package/dist/react/AnalyticsBoundary.js +55 -0
  64. package/dist/react/AreaChips.d.ts +8 -0
  65. package/dist/react/AreaChips.js +11 -0
  66. package/dist/react/CallBar.d.ts +29 -0
  67. package/dist/react/CallBar.js +7 -0
  68. package/dist/react/Coverage.d.ts +24 -0
  69. package/dist/react/Coverage.js +10 -0
  70. package/dist/react/Faq.d.ts +17 -0
  71. package/dist/react/Faq.js +5 -0
  72. package/dist/react/LangSwitch.d.ts +24 -0
  73. package/dist/react/LangSwitch.js +17 -0
  74. package/dist/react/MapFacade.d.ts +18 -0
  75. package/dist/react/MapFacade.js +8 -0
  76. package/dist/react/PlaceDirectory.d.ts +25 -0
  77. package/dist/react/PlaceDirectory.js +13 -0
  78. package/dist/react/QuoteFormShell.d.ts +34 -0
  79. package/dist/react/QuoteFormShell.js +10 -0
  80. package/dist/react/StatusScreen.d.ts +36 -0
  81. package/dist/react/StatusScreen.js +22 -0
  82. package/dist/react/index.d.ts +27 -0
  83. package/dist/react/index.js +29 -0
  84. package/dist/react/parts.d.ts +7 -0
  85. package/dist/react/parts.js +1 -0
  86. package/dist/server/boot.d.ts +11 -0
  87. package/dist/server/boot.js +20 -0
  88. package/dist/server/client-key.d.ts +22 -0
  89. package/dist/server/client-key.js +20 -0
  90. package/dist/server/env.d.ts +44 -0
  91. package/dist/server/env.js +73 -0
  92. package/dist/server/index.d.ts +15 -0
  93. package/dist/server/index.js +15 -0
  94. package/dist/server/lead-store-postgres.d.ts +14 -0
  95. package/dist/server/lead-store-postgres.js +23 -0
  96. package/dist/server/lead-store-sqlite.d.ts +21 -0
  97. package/dist/server/lead-store-sqlite.js +158 -0
  98. package/dist/server/lead-store.d.ts +20 -0
  99. package/dist/server/lead-store.js +43 -0
  100. package/dist/server/notify.d.ts +37 -0
  101. package/dist/server/notify.js +79 -0
  102. package/dist/server/place-source.d.ts +48 -0
  103. package/dist/server/place-source.js +107 -0
  104. package/dist/server/smtp.d.ts +38 -0
  105. package/dist/server/smtp.js +197 -0
  106. package/dist/testing/e2e.d.ts +52 -0
  107. package/dist/testing/e2e.js +57 -0
  108. package/dist/testing/fixtures.d.ts +11 -0
  109. package/dist/testing/fixtures.js +53 -0
  110. package/dist/testing/index.d.ts +12 -0
  111. package/dist/testing/index.js +12 -0
  112. package/dist/testing/landing-contract.d.ts +33 -0
  113. package/dist/testing/landing-contract.js +95 -0
  114. package/dist/testing/lead-store-contract.d.ts +13 -0
  115. package/dist/testing/lead-store-contract.js +55 -0
  116. package/dist/testing/served-files.d.ts +7 -0
  117. package/dist/testing/served-files.js +41 -0
  118. package/package.json +129 -0
  119. package/template/README.md +55 -0
  120. package/template/app/[locale]/[location]/layout.tsx +29 -0
  121. package/template/app/[locale]/[location]/page.tsx +18 -0
  122. package/template/app/[locale]/[location]/prices/page.tsx +26 -0
  123. package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
  124. package/template/app/[locale]/error.tsx +14 -0
  125. package/template/app/[locale]/layout.tsx +28 -0
  126. package/template/app/[locale]/not-found.tsx +18 -0
  127. package/template/app/[locale]/page.tsx +40 -0
  128. package/template/app/[locale]/thanks/page.tsx +21 -0
  129. package/template/app/global-not-found.tsx +28 -0
  130. package/template/app/globals.css +18 -0
  131. package/template/app/health/route.ts +6 -0
  132. package/template/app/og/route.tsx +27 -0
  133. package/template/app/quote/route.ts +17 -0
  134. package/template/app/robots.ts +6 -0
  135. package/template/app/sitemap.ts +8 -0
  136. package/template/assets/brand.toml +68 -0
  137. package/template/assets/card.toml +8 -0
  138. package/template/assets/fonts/Inter-OFL.txt +93 -0
  139. package/template/assets/fonts/Inter-Regular.ttf +0 -0
  140. package/template/assets/fonts/Inter-Regular.woff2 +0 -0
  141. package/template/assets/mark.svg +1 -0
  142. package/template/deploy/config.nix +24 -0
  143. package/template/eslint.config.mjs +28 -0
  144. package/template/flake.nix +46 -0
  145. package/template/instrumentation.ts +13 -0
  146. package/template/next.config.ts +5 -0
  147. package/template/package.json +44 -0
  148. package/template/postcss.config.mjs +3 -0
  149. package/template/proxy.ts +11 -0
  150. package/template/src/entities/content/index.ts +12 -0
  151. package/template/src/entities/content/model/en.ts +47 -0
  152. package/template/src/entities/content/model/fr.ts +47 -0
  153. package/template/src/entities/content/model/types.ts +37 -0
  154. package/template/src/entities/place/api/load.ts +12 -0
  155. package/template/src/entities/place/index.ts +3 -0
  156. package/template/src/entities/place/server.ts +1 -0
  157. package/template/src/shared/config/env.ts +17 -0
  158. package/template/src/shared/config/i18n.ts +15 -0
  159. package/template/src/shared/config/lead.ts +15 -0
  160. package/template/src/shared/config/places.ts +20 -0
  161. package/template/src/shared/config/public.ts +11 -0
  162. package/template/src/shared/config/site.ts +39 -0
  163. package/template/src/shared/ui/fonts.ts +15 -0
  164. package/template/src/views/home/index.ts +1 -0
  165. package/template/src/views/home/ui/PlaceHome.tsx +76 -0
  166. package/template/src/views/not-found/index.ts +1 -0
  167. package/template/src/views/not-found/server.ts +1 -0
  168. package/template/src/views/not-found/ui/Gone.tsx +29 -0
  169. package/template/src/views/not-found/ui/NotFound.tsx +27 -0
  170. package/template/src/views/place/server.ts +13 -0
  171. package/template/src/views/server-error/index.ts +1 -0
  172. package/template/src/views/server-error/ui/ServerError.tsx +24 -0
  173. package/template/steiger.config.ts +26 -0
  174. package/template/tests/bundle_budget.txt +8 -0
  175. package/template/tests/contract.test.ts +14 -0
  176. package/template/tests/e2e/playwright.config.ts +26 -0
  177. package/template/tests/e2e/screenshot-section.css +5 -0
  178. package/template/tests/e2e/sections.spec.ts +12 -0
  179. package/template/tests/e2e/tsconfig.json +11 -0
  180. package/template/tests/empty.ts +1 -0
  181. package/template/tsconfig.json +23 -0
  182. package/template/vitest.config.ts +20 -0
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # @evinvest/kitstart
2
+
3
+ The machinery of a local-service landing site — the part every brand of the
4
+ vertical shares, lifted out of the aquafix reference site so a new brand is a
5
+ `site.ts`, its copy and its own sections.
6
+
7
+ What is here is **behaviour**, not look: routing between subdomains and
8
+ locales, the place model and its publication gate, the lead funnel and its
9
+ antispam, schema.org and sitemaps, analytics events. What a brand looks like
10
+ stays in the brand.
11
+
12
+ ## Subpaths
13
+
14
+ | Import | Runtime | What |
15
+ |---|---|---|
16
+ | `@evinvest/kitstart` | anywhere (edge, client, server) | `defineSite`, places, routing (`createRouting`), the lead schema and funnel (`createAcceptLead`), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
17
+ | `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `sendMail`, `clientKey` |
18
+ | `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
19
+ | `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
20
+ | `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
21
+ | `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
22
+ | `@evinvest/kitstart/testing` | a brand's vitest | `describeLandingContract(site, { globalsCss, proxySource, text })`, `describeLeadStoreContract(name, harness)`, `storefrontPlace`, `serviceAreaPlace`, `testLead` |
23
+ | `@evinvest/kitstart/testing/e2e` | a brand's Playwright | `defineSectionSuite(sections)`, `settle(page, selector)`, `BREAKPOINTS` |
24
+ | bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against `tests/bundle_budget.txt`; fails closed |
25
+
26
+ `vitest` and `@playwright/test` are not peers at all: they are the brand's
27
+ own test runners, which it installs in its `devDependencies`. As peers —
28
+ even optional ones — npm counted them among a brand's production
29
+ dependencies, and a runner's advisory turned `npm audit --omit=dev` red.
30
+
31
+ ## A new brand
32
+
33
+ `template/` (shipped in the tarball too) is the skeleton: a `single`-topology,
34
+ service-area site before launch (no `site` in `assets/card.toml` → noindex,
35
+ robots disallow), with every route file a few literal lines over a factory.
36
+ Copy it, then follow its README. CI builds it on every PR exactly as a brand
37
+ would — packed tarballs, `next build`, its own tests, `kitstart-size`, and a
38
+ live standalone server answering `/`, `/fr`, a page, a 404, the form POST, the
39
+ OG card and robots (`npm run check:template`).
40
+
41
+ ## Nix: `lib.mkLanding`
42
+
43
+ The lib flake exports the flake a landing shares (`nix/mk-landing.nix`), so a
44
+ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
+ the whole of one:
46
+
47
+ ```nix
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.1.0"; # the version in package-lock.json
49
+ inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
+
51
+ landing = ev.lib.mkLanding {
52
+ pkgs, root, pname, sitePort, buildFiles, # required
53
+ prodEnv ? { }, # baked into the image (deploy/config.nix)
54
+ v_flakes ? null, # without it: no `container`
55
+ nodejs ? pkgs.nodejs_22, nodeRuntime ? pkgs.nodejs-slim_22,
56
+ budgetFile ? "tests/bundle_budget.txt", gatedRoute ? "/[locale]/[location]",
57
+ requiredFiles ? [ ], # must be in the standalone output (OG fonts)
58
+ mounts ? [ "/data" ], criticality ? "high",
59
+ smoke ? { page = "/fr"; }, # + host, og, quote = { field = value; }
60
+ containerAttr ? ".#container",
61
+ checkKitstartVersion ? true,
62
+ e2eConfig ? "tests/e2e",
63
+ packageSourceOverrides ? { }, # lock key → `file:`/vendored tarball
64
+ };
65
+ # → { site, bundleBudget, packages.{default,site,container}, containers,
66
+ # checks.{site,bundle-budget}, apps.{default,help,dev,test,accept-test,size,container-smoke},
67
+ # devShell }
68
+ ```
69
+
70
+ - `site`: the hermetic `next build` from `package-lock.json` through
71
+ `importNpmLock` (dependencies keyed on the manifest and the lock only;
72
+ foreign optional native binaries never fetched — the self-check's lock
73
+ carries a Windows-only package with a bogus integrity to prove it).
74
+ `packageSourceOverrides` serves lock entries the registry cannot yet —
75
+ `{ "node_modules/@evinvest/kitstart" = ./vendor/kitstart.tgz; }` builds a
76
+ brand against an unpublished kitstart; the self-check vendors one package
77
+ whose `resolved` 404s.
78
+ - `container`: the OCI image on node-slim, `prodEnv` baked in.
79
+ - `checks.bundle-budget`: `kitstart-size` on the Nix build.
80
+ - `apps.test` (tsc — the app, then `e2eConfig` as its own project with its own
81
+ `tsconfig.json` — lint, vitest, build, size, Playwright on the flake's
82
+ pinned browsers), `accept-test` (Linux only), `size`, `dev`, `help`.
83
+ - `apps.container-smoke`: boots the image with a host port docker picks
84
+ (`-p 127.0.0.1::<port>`: landing ports sit in Linux's ephemeral range), checks
85
+ `/health`, the 302, the page, the OG card and the form POST landing in the
86
+ mount, then boots it again with every `prodEnv` key blanked and wants 500 —
87
+ and once more with only the lead store's key (`LEADS_DB_PATH` /
88
+ `LEADS_DB_URL`) blanked, wanting 500 from the store's refusal alone.
89
+
90
+ **Versions move together.** mkLanding refuses a lock whose
91
+ `@evinvest/kitstart` differs from the lib revision's
92
+ `ts/kitstart/package.json`: the size gate runs the lib's copy of the bin, and
93
+ the two must be one release. Pin the flake to the matching
94
+ `@evinvest/kitstart-vX.Y.Z` tag.
95
+
96
+ ## Widgets
97
+
98
+ Structural only — where behaviour matters more than look. Marketing sections
99
+ (hero, prices, reviews…) stay in the brand until two brands hold the same one.
100
+ Each widget is a Server Component unless it needs the browser (`MapFacade`,
101
+ `AnalyticsBoundary`), styled with the kit's token roles only, restyled through
102
+ `className` — and, where a brand needs geometry inside one (`Faq`, `CallBar`,
103
+ `StatusScreen`, `PlaceDirectory`, `Coverage`), through its named parts:
104
+ `classNames={{ list: "rounded-none", answer: "px-2" }}`, merged after the
105
+ kit's classes so the brand's utility wins. No descendant selectors: they
106
+ break silently when a widget's markup moves. `QuoteFormShell` is headless: it owns the hidden fields and the
107
+ honeypot the funnel reads; the visible fields are the brand's children.
108
+ `StatusScreen` takes the brand's name and marks as props, so the client error
109
+ boundary never imports the site config.
110
+
111
+ Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
112
+ package:
113
+
114
+ ```css
115
+ /* app/globals.css; from src/app/ it is ../../node_modules/… */
116
+ @source "../node_modules/@evinvest/kitstart/dist";
117
+ ```
118
+
119
+ Visual baselines: `test/visual/` renders each widget to a static page
120
+ (`react-dom/server` over the kit's `tokens.css`), and `nix run
121
+ .#kitstart-visual` captures and compares them byte for byte on Linux. PNGs
122
+ come from the `kitstart-visual` CI artifact only.
123
+
124
+ ## Thin `app/` routes
125
+
126
+ Segment config, `config.matcher` and `next/font/local` must be literals in the
127
+ route file — Next reads them statically — so the literal stays in the brand's
128
+ file and the handler comes from a factory:
129
+
130
+ ```ts
131
+ // proxy.ts
132
+ export const proxy = createProxy(site);
133
+ // Files too: `decide` passes the routes outside `[locale]` and
134
+ // `site.publicFiles` (`/icon.svg`), and 404s every other path, `/wp-login.php`
135
+ // included — let past, it would be a cached bare 404 without the phone.
136
+ export const config = { matcher: ["/((?!_next/).*)"] };
137
+
138
+ // app/quote/route.ts
139
+ export const dynamic = "force-dynamic";
140
+ export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable });
141
+
142
+ // app/sitemap.ts — reads the Host header, so it is dynamic; pages are not
143
+ export const dynamic = "force-dynamic";
144
+ export default sitemapRoute(site, places);
145
+
146
+ // app/[locale]/[location]/layout.tsx — ISR: no page reads the request
147
+ export function generateStaticParams() { return []; }
148
+ export const revalidate = 600;
149
+ ```
150
+
151
+ `createPlaceLoader(site, places)(params)` returns the `PlaceView` for a page,
152
+ reading the link mode from the `location` param (`_royat` = host mode).
153
+
154
+ **Dead paths.** Next 16 answers a `notFound()` with an empty shell it fills in
155
+ after hydration — a blank page without JavaScript. So the proxy knows the dead
156
+ paths itself (`decide` → `gone`) and rewrites each to `/<locale>/404/404`, a
157
+ path no route matches, with the language and place in `GONE_HEADER`. The
158
+ brand's `app/global-not-found.tsx` (`experimental.globalNotFound`, set by
159
+ `withLanding`) reads that header — the only thing that does — and renders the
160
+ 404 on the server with `statusTarget(site, parseGoneHeader(…))`. The
161
+ `notFound()` boundary under `[locale]` stays for what the proxy cannot foresee
162
+ (a place the live source withdrew); it and `error.tsx` are client modules and
163
+ take `brandStatusTarget({ locales, phone }, locale)`, never the site config,
164
+ which would carry every place into every page's bundle. Redirects the proxy
165
+ chooses per visitor answer `Cache-Control: private, no-store`.
166
+
167
+ ## Server invariants
168
+
169
+ - **A page's place loader never throws on the live source.** Pages are cached
170
+ (ISR); a cold render that throws is Next's bare `Internal Server Error`
171
+ with no phone on it. A 5xx or an unreachable source serves the baked place
172
+ (`noindex` until the gate fields are back); only a 410, or a 404 in the
173
+ source's own JSON, withdraws a place — a bare 404 is an ingress, not the
174
+ source. The sitemap is the one strict reader (`getPlaceStrict` throws).
175
+ - **The lead is durable before anything else happens.** Every `LeadStore`
176
+ adapter passes `describeLeadStoreContract` and migrates itself to
177
+ `LEAD_SCHEMA_VERSION` on open. SQLite keeps the Rust server's columns
178
+ (`job`, `zip`, `mobile`) and recognises every earlier table in place.
179
+ - **Mail is checked at boot.** With `SMTP_URL` set, `leadNotifier` throws when
180
+ it is built if the URL is malformed (the error never repeats it), there is
181
+ no sender (`LEAD_NOTIFY_FROM`, or `leads@<domain>`) or no recipient
182
+ (`LEAD_NOTIFY_TO`, or the brand's email). Both are bare addresses
183
+ (`leads@brand.fr`), not `Name <…>`. Off loopback, a server without TLS gets
184
+ nothing; past `MAIL_PER_MINUTE` a minute the lead is stored and only logged.
185
+ - **The rate limit knows whose address it counts.** `TRUSTED_PROXY` is
186
+ `cloudflare` (only `CF-Connecting-IP`) or `xff:<n>` (the n-th
187
+ `X-Forwarded-For` hop from the right); production refuses to boot without it.
188
+ - **SQLite wants one writer node.** The file lives on a ReadWriteOnce volume
189
+ mounted by one node; pods on it take turns through `busy_timeout`. A shared
190
+ network filesystem is not a place for it — that is what the Postgres port
191
+ is for.
192
+
193
+ ## The site
194
+
195
+ ```ts
196
+ import { createLocaleRegistry, defineSite, STOREFRONT_GATE } from "@evinvest/kitstart";
197
+
198
+ export const site = defineSite({
199
+ brand: { id: "aquafix", name: "Aquafix", legalName: "Aquafix SAS", email, phone, domain: "aquafix.top", businessType: "Plumber" },
200
+ i18n: createLocaleRegistry({ locales: ["fr", "en"], labels, default: "fr", prefixDefaultLocale: true, hreflang: { fr: "fr-FR" } }),
201
+ ogLocale: { fr: "fr_FR", en: "en_GB" },
202
+ topology: { kind: "subdomains", apex: "directory" }, // or { kind: "single", place: "paris" }
203
+ pages: { home: "", prices: "/prices" },
204
+ places: PLACES,
205
+ publication: STOREFRONT_GATE,
206
+ lead: LEAD,
207
+ });
208
+ ```
209
+
210
+ `domain: null` is a site before launch: every page `noindex`, robots disallow
211
+ everything, the sitemap empty.
212
+
213
+ ## Routing: the link mode rides in the path
214
+
215
+ Pages are cached (ISR), so no page may read a request header. The proxy tells a
216
+ page how to write its links through the route it rewrites to:
217
+
218
+ | Request | Rendered route | Links |
219
+ |---|---|---|
220
+ | `royat.aquafix.top/fr/prices` | `/fr/_royat/prices` | `/fr/…` (host mode) |
221
+ | `aquafix.top/fr/royat/prices` | as is | `/fr/royat/…` (path mode) |
222
+ | single site `clean.example/fr/prices` | `/fr/_paris/prices` | `/fr/…` |
223
+
224
+ `parsePlaceParam("_royat")` reads the mode back out of the `[location]` param.
225
+
226
+ ## Publishing
227
+
228
+ `package.json` says `0.0.0` on purpose: `nix run .#publish -- minor` bumps it
229
+ to `0.1.0`, the version `template/` asks for (`^0.1.0`). kitstart's peers
230
+ must be on the registry first, so a release is two runs:
231
+
232
+ 1. `nix run .#publish -- minor --npm-only --only @evinvest/uikit --only @evinvest/marketing`,
233
+ then wait until `npm view @evinvest/uikit version` answers the new one;
234
+ 2. `nix run .#publish -- minor --npm-only --only @evinvest/kitstart`.
235
+
236
+ The first publish of a new name in the scope needs an automation or classic
237
+ token: a granular token cannot create a name. Bump the lib flake revision a
238
+ brand pins (`mkLanding`) in the same PR as its npm version.
239
+
240
+ ## Shared fixtures with Rust
241
+
242
+ `tests/fixtures/kitstart/` at the repo root holds the site configs, routing
243
+ decisions and the JSON-LD / metadata / sitemap goldens (aquafix's, verbatim).
244
+ This package's tests and the Rust `kitstart` feature both run them; a change to
245
+ a builder's output is a change to those files, visible in review.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // kitstart-size [<build root>] [--route /[locale]/[location]] [--budget tests/bundle_budget.txt]
3
+ // — the first-load JS of a place page against the brand's budget. See `size.ts`.
4
+ import { measure, parseArgs } from "./size.js";
5
+ try {
6
+ process.exitCode = measure(parseArgs(process.argv.slice(2), process.cwd()));
7
+ }
8
+ catch (error) {
9
+ console.error(`✘ ${error instanceof Error ? error.message : String(error)}`);
10
+ process.exitCode = 1;
11
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The one hard gate of a landing: the gzip weight of the JavaScript a place
3
+ * page makes the browser download before it is interactive, against the
4
+ * brand's committed budget. Run after `next build`; `mkLanding`'s
5
+ * `bundle-budget` check runs it against the Nix build.
6
+ */
7
+ /** The page the visitor lands on; every place page shares its chunks. */
8
+ export declare const GATED_ROUTE = "/[locale]/[location]";
9
+ export declare const STATS = ".next/diagnostics/route-bundle-stats.json";
10
+ export declare const BUDGET = "tests/bundle_budget.txt";
11
+ /** The budget file is commented prose; the number is its last non-comment line. */
12
+ export declare function parseBudget(text: string, file?: string): number;
13
+ /**
14
+ * The chunks Next itself lists as the route's first load. Undocumented output,
15
+ * which is why a missing file or route is a failure and never a pass: a gate
16
+ * that reads nothing and reports zero is worse than no gate.
17
+ */
18
+ export declare function firstLoadChunks(statsJson: string, route: string): string[];
19
+ /** gzip at zlib's default level, which is what Next's own server compresses with. */
20
+ export declare const gzipSize: (bytes: Buffer) => number;
21
+ export interface SizeArgs {
22
+ /** Where `tests/bundle_budget.txt` is: the brand repo. */
23
+ repo: string;
24
+ /** Where `.next/` is: the repo, or the Nix build's output. */
25
+ root: string;
26
+ route: string;
27
+ budget: string;
28
+ }
29
+ export declare function parseArgs(argv: readonly string[], cwd: string): SizeArgs;
30
+ /** Prints the chunks and the total; returns the exit code. */
31
+ export declare function measure(args: SizeArgs, log?: Pick<Console, "log" | "error">): number;
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { gzipSync } from "node:zlib";
4
+ /**
5
+ * The one hard gate of a landing: the gzip weight of the JavaScript a place
6
+ * page makes the browser download before it is interactive, against the
7
+ * brand's committed budget. Run after `next build`; `mkLanding`'s
8
+ * `bundle-budget` check runs it against the Nix build.
9
+ */
10
+ /** The page the visitor lands on; every place page shares its chunks. */
11
+ export const GATED_ROUTE = "/[locale]/[location]";
12
+ export const STATS = ".next/diagnostics/route-bundle-stats.json";
13
+ export const BUDGET = "tests/bundle_budget.txt";
14
+ /** The budget file is commented prose; the number is its last non-comment line. */
15
+ export function parseBudget(text, file = BUDGET) {
16
+ const lines = text
17
+ .split("\n")
18
+ .map(l => l.trim())
19
+ .filter(l => l !== "" && !l.startsWith("#"));
20
+ const last = lines.at(-1);
21
+ if (last === undefined || !/^\d+$/.test(last)) {
22
+ throw new Error(`${file}: the last non-comment line must be a byte count, got ${JSON.stringify(last)}`);
23
+ }
24
+ return Number(last);
25
+ }
26
+ function isRouteStats(value) {
27
+ if (typeof value !== "object" || value === null)
28
+ return false;
29
+ const route = Reflect.get(value, "route");
30
+ const chunks = Reflect.get(value, "firstLoadChunkPaths");
31
+ return typeof route === "string" && Array.isArray(chunks) && chunks.every(c => typeof c === "string");
32
+ }
33
+ /**
34
+ * The chunks Next itself lists as the route's first load. Undocumented output,
35
+ * which is why a missing file or route is a failure and never a pass: a gate
36
+ * that reads nothing and reports zero is worse than no gate.
37
+ */
38
+ export function firstLoadChunks(statsJson, route) {
39
+ const parsed = JSON.parse(statsJson);
40
+ if (!Array.isArray(parsed))
41
+ throw new Error(`${STATS}: expected an array of routes`);
42
+ const entry = parsed.filter(isRouteStats).find(r => r.route === route);
43
+ if (!entry)
44
+ throw new Error(`${STATS}: no entry for ${route} — did the route move, or Next's diagnostics?`);
45
+ if (entry.firstLoadChunkPaths.length === 0)
46
+ throw new Error(`${STATS}: ${route} lists no chunks`);
47
+ return entry.firstLoadChunkPaths;
48
+ }
49
+ /** gzip at zlib's default level, which is what Next's own server compresses with. */
50
+ export const gzipSize = (bytes) => gzipSync(bytes).length;
51
+ export function parseArgs(argv, cwd) {
52
+ const args = { repo: cwd, root: cwd, route: GATED_ROUTE, budget: BUDGET };
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const arg = argv[i];
55
+ const value = () => {
56
+ const next = argv[++i];
57
+ if (next === undefined)
58
+ throw new Error(`${arg} needs a value`);
59
+ return next;
60
+ };
61
+ if (arg === "--route")
62
+ args.route = value();
63
+ else if (arg === "--budget")
64
+ args.budget = value();
65
+ else if (arg !== undefined && !arg.startsWith("--"))
66
+ args.root = arg;
67
+ else
68
+ throw new Error(`unknown argument ${arg}`);
69
+ }
70
+ return args;
71
+ }
72
+ /** Prints the chunks and the total; returns the exit code. */
73
+ export function measure(args, log = console) {
74
+ let stats;
75
+ try {
76
+ stats = readFileSync(join(args.root, STATS), "utf8");
77
+ }
78
+ catch {
79
+ log.error(`✘ ${join(args.root, STATS)} is missing — run \`next build\` first`);
80
+ return 1;
81
+ }
82
+ const budget = parseBudget(readFileSync(join(args.repo, args.budget), "utf8"), args.budget);
83
+ let total = 0;
84
+ let raw = 0;
85
+ for (const chunk of firstLoadChunks(stats, args.route)) {
86
+ const bytes = readFileSync(join(args.root, chunk));
87
+ const gz = gzipSize(bytes);
88
+ total += gz;
89
+ raw += bytes.length;
90
+ log.log(` ${String(gz).padStart(8)} B gz ${relative(args.root, join(args.root, chunk))}`);
91
+ }
92
+ const kb = (n) => (n / 1024).toFixed(1);
93
+ log.log(` ${args.route}: ${kb(total)} KB gz (${kb(raw)} KB raw) / budget ${kb(budget)} KB gz`);
94
+ if (total > budget) {
95
+ log.error(`✘ over budget by ${total - budget} B. Raising ${args.budget} is a deliberate commit, with a reason.`);
96
+ return 1;
97
+ }
98
+ return 0;
99
+ }
@@ -0,0 +1,49 @@
1
+ import { type RateLimiter } from "./antispam.js";
2
+ import { type Lead } from "./lead.js";
3
+ import type { Site } from "./site.js";
4
+ /** Hidden fields the form carries besides what the visitor types. */
5
+ export declare const LOCATION_FIELD = "location";
6
+ export declare const LOCALE_FIELD = "locale";
7
+ export declare const FORM_ID_FIELD = "form_id";
8
+ export type Outcome<L extends string> = {
9
+ kind: "stored";
10
+ id: number;
11
+ lead: Lead;
12
+ locale: L;
13
+ formId: string;
14
+ } | {
15
+ kind: "invalid";
16
+ why: string;
17
+ locale: L;
18
+ slug: string | null;
19
+ } | {
20
+ kind: "failed";
21
+ locale: L;
22
+ slug: string | null;
23
+ };
24
+ export interface AcceptDeps {
25
+ /** The commit point: resolves once the lead is durable. */
26
+ insert: (lead: Lead) => Promise<number>;
27
+ /** Runs after the response is sent; see the route handler. */
28
+ defer: (task: () => Promise<void> | void) => void;
29
+ notify: (lead: Lead, id: number) => Promise<void>;
30
+ capture: (lead: Lead, formId: string) => void;
31
+ limiter: RateLimiter;
32
+ now: number;
33
+ log: Pick<Console, "warn" | "error">;
34
+ }
35
+ /**
36
+ * The funnel's commit point, independent of HTTP. Order is the invariant:
37
+ * validate → screen → **insert** → (deferred) notify and capture.
38
+ *
39
+ * Nothing that passes validation is thrown away. A submission the barriers
40
+ * suspect is stored with its `spamVerdict` and simply not notified: a
41
+ * mis-set clock, a fast thumb or a shared carrier NAT must not cost a real
42
+ * customer, and a reviewer can still find the row. A lead with no point, or
43
+ * one we no longer have (an old page, a stale form), is stored without one.
44
+ *
45
+ * A notification failure logs and changes nothing; a store failure is
46
+ * `failed`, never `stored` — a thank-you page for a lead that was never
47
+ * written is the worst outcome this system can produce.
48
+ */
49
+ export declare function createAcceptLead<L extends string, P extends string>(site: Site<L, P>): (form: FormData, clientKey: string, deps: AcceptDeps) => Promise<Outcome<L>>;
@@ -0,0 +1,78 @@
1
+ import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
2
+ import { MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
3
+ /** Hidden fields the form carries besides what the visitor types. */
4
+ export const LOCATION_FIELD = "location";
5
+ export const LOCALE_FIELD = "locale";
6
+ export const FORM_ID_FIELD = "form_id";
7
+ const FORM_ID = /^[a-z0-9_-]{1,32}$/;
8
+ function field(form, name) {
9
+ const value = form.get(name);
10
+ return typeof value === "string" ? value.trim().slice(0, MAX_FIELD) : null;
11
+ }
12
+ /**
13
+ * The funnel's commit point, independent of HTTP. Order is the invariant:
14
+ * validate → screen → **insert** → (deferred) notify and capture.
15
+ *
16
+ * Nothing that passes validation is thrown away. A submission the barriers
17
+ * suspect is stored with its `spamVerdict` and simply not notified: a
18
+ * mis-set clock, a fast thumb or a shared carrier NAT must not cost a real
19
+ * customer, and a reviewer can still find the row. A lead with no point, or
20
+ * one we no longer have (an old page, a stale form), is stored without one.
21
+ *
22
+ * A notification failure logs and changes nothing; a store failure is
23
+ * `failed`, never `stored` — a thank-you page for a lead that was never
24
+ * written is the worst outcome this system can produce.
25
+ */
26
+ export function createAcceptLead(site) {
27
+ return (form, clientKey, deps) => accept(site, form, clientKey, deps);
28
+ }
29
+ async function accept(site, form, clientKey, deps) {
30
+ const rawSlug = field(form, LOCATION_FIELD);
31
+ const slug = rawSlug && site.placeSlugs.includes(rawSlug) ? rawSlug : null;
32
+ const rawLocale = field(form, LOCALE_FIELD);
33
+ const locale = site.i18n.isLocale(rawLocale) ? rawLocale : site.i18n.defaultLocale;
34
+ const candidate = readCandidate(site.lead, form, slug);
35
+ const why = validateCandidate(site.lead, candidate);
36
+ if (why) {
37
+ deps.log.warn(`quote: rejected a submission for ${slug ?? "no point"}: missing ${why}`);
38
+ return { kind: "invalid", why, locale, slug };
39
+ }
40
+ // After validation, so a typo corrected and resent does not spend the limit.
41
+ const verdict = screen({
42
+ honeypot: [HONEYPOT_FIELD, ...LEGACY_HONEYPOT_FIELDS].map(name => field(form, name)).find(v => v) ?? null,
43
+ renderedAt: field(form, RENDERED_AT_FIELD),
44
+ clientKey,
45
+ now: deps.now,
46
+ limiter: deps.limiter,
47
+ });
48
+ const lead = { ...candidate, spamVerdict: verdict === "ok" ? null : verdict };
49
+ let id;
50
+ try {
51
+ id = await deps.insert(lead);
52
+ }
53
+ catch (error) {
54
+ deps.log.error(`quote: the lead store rejected a submission for ${slug ?? "no point"}`, error);
55
+ return { kind: "failed", locale, slug };
56
+ }
57
+ // It reaches analytics as a property; anything but a short slug is dropped.
58
+ const posted = field(form, FORM_ID_FIELD);
59
+ const formId = posted !== null && FORM_ID.test(posted) ? posted : "quote";
60
+ // The render stamp comes from the client, so it only marks: a `too-fast`
61
+ // lead (or one from a page cached before the stamp existed) is still sent
62
+ // on, flagged. The honeypot and the rate limit are the server's own
63
+ // evidence, and those leads wait in the table for a reviewer.
64
+ if (lead.spamVerdict && lead.spamVerdict !== "too-fast") {
65
+ deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified`);
66
+ return { kind: "stored", id, lead, locale, formId };
67
+ }
68
+ deps.defer(async () => {
69
+ try {
70
+ await deps.notify(lead, id);
71
+ }
72
+ catch (error) {
73
+ deps.log.error(`quote: lead ${id} is stored but its notification failed`, error);
74
+ }
75
+ });
76
+ deps.defer(() => deps.capture(lead, formId));
77
+ return { kind: "stored", id, lead, locale, formId };
78
+ }
@@ -0,0 +1,43 @@
1
+ import { type AnalyticsSink } from "@evinvest/analytics";
2
+ /**
3
+ * The event model. Analytics records; it never decides what renders.
4
+ *
5
+ * `contact_intent_click` is an intention, not a lead: a click on `tel:` is
6
+ * visible, whether the call happened is not. It is named apart from
7
+ * `lead_form_submit` so the two numbers can never be read as one.
8
+ */
9
+ export declare const EVENTS: {
10
+ readonly pageView: "location_page_view";
11
+ readonly intent: "contact_intent_click";
12
+ readonly leadSubmit: "lead_form_submit";
13
+ };
14
+ export type IntentChannel = "form_open" | "whatsapp" | "phone" | "booking";
15
+ /**
16
+ * Every property name an event may carry. A customer's phone or address comes
17
+ * through the same pages, and once it reaches PostHog the only remedy is
18
+ * deleting the project's history — so the list is enforced by the sink (dev
19
+ * throws, prod drops) rather than by review.
20
+ */
21
+ export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id"];
22
+ /**
23
+ * The thank-you page is the form's receipt, not a visit to the point: counting
24
+ * it as `location_page_view` would add a page view to every lead.
25
+ */
26
+ export declare function countsAsPageView(pathname: string): boolean;
27
+ /**
28
+ * Where events go, and whose they are. `key: null` → every capture is a silent
29
+ * no-op. `brandId` travels in the target, not in an import of the site config:
30
+ * this module reaches the client island, and the config would ride with it.
31
+ */
32
+ export interface AnalyticsTarget {
33
+ key: string | null;
34
+ host: string;
35
+ brandId: string;
36
+ }
37
+ /**
38
+ * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
39
+ * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
40
+ * because the events that matter most — a tap on `tel:` or `wa.me` — are
41
+ * followed by the page handing the visitor to another app.
42
+ */
43
+ export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null): AnalyticsSink;
@@ -0,0 +1,42 @@
1
+ import { createBeaconSink } from "@evinvest/analytics";
2
+ import { THANKS } from "./routing.js";
3
+ /**
4
+ * The event model. Analytics records; it never decides what renders.
5
+ *
6
+ * `contact_intent_click` is an intention, not a lead: a click on `tel:` is
7
+ * visible, whether the call happened is not. It is named apart from
8
+ * `lead_form_submit` so the two numbers can never be read as one.
9
+ */
10
+ export const EVENTS = {
11
+ pageView: "location_page_view",
12
+ intent: "contact_intent_click",
13
+ leadSubmit: "lead_form_submit",
14
+ };
15
+ /**
16
+ * Every property name an event may carry. A customer's phone or address comes
17
+ * through the same pages, and once it reaches PostHog the only remedy is
18
+ * deleting the project's history — so the list is enforced by the sink (dev
19
+ * throws, prod drops) rather than by review.
20
+ */
21
+ export const ALLOWED_PROPS = ["brand_id", "location_id", "source", "device", "channel", "form_id"];
22
+ /**
23
+ * The thank-you page is the form's receipt, not a visit to the point: counting
24
+ * it as `location_page_view` would add a page view to every lead.
25
+ */
26
+ export function countsAsPageView(pathname) {
27
+ return !pathname.replace(/\/+$/, "").endsWith(THANKS);
28
+ }
29
+ /**
30
+ * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
31
+ * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
32
+ * because the events that matter most — a tap on `tel:` or `wa.me` — are
33
+ * followed by the page handing the visitor to another app.
34
+ */
35
+ export function analyticsSink(target, locationId) {
36
+ return createBeaconSink({
37
+ key: target.key ?? undefined,
38
+ host: target.host,
39
+ allowedProps: ALLOWED_PROPS,
40
+ globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
41
+ });
42
+ }
@@ -0,0 +1,60 @@
1
+ import type { SpamVerdict } from "./lead.js";
2
+ /**
3
+ * Three cheap barriers, none of which needs the visitor to run a script — the
4
+ * form has to work before any JavaScript arrives, so a challenge widget is not
5
+ * an option. A rejected submission is answered exactly like an accepted one,
6
+ * so a bot learns nothing from the response.
7
+ */
8
+ /**
9
+ * The field a human never sees and a form-filling bot fills. A neutral name:
10
+ * a browser's autofill knows `website`, `url` or `company` and would fill the
11
+ * trap for a real person.
12
+ */
13
+ export declare const HONEYPOT_FIELD = "hp_ref";
14
+ /** The trap's name on pages cached before it was renamed; still read. */
15
+ export declare const LEGACY_HONEYPOT_FIELDS: readonly string[];
16
+ /** When the form was rendered, in ms since the epoch. */
17
+ export declare const RENDERED_AT_FIELD = "t";
18
+ /** Faster than this from render to submit is a script, not a person. */
19
+ export declare const MIN_FILL_MS = 3000;
20
+ export type Screening = "ok" | SpamVerdict;
21
+ export declare function checkTiming(renderedAt: string | null, now: number): Screening;
22
+ /** How many distinct keys a limiter tracks before new ones share one bucket. */
23
+ export declare const RATE_LIMIT_MAX_KEYS = 10000;
24
+ /** The shared bucket keys past the cap count against. */
25
+ export declare const RATE_LIMIT_OVERFLOW_KEY = "\0overflow";
26
+ /**
27
+ * A fixed-window counter per client address, in memory. Per process by design:
28
+ * it is a speed bump for one bot hammering one pod, not an accounting system,
29
+ * and a restart forgetting it costs nothing.
30
+ *
31
+ * Bounded: past `maxKeys` distinct addresses in one window, new ones count
32
+ * against one shared bucket — a flood of forged addresses throttles itself
33
+ * instead of growing the map. Expired windows are swept at most once a second.
34
+ */
35
+ export declare class RateLimiter {
36
+ private readonly limit;
37
+ private readonly windowMs;
38
+ private readonly maxKeys;
39
+ private readonly hits;
40
+ private lastPrune;
41
+ constructor(limit: number, windowMs: number, maxKeys?: number);
42
+ /** `true` if this hit is allowed. */
43
+ hit(key: string, now: number): boolean;
44
+ /** Distinct buckets held now, for tests and diagnostics. */
45
+ get size(): number;
46
+ private prune;
47
+ }
48
+ /**
49
+ * The barriers in their order. Every submission past the honeypot spends the
50
+ * limiter — a script that omits the render stamp must not get unlimited
51
+ * `too-fast` rows — and the verdict ranks the server's own evidence first:
52
+ * honeypot, then rate-limited, then the client's stamp (too-fast).
53
+ */
54
+ export declare function screen(input: {
55
+ honeypot: string | null;
56
+ renderedAt: string | null;
57
+ clientKey: string;
58
+ now: number;
59
+ limiter: RateLimiter;
60
+ }): Screening;