@evinvest/kitstart 0.19.1 → 0.20.1

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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.19.1"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.20.1"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -61,6 +61,7 @@ landing = ev.lib.mkLanding {
61
61
  checkKitstartVersion ? true,
62
62
  e2eConfig ? "tests/e2e",
63
63
  packageSourceOverrides ? { }, # lock key → `file:`/vendored tarball
64
+ maxOldSpaceMiB ? 72, # V8 old-space cap; raise with cacheMaxMemorySize (16 MB)
64
65
  };
65
66
  # → { site, bundleBudget, packages.{default,site,container}, containers,
66
67
  # checks.{site,bundle-budget}, apps.{default,help,dev,test,accept-test,size,container-smoke},
@@ -937,6 +938,13 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
937
938
  suspect, for the body's `suspect` property. Off by default, and to stay off
938
939
  until the panel's `lead.created` accepts the property: it refuses an
939
940
  unknown one, and the outbox would park the lead. Off, nothing changes.
941
+ - **The visitor's language: `panelLocale`, off.** With
942
+ `leadWebhook(…, { panelLocale: true })` `ctx.panelLocale` is `fr` or `en`
943
+ (the site locale's language, `panelLocaleOf`; a locale the panel does not
944
+ take leaves it absent), and `panelLocaleProperties(ctx.panelLocale)` writes
945
+ it as `lead.created`'s `properties.locale`. Off by default, and to stay off
946
+ until the panel accepts the property — it refuses an unknown one, and the
947
+ outbox would park the lead. Off, the context and the body are as before.
940
948
  - **The sale: `panelFlow`, off.** With `leadWebhook(…, { panelFlow: true })`
941
949
  `ctx.flow` says how the need was sold (`panelFlowOf(lead)`), and
942
950
  `panelFlowProperties(ctx.flow)` writes it as `lead.created`'s properties:
@@ -1,28 +1,9 @@
1
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;
2
+ export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_OVERFLOW_KEY, RENDERED_AT_FIELD } from "../generated/contract.js";
20
3
  export type Screening = "ok" | SpamVerdict;
21
4
  export declare function checkTiming(renderedAt: string | null, now: number): Screening;
22
5
  /** How many distinct keys a limiter tracks before new ones share one bucket. */
23
6
  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
7
  /**
27
8
  * A fixed-window counter per client address, in memory. Per process by design:
28
9
  * it is a speed bump for one bot hammering one pod, not an accounting system,
@@ -1,21 +1,11 @@
1
+ import { MIN_FILL_MS, RATE_LIMIT_OVERFLOW_KEY } from "../generated/contract.js";
2
+ export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_OVERFLOW_KEY, RENDERED_AT_FIELD } from "../generated/contract.js";
1
3
  /**
2
4
  * Three cheap barriers, none of which needs the visitor to run a script — the
3
5
  * form has to work before any JavaScript arrives, so a challenge widget is not
4
6
  * an option. A rejected submission is answered exactly like an accepted one,
5
7
  * so a bot learns nothing from the response.
6
8
  */
7
- /**
8
- * The field a human never sees and a form-filling bot fills. A neutral name:
9
- * a browser's autofill knows `website`, `url` or `company` and would fill the
10
- * trap for a real person.
11
- */
12
- export const HONEYPOT_FIELD = "hp_ref";
13
- /** The trap's name on pages cached before it was renamed; still read. */
14
- export const LEGACY_HONEYPOT_FIELDS = ["website"];
15
- /** When the form was rendered, in ms since the epoch. */
16
- export const RENDERED_AT_FIELD = "t";
17
- /** Faster than this from render to submit is a script, not a person. */
18
- export const MIN_FILL_MS = 3_000;
19
9
  /** A render time this far ahead of the clock was forged. */
20
10
  const MAX_SKEW_MS = 60_000;
21
11
  export function checkTiming(renderedAt, now) {
@@ -26,8 +16,6 @@ export function checkTiming(renderedAt, now) {
26
16
  }
27
17
  /** How many distinct keys a limiter tracks before new ones share one bucket. */
28
18
  export const RATE_LIMIT_MAX_KEYS = 10_000;
29
- /** The shared bucket keys past the cap count against. */
30
- export const RATE_LIMIT_OVERFLOW_KEY = "\u0000overflow";
31
19
  const PRUNE_EVERY_MS = 1_000;
32
20
  /**
33
21
  * A fixed-window counter per client address, in memory. Per process by design:
@@ -23,6 +23,8 @@ export interface PlaceLive<L extends string> {
23
23
  telegram?: string;
24
24
  /** The panel's kill switches for the messengers; a key left out is on. */
25
25
  messengers?: MessengerSwitches;
26
+ /** Where the place asks for a Google review (`REVIEW_HOSTS`, `https:` only); one that does not validate is dropped. */
27
+ reviewUrl?: string;
26
28
  }
27
29
  /**
28
30
  * Drops what does not validate instead of failing the page on one bad field.
@@ -1,6 +1,8 @@
1
1
  import { BookingConfigError, parseBookingConfig } from "../booking/validate.js";
2
2
  import { TELEGRAM_BOT } from "../messenger.js";
3
3
  import { parseInstant } from "./rating.js";
4
+ /** The hosts Google's own review links live on: `g.page/r/…` and `search.google.com/local/writereview`. */
5
+ const REVIEW_HOSTS = ["g.page", "search.google.com"];
4
6
  const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
5
7
  const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
6
8
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -47,6 +49,13 @@ function httpsUrl(v) {
47
49
  return undefined;
48
50
  }
49
51
  }
52
+ function reviewUrl(v) {
53
+ const s = httpsUrl(v);
54
+ if (!s)
55
+ return undefined;
56
+ // `httpsUrl` parsed it once already; a host check on the string would be fooled by userinfo (`https://g.page@evil.example`).
57
+ return REVIEW_HOSTS.includes(new URL(s).hostname) ? s : undefined;
58
+ }
50
59
  function landmark(v, locales) {
51
60
  if (!isObject(v))
52
61
  return undefined;
@@ -139,6 +148,9 @@ export function parsePlaceLive(body, locales, rules = {}) {
139
148
  const m = messengers(body.messengers);
140
149
  if (m)
141
150
  live.messengers = m;
151
+ const review = reviewUrl(body.reviewUrl);
152
+ if (review)
153
+ live.reviewUrl = review;
142
154
  return live;
143
155
  }
144
156
  /**
@@ -168,5 +180,6 @@ export function mergeLive(baked, live) {
168
180
  rating: live.rating ?? baked.rating,
169
181
  ...(booking ? { booking } : {}),
170
182
  ...(switches ? { messengers: switches } : {}),
183
+ ...(live.reviewUrl ? { reviewUrl: live.reviewUrl } : {}),
171
184
  };
172
185
  }
@@ -1,6 +1,5 @@
1
1
  import type { Place, Rating } from "./types.js";
2
- /** Google Business Profile API policy: no cached copy older than this. */
3
- export declare const RATING_MAX_AGE_DAYS = 30;
2
+ export { RATING_MAX_AGE_DAYS } from "../../generated/contract.js";
4
3
  /**
5
4
  * An instant from the live source: RFC 3339 with an offset or `Z`, or a bare
6
5
  * date (midnight UTC). A date-time without an offset is refused — `Date.parse`
@@ -1,5 +1,5 @@
1
- /** Google Business Profile API policy: no cached copy older than this. */
2
- export const RATING_MAX_AGE_DAYS = 30;
1
+ import { RATING_MAX_AGE_DAYS } from "../../generated/contract.js";
2
+ export { RATING_MAX_AGE_DAYS } from "../../generated/contract.js";
3
3
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
4
4
  const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
5
5
  /**
@@ -96,6 +96,8 @@ export interface Place<L extends string> {
96
96
  * settings); absent → `DEFAULT_BOOKING`, a call (`bookingOf`).
97
97
  */
98
98
  booking?: BookingConfig;
99
+ /** Where the place asks for a Google review (`PlaceLive.reviewUrl`); live only, absent until the panel sends it. */
100
+ reviewUrl?: string;
99
101
  }
100
102
  /** Which messengers the panel lets a place offer; a key left out is on. */
101
103
  export interface MessengerSwitches {
@@ -31,14 +31,9 @@ import type { Site } from "./site.js";
31
31
  * must not be cached as permanent. Only page paths enter it — `/quote`,
32
32
  * `/sitemap.xml`, `/og` and the brand's files pass straight through.
33
33
  */
34
- /** Not indexable, not in the sitemap, but negotiated like any other page. */
35
- export declare const THANKS = "/thanks";
36
- export declare const LANG_COOKIE = "lang";
37
- export declare const LANG_COOKIE_MAX_AGE = 31536000;
38
- /** The prefix a `[location]` route param carries in host link mode. */
39
- export declare const HOST_MARK = "_";
34
+ export { GONE, GONE_HEADER, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, THANKS } from "../generated/contract.js";
40
35
  /**
41
- * How a dead path reaches a 404 a visitor without JavaScript can read.
36
+ * The path no route matches, in a language.
42
37
  *
43
38
  * Next 16 cannot put a `notFound()` boundary into the HTML: that response is
44
39
  * an empty `<html id="__next_error__">` the client fills in after hydration.
@@ -48,29 +43,18 @@ export declare const HOST_MARK = "_";
48
43
  * says which language and place the 404 speaks for in {@link GONE_HEADER}.
49
44
  * Reserved: no place may take `404` as its slug.
50
45
  */
51
- export declare const GONE = "404";
52
- /** The path no route matches, in a language. */
53
46
  export declare function gonePath(locale: string): string;
54
47
  /**
55
- * Set only by the proxy, on a `gone` rewrite, and stripped from every other
56
- * request; read only by the global not-found page, a route of its own — a
57
- * page that read it would render per request, and none does.
48
+ * `fr` or `fr/_royat`: the 404's language and, if it has one, its place. Read
49
+ * only by the global not-found page, a route of its own — a page that read it
50
+ * would render per request, and none does.
58
51
  */
59
- export declare const GONE_HEADER = "x-landing-not-found";
60
- /** `fr` or `fr/_royat`: the 404's language and, if it has one, its place. */
61
52
  export declare function goneHeader(locale: string, location: string | null): string;
62
53
  /** Inverse of {@link goneHeader}; anything absent or malformed reads as nothing. */
63
54
  export declare function parseGoneHeader(value: string | null): {
64
55
  locale?: string;
65
56
  location?: string;
66
57
  };
67
- /**
68
- * The routes every landing has outside `[locale]`, which pass untouched: the
69
- * form target, its booking request (`bookingRoute`) and its price
70
- * confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
71
- * `/_next/…` and the site's `publicFiles` is `gone`.
72
- */
73
- export declare const NON_PAGE_ROUTES: readonly string[];
74
58
  /** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
75
59
  export declare const PASS_PATHS: readonly string[];
76
60
  /** The `[location]` param for a slug in a mode: `_royat` on its host, `royat` through the apex. */
@@ -1,3 +1,4 @@
1
+ import { GONE, HOST_MARK, NON_PAGE_ROUTES, THANKS } from "../generated/contract.js";
1
2
  /**
2
3
  * Which place and which language a request gets, decided before any route
3
4
  * renders. Pure, so it is tested without a server — and mirrored by the Rust
@@ -29,14 +30,9 @@
29
30
  * must not be cached as permanent. Only page paths enter it — `/quote`,
30
31
  * `/sitemap.xml`, `/og` and the brand's files pass straight through.
31
32
  */
32
- /** Not indexable, not in the sitemap, but negotiated like any other page. */
33
- export const THANKS = "/thanks";
34
- export const LANG_COOKIE = "lang";
35
- export const LANG_COOKIE_MAX_AGE = 31_536_000;
36
- /** The prefix a `[location]` route param carries in host link mode. */
37
- export const HOST_MARK = "_";
33
+ export { GONE, GONE_HEADER, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, THANKS } from "../generated/contract.js";
38
34
  /**
39
- * How a dead path reaches a 404 a visitor without JavaScript can read.
35
+ * The path no route matches, in a language.
40
36
  *
41
37
  * Next 16 cannot put a `notFound()` boundary into the HTML: that response is
42
38
  * an empty `<html id="__next_error__">` the client fills in after hydration.
@@ -46,18 +42,14 @@ export const HOST_MARK = "_";
46
42
  * says which language and place the 404 speaks for in {@link GONE_HEADER}.
47
43
  * Reserved: no place may take `404` as its slug.
48
44
  */
49
- export const GONE = "404";
50
- /** The path no route matches, in a language. */
51
45
  export function gonePath(locale) {
52
46
  return `/${locale}/${GONE}/${GONE}`;
53
47
  }
54
48
  /**
55
- * Set only by the proxy, on a `gone` rewrite, and stripped from every other
56
- * request; read only by the global not-found page, a route of its own — a
57
- * page that read it would render per request, and none does.
49
+ * `fr` or `fr/_royat`: the 404's language and, if it has one, its place. Read
50
+ * only by the global not-found page, a route of its own — a page that read it
51
+ * would render per request, and none does.
58
52
  */
59
- export const GONE_HEADER = "x-landing-not-found";
60
- /** `fr` or `fr/_royat`: the 404's language and, if it has one, its place. */
61
53
  export function goneHeader(locale, location) {
62
54
  return location ? `${locale}/${location}` : locale;
63
55
  }
@@ -68,13 +60,6 @@ export function parseGoneHeader(value) {
68
60
  return {};
69
61
  return { ...(locale ? { locale } : {}), ...(location ? { location } : {}) };
70
62
  }
71
- /**
72
- * The routes every landing has outside `[locale]`, which pass untouched: the
73
- * form target, its booking request (`bookingRoute`) and its price
74
- * confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
75
- * `/_next/…` and the site's `publicFiles` is `gone`.
76
- */
77
- export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
78
63
  /** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
79
64
  export const PASS_PATHS = NON_PAGE_ROUTES;
80
65
  function isInfrastructure(pathname, publicFiles) {
@@ -1,5 +1,6 @@
1
1
  import type { Place } from "../place/types.js";
2
2
  import type { Site } from "../site.js";
3
+ export { AI_CRAWLERS } from "../../generated/contract.js";
3
4
  /**
4
5
  * One sitemap per host: a sitemap may only list URLs on its own host, and a
5
6
  * place's canonical host is its subdomain (the apex on a `single` site). The
@@ -36,8 +37,6 @@ export interface RobotsFile {
36
37
  * places a crawler drops).
37
38
  */
38
39
  export declare function sitemapFor<L extends string, P extends string>(site: Site<L, P>, host: string, places: readonly Place<L>[]): SitemapEntry[];
39
- /** Crawlers named explicitly: several treat a bare wildcard as ambiguous. */
40
- export declare const AI_CRAWLERS: string[];
41
40
  /**
42
41
  * Allow everything, and point at the host's own sitemap. Unpublished places
43
42
  * are held back by `noindex`, not here, so a crawler can still read the
@@ -1,6 +1,8 @@
1
+ import { AI_CRAWLERS } from "../../generated/contract.js";
1
2
  import { isPublished } from "../place/publication.js";
2
3
  import { placeOrigin, siteOrigin } from "../place/view.js";
3
4
  import { createRouting } from "../routing.js";
5
+ export { AI_CRAWLERS } from "../../generated/contract.js";
4
6
  function cluster(site, origin, suffix, priority) {
5
7
  const languages = site.i18n.languageAlternates(suffix || "/", origin);
6
8
  return site.i18n.locales.map(locale => ({
@@ -28,8 +30,6 @@ export function sitemapFor(site, host, places) {
28
30
  return [];
29
31
  return site.pageKeys.flatMap(page => cluster(site, origin, site.pages[page], page === "home" ? 1 : 0.8));
30
32
  }
31
- /** Crawlers named explicitly: several treat a bare wildcard as ambiguous. */
32
- export const AI_CRAWLERS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
33
33
  /**
34
34
  * Allow everything, and point at the host's own sitemap. Unpublished places
35
35
  * are held back by `noindex`, not here, so a crawler can still read the
@@ -0,0 +1,15 @@
1
+ export declare const HONEYPOT_FIELD = "hp_ref";
2
+ export declare const LEGACY_HONEYPOT_FIELDS: readonly ["website"];
3
+ export declare const RENDERED_AT_FIELD = "t";
4
+ export declare const MIN_FILL_MS = 3000;
5
+ export declare const RATE_LIMIT_OVERFLOW_KEY = "\0overflow";
6
+ export declare const THANKS = "/thanks";
7
+ export declare const LANG_COOKIE = "lang";
8
+ export declare const LANG_COOKIE_MAX_AGE = 31536000;
9
+ export declare const HOST_MARK = "_";
10
+ export declare const GONE = "404";
11
+ export declare const GONE_HEADER = "x-landing-not-found";
12
+ export declare const NON_PAGE_ROUTES: readonly ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
13
+ export declare const OG_IMAGE_SIZE: readonly [1200, 630];
14
+ export declare const RATING_MAX_AGE_DAYS = 30;
15
+ export declare const AI_CRAWLERS: readonly ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
@@ -0,0 +1,16 @@
1
+ // AUTO-GENERATED by `nix run .#gen` — do not edit.
2
+ export const HONEYPOT_FIELD = "hp_ref";
3
+ export const LEGACY_HONEYPOT_FIELDS = ["website"];
4
+ export const RENDERED_AT_FIELD = "t";
5
+ export const MIN_FILL_MS = 3000;
6
+ export const RATE_LIMIT_OVERFLOW_KEY = "\0overflow";
7
+ export const THANKS = "/thanks";
8
+ export const LANG_COOKIE = "lang";
9
+ export const LANG_COOKIE_MAX_AGE = 31536000;
10
+ export const HOST_MARK = "_";
11
+ export const GONE = "404";
12
+ export const GONE_HEADER = "x-landing-not-found";
13
+ export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
14
+ export const OG_IMAGE_SIZE = [1200, 630];
15
+ export const RATING_MAX_AGE_DAYS = 30;
16
+ export const AI_CRAWLERS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
@@ -25,6 +25,12 @@ export interface LandingOptions {
25
25
  * - `images.unoptimized` + the sharp exclusion — the optimiser would add a
26
26
  * native dependency the Nix image cannot load, for sources already sized;
27
27
  * - `isrFlushToDisk: false` — the server runs from the read-only Nix store, so
28
- * the cache stays in memory, per pod.
28
+ * the cache stays in memory, per pod;
29
+ * - `cacheMaxMemorySize` — that in-memory LRU defaults to 50 MB, and a brand's
30
+ * paths (locales × points × A/B buckets) fill it: aquafix's ~550 pushed the
31
+ * retained heap from 27 to 90 MB and the pod into its 128Mi OOM kill
32
+ * (devops#74). At 16 MB it plateaus at 55 MB. A miss re-renders and refetches
33
+ * the live place, so a brand raising it must raise mkLanding's
34
+ * `maxOldSpaceMiB` (72) too, or V8 dies with a heap OOM instead.
29
35
  */
30
36
  export declare function withLanding(config: NextConfig, options: LandingOptions): NextConfig;
@@ -13,13 +13,20 @@ export { buildEnv, readCard, readMarkPath, readOgPalette, readStringTables } fro
13
13
  * - `images.unoptimized` + the sharp exclusion — the optimiser would add a
14
14
  * native dependency the Nix image cannot load, for sources already sized;
15
15
  * - `isrFlushToDisk: false` — the server runs from the read-only Nix store, so
16
- * the cache stays in memory, per pod.
16
+ * the cache stays in memory, per pod;
17
+ * - `cacheMaxMemorySize` — that in-memory LRU defaults to 50 MB, and a brand's
18
+ * paths (locales × points × A/B buckets) fill it: aquafix's ~550 pushed the
19
+ * retained heap from 27 to 90 MB and the pod into its 128Mi OOM kill
20
+ * (devops#74). At 16 MB it plateaus at 55 MB. A miss re-renders and refetches
21
+ * the live place, so a brand raising it must raise mkLanding's
22
+ * `maxOldSpaceMiB` (72) too, or V8 dies with a heap OOM instead.
17
23
  */
18
24
  export function withLanding(config, options) {
19
25
  return {
20
26
  poweredByHeader: false,
21
27
  output: "standalone",
22
28
  expireTime: 86_400,
29
+ cacheMaxMemorySize: 16 * 1024 * 1024,
23
30
  ...config,
24
31
  env: { ...buildEnv(options.root), ...config.env },
25
32
  images: { unoptimized: true, ...config.images },
@@ -1,8 +1,8 @@
1
1
  import { isPublished } from "../core/place/publication.js";
2
+ import { OG_IMAGE_SIZE } from "../generated/contract.js";
2
3
  import { placeOrigin, siteOrigin } from "../core/place/view.js";
3
4
  import { ogImageUrl } from "../core/seo/sitemap.js";
4
5
  import { ogLocaleOf } from "../core/site.js";
5
- const OG_SIZE = { width: 1200, height: 630 };
6
6
  const PRE_LAUNCH = { index: false, follow: false };
7
7
  function others(site, locale) {
8
8
  return site.i18n.locales.filter(l => l !== locale).map(l => ogLocaleOf(site, l));
@@ -22,7 +22,7 @@ function head(site, page) {
22
22
  ...(page.canonical !== null ? { url: page.canonical } : {}),
23
23
  locale: ogLocaleOf(site, page.locale),
24
24
  alternateLocale: others(site, page.locale),
25
- images: [{ url: page.image, ...OG_SIZE }],
25
+ images: [{ url: page.image, width: OG_IMAGE_SIZE[0], height: OG_IMAGE_SIZE[1] }],
26
26
  },
27
27
  twitter: { card: "summary_large_image" },
28
28
  };
package/dist/next/og.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { ImageResponse } from "next/og.js";
2
2
  import { memoByKey } from "../core/memo.js";
3
+ import { OG_IMAGE_SIZE } from "../generated/contract.js";
3
4
  import { bakedPlace } from "../core/site.js";
4
5
  export function ogRoute(site, options) {
5
- const width = options.width ?? 1200;
6
- const height = options.height ?? 630;
6
+ const width = options.width ?? OG_IMAGE_SIZE[0];
7
+ const height = options.height ?? OG_IMAGE_SIZE[1];
7
8
  // Read once per process, like the cards; a failed read is retried next time.
8
9
  let fonts;
9
10
  const loadFonts = () => {
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
16
16
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
17
17
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
18
18
  export { checkLeadStore } from "./boot.js";
19
- export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
19
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, panelLocaleOf, panelLocaleProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, type PanelLocale, } from "./lead-webhook.js";
20
20
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
21
21
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
16
16
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
17
17
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
18
18
  export { checkLeadStore } from "./boot.js";
19
- export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, } from "./lead-webhook.js";
19
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, panelLocaleOf, panelLocaleProperties, } from "./lead-webhook.js";
20
20
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
21
21
  export { signatureHeaders, signWebhook } from "./webhook-signature.js";
@@ -55,7 +55,22 @@ export interface LeadWebhookContext {
55
55
  * on a lead that has one; otherwise absent, and the body must not carry it.
56
56
  */
57
57
  messageRef?: string;
58
+ /**
59
+ * The language the visitor read the page in, as the panel's
60
+ * `properties.locale` takes it (`panelLocaleOf`): `fr` or `en` only, and
61
+ * only under `panelLocale`; otherwise absent, and the body must not carry
62
+ * the property. `panelLocaleProperties` writes it.
63
+ */
64
+ panelLocale?: PanelLocale;
58
65
  }
66
+ /** The languages the Service-Arb panel's `lead.created` takes in `properties.locale`. */
67
+ export type PanelLocale = "fr" | "en";
68
+ /** The panel's locale for a site locale (`fr`, `en-GB` → `en`), or nothing for one it does not take. */
69
+ export declare function panelLocaleOf(locale: string): PanelLocale | undefined;
70
+ /** `ctx.panelLocale` as `lead.created`'s properties, to spread into the body's `properties`; `{}` without one. */
71
+ export declare function panelLocaleProperties(locale: PanelLocale | undefined): {
72
+ locale?: PanelLocale;
73
+ };
59
74
  /** A lead's sale as the panel's `lead.created` may carry it. */
60
75
  export interface PanelFlow {
61
76
  flow: LeadFlow;
@@ -146,6 +161,8 @@ export interface LeadWebhook {
146
161
  readonly panelBooking?: boolean;
147
162
  /** Whether `ctx.channel` says `whatsapp` / `telegram` and `ctx.messageRef` is filled in (`LeadWebhookOptions.panelMessenger`). */
148
163
  readonly panelMessenger?: boolean;
164
+ /** Whether `ctx.panelLocale` is filled in (`LeadWebhookOptions.panelLocale`). */
165
+ readonly panelLocale?: boolean;
149
166
  /**
150
167
  * Queues `booking.requested@1` behind the lead's `lead.created`, through
151
168
  * the same outbox: not sent until that row is delivered, and a `409` / `425`
@@ -198,6 +215,13 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
198
215
  * messenger lead goes as a `form`, without its reference.
199
216
  */
200
217
  panelMessenger?: boolean;
218
+ /**
219
+ * The switch for `properties.locale` (`ctx.panelLocale`: `fr` or `en`). Off
220
+ * (the default) until the panel's `lead.created` accepts the property — it
221
+ * refuses an unknown one, and the outbox would park the lead. Off, the
222
+ * context carries nothing and the body is as it was.
223
+ */
224
+ panelLocale?: boolean;
201
225
  /** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
202
226
  buildBookingBody?: BuildBookingBody | undefined;
203
227
  }
@@ -3,6 +3,15 @@ import { randomUUID } from "node:crypto";
3
3
  import { channelOf, suspectOf } from "../core/lead.js";
4
4
  import { experimentsDeclaredBody } from "./experiments-declared.js";
5
5
  import { openWebhookOutbox } from "./webhook-outbox.js";
6
+ /** The panel's locale for a site locale (`fr`, `en-GB` → `en`), or nothing for one it does not take. */
7
+ export function panelLocaleOf(locale) {
8
+ const primary = locale.toLowerCase().split(/[-_]/, 1)[0];
9
+ return primary === "fr" || primary === "en" ? primary : undefined;
10
+ }
11
+ /** `ctx.panelLocale` as `lead.created`'s properties, to spread into the body's `properties`; `{}` without one. */
12
+ export function panelLocaleProperties(locale) {
13
+ return locale ? { locale } : {};
14
+ }
6
15
  /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
7
16
  export function panelFlowOf(lead) {
8
17
  if (!lead.flow)
@@ -40,7 +49,7 @@ export function panelChannel(channel, messenger = false) {
40
49
  * so a broken setting fails startup rather than a lead.
41
50
  */
42
51
  export function leadWebhook(site, env, options) {
43
- const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, ...outboxOptions } = options;
52
+ const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, panelLocale = false, ...outboxOptions } = options;
44
53
  if (!env.leadWebhook || !buildBody)
45
54
  return null;
46
55
  if (env.leadsDb.kind !== "sqlite")
@@ -54,6 +63,7 @@ export function leadWebhook(site, env, options) {
54
63
  panelFlow,
55
64
  panelBooking,
56
65
  panelMessenger,
66
+ panelLocale,
57
67
  requestBooking(request) {
58
68
  if (!panelBooking || !buildBookingBody)
59
69
  return { kind: "off" };
@@ -77,6 +87,7 @@ export function leadWebhook(site, env, options) {
77
87
  enqueue(lead, id, meta) {
78
88
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
79
89
  const flow = panelFlow ? panelFlowOf(lead) : undefined;
90
+ const locale = panelLocale ? panelLocaleOf(meta.locale) : undefined;
80
91
  const ctx = {
81
92
  leadId: id,
82
93
  brandId: site.brand.id,
@@ -87,6 +98,7 @@ export function leadWebhook(site, env, options) {
87
98
  ...(flow ? { flow } : {}),
88
99
  channel: panelChannel(channelOf(lead), panelMessenger),
89
100
  ...(panelMessenger && lead.messageRef ? { messageRef: lead.messageRef } : {}),
101
+ ...(locale ? { panelLocale: locale } : {}),
90
102
  };
91
103
  const body = JSON.stringify(buildBody(lead, ctx));
92
104
  if (typeof body !== "string")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.19.1";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.20.1";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.4.0",
22
- "@evinvest/i18n": "^0.10.0",
23
- "@evinvest/kitstart": "^0.19.0",
22
+ "@evinvest/i18n": "^0.11.0",
23
+ "@evinvest/kitstart": "^0.20.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.29.0",
26
26
  "next": "16.4.0",