@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.
- package/README.md +245 -0
- package/dist/cli/kitstart-size.d.ts +2 -0
- package/dist/cli/kitstart-size.js +11 -0
- package/dist/cli/size.d.ts +31 -0
- package/dist/cli/size.js +99 -0
- package/dist/core/accept.d.ts +49 -0
- package/dist/core/accept.js +78 -0
- package/dist/core/analytics.d.ts +43 -0
- package/dist/core/analytics.js +42 -0
- package/dist/core/antispam.d.ts +60 -0
- package/dist/core/antispam.js +90 -0
- package/dist/core/content.d.ts +82 -0
- package/dist/core/content.js +12 -0
- package/dist/core/lead.d.ts +88 -0
- package/dist/core/lead.js +42 -0
- package/dist/core/memo.d.ts +8 -0
- package/dist/core/memo.js +19 -0
- package/dist/core/place/index.d.ts +5 -0
- package/dist/core/place/index.js +5 -0
- package/dist/core/place/live.d.ts +30 -0
- package/dist/core/place/live.js +132 -0
- package/dist/core/place/publication.d.ts +29 -0
- package/dist/core/place/publication.js +42 -0
- package/dist/core/place/rating.d.ts +16 -0
- package/dist/core/place/rating.js +33 -0
- package/dist/core/place/types.d.ts +91 -0
- package/dist/core/place/types.js +27 -0
- package/dist/core/place/view.d.ts +56 -0
- package/dist/core/place/view.js +43 -0
- package/dist/core/routing.d.ts +121 -0
- package/dist/core/routing.js +198 -0
- package/dist/core/seo/ld.d.ts +58 -0
- package/dist/core/seo/ld.js +161 -0
- package/dist/core/seo/sitemap.d.ts +52 -0
- package/dist/core/seo/sitemap.js +60 -0
- package/dist/core/site.d.ts +118 -0
- package/dist/core/site.js +84 -0
- package/dist/core/status.d.ts +47 -0
- package/dist/core/status.js +62 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/next/config/build-env.d.ts +32 -0
- package/dist/next/config/build-env.js +101 -0
- package/dist/next/config/index.d.ts +30 -0
- package/dist/next/config/index.js +38 -0
- package/dist/next/index.d.ts +12 -0
- package/dist/next/index.js +12 -0
- package/dist/next/load.d.ts +24 -0
- package/dist/next/load.js +36 -0
- package/dist/next/metadata.d.ts +31 -0
- package/dist/next/metadata.js +66 -0
- package/dist/next/og-palette.d.ts +14 -0
- package/dist/next/og-palette.js +14 -0
- package/dist/next/og.d.ts +31 -0
- package/dist/next/og.js +42 -0
- package/dist/next/quote-route.d.ts +36 -0
- package/dist/next/quote-route.js +124 -0
- package/dist/next/routes.d.ts +15 -0
- package/dist/next/routes.js +50 -0
- package/dist/proxy/index.d.ts +31 -0
- package/dist/proxy/index.js +108 -0
- package/dist/react/AnalyticsBoundary.d.ts +14 -0
- package/dist/react/AnalyticsBoundary.js +55 -0
- package/dist/react/AreaChips.d.ts +8 -0
- package/dist/react/AreaChips.js +11 -0
- package/dist/react/CallBar.d.ts +29 -0
- package/dist/react/CallBar.js +7 -0
- package/dist/react/Coverage.d.ts +24 -0
- package/dist/react/Coverage.js +10 -0
- package/dist/react/Faq.d.ts +17 -0
- package/dist/react/Faq.js +5 -0
- package/dist/react/LangSwitch.d.ts +24 -0
- package/dist/react/LangSwitch.js +17 -0
- package/dist/react/MapFacade.d.ts +18 -0
- package/dist/react/MapFacade.js +8 -0
- package/dist/react/PlaceDirectory.d.ts +25 -0
- package/dist/react/PlaceDirectory.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +34 -0
- package/dist/react/QuoteFormShell.js +10 -0
- package/dist/react/StatusScreen.d.ts +36 -0
- package/dist/react/StatusScreen.js +22 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.js +29 -0
- package/dist/react/parts.d.ts +7 -0
- package/dist/react/parts.js +1 -0
- package/dist/server/boot.d.ts +11 -0
- package/dist/server/boot.js +20 -0
- package/dist/server/client-key.d.ts +22 -0
- package/dist/server/client-key.js +20 -0
- package/dist/server/env.d.ts +44 -0
- package/dist/server/env.js +73 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/lead-store-postgres.d.ts +14 -0
- package/dist/server/lead-store-postgres.js +23 -0
- package/dist/server/lead-store-sqlite.d.ts +21 -0
- package/dist/server/lead-store-sqlite.js +158 -0
- package/dist/server/lead-store.d.ts +20 -0
- package/dist/server/lead-store.js +43 -0
- package/dist/server/notify.d.ts +37 -0
- package/dist/server/notify.js +79 -0
- package/dist/server/place-source.d.ts +48 -0
- package/dist/server/place-source.js +107 -0
- package/dist/server/smtp.d.ts +38 -0
- package/dist/server/smtp.js +197 -0
- package/dist/testing/e2e.d.ts +52 -0
- package/dist/testing/e2e.js +57 -0
- package/dist/testing/fixtures.d.ts +11 -0
- package/dist/testing/fixtures.js +53 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/landing-contract.d.ts +33 -0
- package/dist/testing/landing-contract.js +95 -0
- package/dist/testing/lead-store-contract.d.ts +13 -0
- package/dist/testing/lead-store-contract.js +55 -0
- package/dist/testing/served-files.d.ts +7 -0
- package/dist/testing/served-files.js +41 -0
- package/package.json +129 -0
- package/template/README.md +55 -0
- package/template/app/[locale]/[location]/layout.tsx +29 -0
- package/template/app/[locale]/[location]/page.tsx +18 -0
- package/template/app/[locale]/[location]/prices/page.tsx +26 -0
- package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
- package/template/app/[locale]/error.tsx +14 -0
- package/template/app/[locale]/layout.tsx +28 -0
- package/template/app/[locale]/not-found.tsx +18 -0
- package/template/app/[locale]/page.tsx +40 -0
- package/template/app/[locale]/thanks/page.tsx +21 -0
- package/template/app/global-not-found.tsx +28 -0
- package/template/app/globals.css +18 -0
- package/template/app/health/route.ts +6 -0
- package/template/app/og/route.tsx +27 -0
- package/template/app/quote/route.ts +17 -0
- package/template/app/robots.ts +6 -0
- package/template/app/sitemap.ts +8 -0
- package/template/assets/brand.toml +68 -0
- package/template/assets/card.toml +8 -0
- package/template/assets/fonts/Inter-OFL.txt +93 -0
- package/template/assets/fonts/Inter-Regular.ttf +0 -0
- package/template/assets/fonts/Inter-Regular.woff2 +0 -0
- package/template/assets/mark.svg +1 -0
- package/template/deploy/config.nix +24 -0
- package/template/eslint.config.mjs +28 -0
- package/template/flake.nix +46 -0
- package/template/instrumentation.ts +13 -0
- package/template/next.config.ts +5 -0
- package/template/package.json +44 -0
- package/template/postcss.config.mjs +3 -0
- package/template/proxy.ts +11 -0
- package/template/src/entities/content/index.ts +12 -0
- package/template/src/entities/content/model/en.ts +47 -0
- package/template/src/entities/content/model/fr.ts +47 -0
- package/template/src/entities/content/model/types.ts +37 -0
- package/template/src/entities/place/api/load.ts +12 -0
- package/template/src/entities/place/index.ts +3 -0
- package/template/src/entities/place/server.ts +1 -0
- package/template/src/shared/config/env.ts +17 -0
- package/template/src/shared/config/i18n.ts +15 -0
- package/template/src/shared/config/lead.ts +15 -0
- package/template/src/shared/config/places.ts +20 -0
- package/template/src/shared/config/public.ts +11 -0
- package/template/src/shared/config/site.ts +39 -0
- package/template/src/shared/ui/fonts.ts +15 -0
- package/template/src/views/home/index.ts +1 -0
- package/template/src/views/home/ui/PlaceHome.tsx +76 -0
- package/template/src/views/not-found/index.ts +1 -0
- package/template/src/views/not-found/server.ts +1 -0
- package/template/src/views/not-found/ui/Gone.tsx +29 -0
- package/template/src/views/not-found/ui/NotFound.tsx +27 -0
- package/template/src/views/place/server.ts +13 -0
- package/template/src/views/server-error/index.ts +1 -0
- package/template/src/views/server-error/ui/ServerError.tsx +24 -0
- package/template/steiger.config.ts +26 -0
- package/template/tests/bundle_budget.txt +8 -0
- package/template/tests/contract.test.ts +14 -0
- package/template/tests/e2e/playwright.config.ts +26 -0
- package/template/tests/e2e/screenshot-section.css +5 -0
- package/template/tests/e2e/sections.spec.ts +12 -0
- package/template/tests/e2e/tsconfig.json +11 -0
- package/template/tests/empty.ts +1 -0
- package/template/tsconfig.json +23 -0
- package/template/vitest.config.ts +20 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The place model. Three words, one each:
|
|
3
|
+
*
|
|
4
|
+
* - **Place** — the aggregate: one point of the business, baked config merged
|
|
5
|
+
* with the live source;
|
|
6
|
+
* - **Presence** — how the place exists for a visitor: a storefront with an
|
|
7
|
+
* address, or a service area with none;
|
|
8
|
+
* - **ServiceArea** — where the van goes.
|
|
9
|
+
*
|
|
10
|
+
* A service-area business has no address *in the type*, so no builder can
|
|
11
|
+
* render one for it by accident — schema.org, the map and the footer all have
|
|
12
|
+
* to ask `presence.kind` first.
|
|
13
|
+
*/
|
|
14
|
+
export type DayOfWeek = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
|
|
15
|
+
export interface PostalAddress {
|
|
16
|
+
street: string;
|
|
17
|
+
postalCode: string;
|
|
18
|
+
locality: string;
|
|
19
|
+
/** Région administrative, as schema.org `addressRegion`. */
|
|
20
|
+
region: string;
|
|
21
|
+
/** ISO 3166-1 alpha-2. */
|
|
22
|
+
country: string;
|
|
23
|
+
}
|
|
24
|
+
export interface Geo {
|
|
25
|
+
lat: number;
|
|
26
|
+
lng: number;
|
|
27
|
+
}
|
|
28
|
+
export interface OpeningHours {
|
|
29
|
+
days: readonly DayOfWeek[];
|
|
30
|
+
/** `HH:MM`, local time. */
|
|
31
|
+
opens: string;
|
|
32
|
+
closes: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Google's rating for the place, mirrored from the Business Profile API. Never
|
|
36
|
+
* baked: it exists only when the live source supplied it, and the API's terms
|
|
37
|
+
* cap how long a copy may be shown, so `fetchedAt` travels with it.
|
|
38
|
+
*/
|
|
39
|
+
export interface Rating {
|
|
40
|
+
value: number;
|
|
41
|
+
count: number;
|
|
42
|
+
/** ISO 8601. */
|
|
43
|
+
fetchedAt: string;
|
|
44
|
+
}
|
|
45
|
+
/** Where the van goes: named communes, or a radius around a centre. */
|
|
46
|
+
export type ServiceArea = {
|
|
47
|
+
kind: "localities";
|
|
48
|
+
names: readonly string[];
|
|
49
|
+
} | {
|
|
50
|
+
kind: "radius";
|
|
51
|
+
center: Geo;
|
|
52
|
+
km: number;
|
|
53
|
+
};
|
|
54
|
+
export type Presence<L extends string> = {
|
|
55
|
+
kind: "storefront";
|
|
56
|
+
address: PostalAddress;
|
|
57
|
+
geo: Geo | null;
|
|
58
|
+
/** Absolute URL of the storefront or van photo taken at the place. */
|
|
59
|
+
storefrontPhoto: string | null;
|
|
60
|
+
/** How a visitor recognises the place — a local landmark, per language. */
|
|
61
|
+
landmark: Readonly<Record<L, string>> | null;
|
|
62
|
+
} | {
|
|
63
|
+
kind: "service-area";
|
|
64
|
+
};
|
|
65
|
+
export interface Place<L extends string> {
|
|
66
|
+
/** The subdomain, or the path segment through the apex. */
|
|
67
|
+
slug: string;
|
|
68
|
+
/** The Google Business Profile's own name — the schema.org `name`. */
|
|
69
|
+
gbpName: string;
|
|
70
|
+
/** The short place name the copy uses: "Royat", "Lyon 3e". */
|
|
71
|
+
name: Readonly<Record<L, string>>;
|
|
72
|
+
presence: Presence<L>;
|
|
73
|
+
serviceArea: readonly ServiceArea[] | null;
|
|
74
|
+
/** International numbers as printed; `null` → the brand's own. */
|
|
75
|
+
channels: {
|
|
76
|
+
phone: string | null;
|
|
77
|
+
whatsapp: string | null;
|
|
78
|
+
};
|
|
79
|
+
hours: readonly OpeningHours[] | null;
|
|
80
|
+
/** Live only. */
|
|
81
|
+
rating: Rating | null;
|
|
82
|
+
}
|
|
83
|
+
/** The storefront half of a place, or `null` for a service-area business. */
|
|
84
|
+
export declare function storefrontOf<L extends string>(place: Place<L>): Extract<Presence<L>, {
|
|
85
|
+
kind: "storefront";
|
|
86
|
+
}> | null;
|
|
87
|
+
/**
|
|
88
|
+
* The communes a place names. Until the zone is named, a storefront serves its
|
|
89
|
+
* own town; a radius names no commune.
|
|
90
|
+
*/
|
|
91
|
+
export declare function servedLocalities(place: Place<string>): string[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The place model. Three words, one each:
|
|
3
|
+
*
|
|
4
|
+
* - **Place** — the aggregate: one point of the business, baked config merged
|
|
5
|
+
* with the live source;
|
|
6
|
+
* - **Presence** — how the place exists for a visitor: a storefront with an
|
|
7
|
+
* address, or a service area with none;
|
|
8
|
+
* - **ServiceArea** — where the van goes.
|
|
9
|
+
*
|
|
10
|
+
* A service-area business has no address *in the type*, so no builder can
|
|
11
|
+
* render one for it by accident — schema.org, the map and the footer all have
|
|
12
|
+
* to ask `presence.kind` first.
|
|
13
|
+
*/
|
|
14
|
+
/** The storefront half of a place, or `null` for a service-area business. */
|
|
15
|
+
export function storefrontOf(place) {
|
|
16
|
+
return place.presence.kind === "storefront" ? place.presence : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The communes a place names. Until the zone is named, a storefront serves its
|
|
20
|
+
* own town; a radius names no commune.
|
|
21
|
+
*/
|
|
22
|
+
export function servedLocalities(place) {
|
|
23
|
+
if (place.serviceArea === null) {
|
|
24
|
+
return place.presence.kind === "storefront" ? [place.presence.address.locality] : [];
|
|
25
|
+
}
|
|
26
|
+
return place.serviceArea.flatMap(area => (area.kind === "localities" ? area.names : []));
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Place } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* How links are written on a place's pages. On its own host a page is
|
|
4
|
+
* `/fr/prices` (`host`); reached through the apex fallback it is
|
|
5
|
+
* `/fr/<slug>/prices` (`path`). The proxy says which one a request is by the
|
|
6
|
+
* route it rewrites to — see `placeParam`.
|
|
7
|
+
*/
|
|
8
|
+
export type LinkMode = "host" | "path";
|
|
9
|
+
/** The part of a site that decides where a place's canonical URLs live. */
|
|
10
|
+
export interface OriginFacts {
|
|
11
|
+
brand: {
|
|
12
|
+
domain: string | null;
|
|
13
|
+
};
|
|
14
|
+
topology: {
|
|
15
|
+
kind: "subdomains";
|
|
16
|
+
} | {
|
|
17
|
+
kind: "single";
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A place as one request sees it: the merged record, the language, and how its
|
|
22
|
+
* links are written on this host. Sections take this instead of eight props.
|
|
23
|
+
*/
|
|
24
|
+
export interface PlaceView<L extends string> {
|
|
25
|
+
place: Place<L>;
|
|
26
|
+
locale: L;
|
|
27
|
+
mode: LinkMode;
|
|
28
|
+
/** Root-relative link to one of this place's pages in `locale`. */
|
|
29
|
+
href: (suffix: string, locale?: L) => string;
|
|
30
|
+
/** Canonical URL — absolute once the site has a domain. */
|
|
31
|
+
url: (suffix: string, locale?: L) => string;
|
|
32
|
+
}
|
|
33
|
+
/** `https://<domain>`, or `null` for a site with no domain yet. */
|
|
34
|
+
export declare function siteOrigin(site: OriginFacts): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Where a place's pages canonically live: its subdomain in a `subdomains`
|
|
37
|
+
* site, the apex in a `single` one, whichever URL served the request.
|
|
38
|
+
*/
|
|
39
|
+
export declare function placeOrigin(site: OriginFacts, slug: string): string | null;
|
|
40
|
+
/** Where a place's pages link to, given how this request reached it. */
|
|
41
|
+
export interface LinkBase {
|
|
42
|
+
mode: LinkMode;
|
|
43
|
+
slug: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A root-relative href to one of the place's pages. `suffix` is a page suffix
|
|
47
|
+
* and may carry a fragment: `"#quote"`, `"/prices#faq"`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function placeHref(base: LinkBase, locale: string, suffix: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* The canonical URL of a place's page. A site with no domain has no absolute
|
|
52
|
+
* URL to give; the root-relative path it serves is the honest answer, and
|
|
53
|
+
* every page of such a site is `noindex` anyway.
|
|
54
|
+
*/
|
|
55
|
+
export declare function placeUrl(site: OriginFacts, slug: string, locale: string, suffix: string): string;
|
|
56
|
+
export declare function createPlaceView<L extends string>(site: OriginFacts, place: Place<L>, locale: NoInfer<L>, mode: LinkMode): PlaceView<L>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** `https://<domain>`, or `null` for a site with no domain yet. */
|
|
2
|
+
export function siteOrigin(site) {
|
|
3
|
+
return site.brand.domain === null ? null : `https://${site.brand.domain}`;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Where a place's pages canonically live: its subdomain in a `subdomains`
|
|
7
|
+
* site, the apex in a `single` one, whichever URL served the request.
|
|
8
|
+
*/
|
|
9
|
+
export function placeOrigin(site, slug) {
|
|
10
|
+
const { domain } = site.brand;
|
|
11
|
+
if (domain === null)
|
|
12
|
+
return null;
|
|
13
|
+
return site.topology.kind === "single" ? `https://${domain}` : `https://${slug}.${domain}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A root-relative href to one of the place's pages. `suffix` is a page suffix
|
|
17
|
+
* and may carry a fragment: `"#quote"`, `"/prices#faq"`.
|
|
18
|
+
*/
|
|
19
|
+
export function placeHref(base, locale, suffix) {
|
|
20
|
+
const prefix = base.mode === "host" ? `/${locale}` : `/${locale}/${base.slug}`;
|
|
21
|
+
return `${prefix}${suffix}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The canonical URL of a place's page. A site with no domain has no absolute
|
|
25
|
+
* URL to give; the root-relative path it serves is the honest answer, and
|
|
26
|
+
* every page of such a site is `noindex` anyway.
|
|
27
|
+
*/
|
|
28
|
+
export function placeUrl(site, slug, locale, suffix) {
|
|
29
|
+
const origin = placeOrigin(site, slug);
|
|
30
|
+
if (origin !== null)
|
|
31
|
+
return `${origin}/${locale}${suffix}`;
|
|
32
|
+
return placeHref({ mode: site.topology.kind === "single" ? "host" : "path", slug }, locale, suffix);
|
|
33
|
+
}
|
|
34
|
+
export function createPlaceView(site, place, locale, mode) {
|
|
35
|
+
const base = { mode, slug: place.slug };
|
|
36
|
+
return {
|
|
37
|
+
place,
|
|
38
|
+
locale,
|
|
39
|
+
mode,
|
|
40
|
+
href: (suffix, other = locale) => placeHref(base, other, suffix),
|
|
41
|
+
url: (suffix, other = locale) => placeUrl(site, place.slug, other, suffix),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { LinkMode } from "./place/view.js";
|
|
2
|
+
import type { Site } from "./site.js";
|
|
3
|
+
/**
|
|
4
|
+
* Which place and which language a request gets, decided before any route
|
|
5
|
+
* renders. Pure, so it is tested without a server — and mirrored by the Rust
|
|
6
|
+
* `kitstart` feature against `tests/fixtures/kitstart/decide.json`.
|
|
7
|
+
*
|
|
8
|
+
* ```text
|
|
9
|
+
* <slug>.<domain>/fr/prices → /fr/_<slug>/prices (host link mode)
|
|
10
|
+
* <domain>/fr/<slug>/prices → as it is (path link mode, apex fallback)
|
|
11
|
+
* single site: /fr/prices → /fr/_<place>/prices (host link mode)
|
|
12
|
+
* /fr/_<slug>/… → as it is (Next re-enters the proxy with it)
|
|
13
|
+
* ?lang=<l> on a page → cookie for a year, 303 to the clean URL
|
|
14
|
+
* a `legacyRedirects` path → 301 to where it moved
|
|
15
|
+
* unprefixed page → 302 to /<cookie ?? Accept-Language>/…
|
|
16
|
+
* any other path → gone: the 404 in the page's (or the
|
|
17
|
+
* negotiated) language, for its place
|
|
18
|
+
* /_next/…, a NON_PAGE_ROUTES path, a site's `publicFiles` → pass
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* A path with an extension is no exception: `/wp-login.php` or `/fr/x.php`
|
|
22
|
+
* let past would reach `[locale]`, whose `notFound()` Next caches as a page —
|
|
23
|
+
* a bare 404 without the phone, one ISR entry per scanner's guess.
|
|
24
|
+
*
|
|
25
|
+
* The link mode rides in the path, never in a request header: a page that read
|
|
26
|
+
* a header would render per request, and every page here is cached (ISR). In
|
|
27
|
+
* the path, each mode is its own cache entry, and the two modes' different
|
|
28
|
+
* links can never share one.
|
|
29
|
+
*
|
|
30
|
+
* The negotiation is a **302**, never a 301: the choice is per visitor and
|
|
31
|
+
* must not be cached as permanent. Only page paths enter it — `/quote`,
|
|
32
|
+
* `/sitemap.xml`, `/og` and the brand's files pass straight through.
|
|
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 = "_";
|
|
40
|
+
/**
|
|
41
|
+
* How a dead path reaches a 404 a visitor without JavaScript can read.
|
|
42
|
+
*
|
|
43
|
+
* Next 16 cannot put a `notFound()` boundary into the HTML: that response is
|
|
44
|
+
* an empty `<html id="__next_error__">` the client fills in after hydration.
|
|
45
|
+
* What it does render on the server is a path no route matches — through
|
|
46
|
+
* `app/global-not-found.tsx`. The proxy knows every place and every page, so
|
|
47
|
+
* it rewrites a dead path to `/<locale>/404/404`, which matches nothing, and
|
|
48
|
+
* says which language and place the 404 speaks for in {@link GONE_HEADER}.
|
|
49
|
+
* Reserved: no place may take `404` as its slug.
|
|
50
|
+
*/
|
|
51
|
+
export declare const GONE = "404";
|
|
52
|
+
/** The path no route matches, in a language. */
|
|
53
|
+
export declare function gonePath(locale: string): string;
|
|
54
|
+
/**
|
|
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.
|
|
58
|
+
*/
|
|
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
|
+
export declare function goneHeader(locale: string, location: string | null): string;
|
|
62
|
+
/** Inverse of {@link goneHeader}; anything absent or malformed reads as nothing. */
|
|
63
|
+
export declare function parseGoneHeader(value: string | null): {
|
|
64
|
+
locale?: string;
|
|
65
|
+
location?: string;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The routes every landing has outside `[locale]`, which pass untouched: the
|
|
69
|
+
* form target, the OG card, the probe, the sitemap and robots. Anything else
|
|
70
|
+
* without a language but `/_next/…` and the site's `publicFiles` is `gone`.
|
|
71
|
+
*/
|
|
72
|
+
export declare const NON_PAGE_ROUTES: readonly string[];
|
|
73
|
+
/** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
|
|
74
|
+
export declare const PASS_PATHS: readonly string[];
|
|
75
|
+
/** The `[location]` param for a slug in a mode: `_royat` on its host, `royat` through the apex. */
|
|
76
|
+
export declare function placeParam(slug: string, mode: LinkMode): string;
|
|
77
|
+
/** Inverse of {@link placeParam}. */
|
|
78
|
+
export declare function parsePlaceParam(param: string): {
|
|
79
|
+
slug: string;
|
|
80
|
+
mode: LinkMode;
|
|
81
|
+
};
|
|
82
|
+
/** Every suffix the proxy treats as a page of a place. */
|
|
83
|
+
export declare function pointSuffixes<L extends string, P extends string>(site: Site<L, P>): string[];
|
|
84
|
+
export interface RequestFacts {
|
|
85
|
+
host: string;
|
|
86
|
+
pathname: string;
|
|
87
|
+
query: URLSearchParams;
|
|
88
|
+
acceptLanguage: string | null;
|
|
89
|
+
cookieLang: string | null;
|
|
90
|
+
}
|
|
91
|
+
export type Decision<L extends string> = {
|
|
92
|
+
kind: "pass";
|
|
93
|
+
} | {
|
|
94
|
+
kind: "negotiate";
|
|
95
|
+
location: string;
|
|
96
|
+
} | {
|
|
97
|
+
kind: "choose";
|
|
98
|
+
location: string;
|
|
99
|
+
locale: L;
|
|
100
|
+
} | {
|
|
101
|
+
kind: "moved";
|
|
102
|
+
location: string;
|
|
103
|
+
} | {
|
|
104
|
+
kind: "serve";
|
|
105
|
+
pathname: string;
|
|
106
|
+
}
|
|
107
|
+
/** A dead path: the 404 in `locale`, for a place's param or (`null`) the brand. */
|
|
108
|
+
| {
|
|
109
|
+
kind: "gone";
|
|
110
|
+
locale: L;
|
|
111
|
+
location: string | null;
|
|
112
|
+
};
|
|
113
|
+
export interface Routing<L extends string> {
|
|
114
|
+
/**
|
|
115
|
+
* The place a host names: `royat.<domain>` → `"royat"`, `royat.localhost:3000`
|
|
116
|
+
* too, for local work. On a `single` site every host is the one place.
|
|
117
|
+
*/
|
|
118
|
+
hostSlug(host: string): string | null;
|
|
119
|
+
decide(req: RequestFacts): Decision<L>;
|
|
120
|
+
}
|
|
121
|
+
export declare function createRouting<L extends string, P extends string>(site: Site<L, P>): Routing<L>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which place and which language a request gets, decided before any route
|
|
3
|
+
* renders. Pure, so it is tested without a server — and mirrored by the Rust
|
|
4
|
+
* `kitstart` feature against `tests/fixtures/kitstart/decide.json`.
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* <slug>.<domain>/fr/prices → /fr/_<slug>/prices (host link mode)
|
|
8
|
+
* <domain>/fr/<slug>/prices → as it is (path link mode, apex fallback)
|
|
9
|
+
* single site: /fr/prices → /fr/_<place>/prices (host link mode)
|
|
10
|
+
* /fr/_<slug>/… → as it is (Next re-enters the proxy with it)
|
|
11
|
+
* ?lang=<l> on a page → cookie for a year, 303 to the clean URL
|
|
12
|
+
* a `legacyRedirects` path → 301 to where it moved
|
|
13
|
+
* unprefixed page → 302 to /<cookie ?? Accept-Language>/…
|
|
14
|
+
* any other path → gone: the 404 in the page's (or the
|
|
15
|
+
* negotiated) language, for its place
|
|
16
|
+
* /_next/…, a NON_PAGE_ROUTES path, a site's `publicFiles` → pass
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* A path with an extension is no exception: `/wp-login.php` or `/fr/x.php`
|
|
20
|
+
* let past would reach `[locale]`, whose `notFound()` Next caches as a page —
|
|
21
|
+
* a bare 404 without the phone, one ISR entry per scanner's guess.
|
|
22
|
+
*
|
|
23
|
+
* The link mode rides in the path, never in a request header: a page that read
|
|
24
|
+
* a header would render per request, and every page here is cached (ISR). In
|
|
25
|
+
* the path, each mode is its own cache entry, and the two modes' different
|
|
26
|
+
* links can never share one.
|
|
27
|
+
*
|
|
28
|
+
* The negotiation is a **302**, never a 301: the choice is per visitor and
|
|
29
|
+
* must not be cached as permanent. Only page paths enter it — `/quote`,
|
|
30
|
+
* `/sitemap.xml`, `/og` and the brand's files pass straight through.
|
|
31
|
+
*/
|
|
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 = "_";
|
|
38
|
+
/**
|
|
39
|
+
* How a dead path reaches a 404 a visitor without JavaScript can read.
|
|
40
|
+
*
|
|
41
|
+
* Next 16 cannot put a `notFound()` boundary into the HTML: that response is
|
|
42
|
+
* an empty `<html id="__next_error__">` the client fills in after hydration.
|
|
43
|
+
* What it does render on the server is a path no route matches — through
|
|
44
|
+
* `app/global-not-found.tsx`. The proxy knows every place and every page, so
|
|
45
|
+
* it rewrites a dead path to `/<locale>/404/404`, which matches nothing, and
|
|
46
|
+
* says which language and place the 404 speaks for in {@link GONE_HEADER}.
|
|
47
|
+
* Reserved: no place may take `404` as its slug.
|
|
48
|
+
*/
|
|
49
|
+
export const GONE = "404";
|
|
50
|
+
/** The path no route matches, in a language. */
|
|
51
|
+
export function gonePath(locale) {
|
|
52
|
+
return `/${locale}/${GONE}/${GONE}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
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.
|
|
58
|
+
*/
|
|
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
|
+
export function goneHeader(locale, location) {
|
|
62
|
+
return location ? `${locale}/${location}` : locale;
|
|
63
|
+
}
|
|
64
|
+
/** Inverse of {@link goneHeader}; anything absent or malformed reads as nothing. */
|
|
65
|
+
export function parseGoneHeader(value) {
|
|
66
|
+
const [locale, location, extra] = (value ?? "").split("/");
|
|
67
|
+
if (extra !== undefined)
|
|
68
|
+
return {};
|
|
69
|
+
return { ...(locale ? { locale } : {}), ...(location ? { location } : {}) };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The routes every landing has outside `[locale]`, which pass untouched: the
|
|
73
|
+
* form target, the OG card, the probe, the sitemap and robots. Anything else
|
|
74
|
+
* without a language but `/_next/…` and the site's `publicFiles` is `gone`.
|
|
75
|
+
*/
|
|
76
|
+
export const NON_PAGE_ROUTES = ["/quote", "/og", "/health", "/sitemap.xml", "/robots.txt"];
|
|
77
|
+
/** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
|
|
78
|
+
export const PASS_PATHS = NON_PAGE_ROUTES;
|
|
79
|
+
function isInfrastructure(pathname, publicFiles) {
|
|
80
|
+
return NON_PAGE_ROUTES.includes(pathname) || publicFiles.includes(pathname) || pathname.startsWith("/_next/");
|
|
81
|
+
}
|
|
82
|
+
/** The `[location]` param for a slug in a mode: `_royat` on its host, `royat` through the apex. */
|
|
83
|
+
export function placeParam(slug, mode) {
|
|
84
|
+
return mode === "host" ? `${HOST_MARK}${slug}` : slug;
|
|
85
|
+
}
|
|
86
|
+
/** Inverse of {@link placeParam}. */
|
|
87
|
+
export function parsePlaceParam(param) {
|
|
88
|
+
return param.startsWith(HOST_MARK) ? { slug: param.slice(HOST_MARK.length), mode: "host" } : { slug: param, mode: "path" };
|
|
89
|
+
}
|
|
90
|
+
/** Every suffix the proxy treats as a page of a place. */
|
|
91
|
+
export function pointSuffixes(site) {
|
|
92
|
+
return [...site.pageKeys.map(k => site.pages[k]), THANKS];
|
|
93
|
+
}
|
|
94
|
+
function withQuery(path, query) {
|
|
95
|
+
const rest = new URLSearchParams(query);
|
|
96
|
+
rest.delete("lang");
|
|
97
|
+
const qs = rest.toString();
|
|
98
|
+
return qs ? `${path}?${qs}` : path;
|
|
99
|
+
}
|
|
100
|
+
export function createRouting(site) {
|
|
101
|
+
const { i18n, placeSlugs, topology } = site;
|
|
102
|
+
const publicFiles = site.publicFiles ?? [];
|
|
103
|
+
const suffixes = pointSuffixes(site);
|
|
104
|
+
const hosts = [site.brand.domain, "localhost"].filter((h) => h !== null);
|
|
105
|
+
function hostSlug(host) {
|
|
106
|
+
if (topology.kind === "single")
|
|
107
|
+
return topology.place;
|
|
108
|
+
const name = host.toLowerCase().replace(/:\d+$/, "");
|
|
109
|
+
for (const base of hosts) {
|
|
110
|
+
if (name.endsWith(`.${base}`)) {
|
|
111
|
+
const sub = name.slice(0, -(base.length + 1));
|
|
112
|
+
return placeSlugs.includes(sub) ? sub : null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function legacy(locale, rest) {
|
|
118
|
+
for (const redirect of site.legacyRedirects ?? []) {
|
|
119
|
+
if (redirect.from !== rest)
|
|
120
|
+
continue;
|
|
121
|
+
const to = redirect.to(locale);
|
|
122
|
+
if (to !== null)
|
|
123
|
+
return to;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/** Is `rest` (locale-free) a page this host serves? */
|
|
128
|
+
function isPage(rest, slug) {
|
|
129
|
+
if (slug)
|
|
130
|
+
return suffixes.includes(rest);
|
|
131
|
+
if (rest === "" || rest === THANKS)
|
|
132
|
+
return true; // the brand's own pages
|
|
133
|
+
const [, first = "", ...more] = rest.split("/");
|
|
134
|
+
const suffix = more.length ? `/${more.join("/")}` : "";
|
|
135
|
+
return placeSlugs.includes(first) && suffixes.includes(suffix);
|
|
136
|
+
}
|
|
137
|
+
function split(pathname) {
|
|
138
|
+
const trimmed = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
139
|
+
const [, first = "", ...more] = trimmed.split("/");
|
|
140
|
+
if (i18n.isLocale(first))
|
|
141
|
+
return { locale: first, rest: more.length ? `/${more.join("/")}` : "" };
|
|
142
|
+
return { locale: null, rest: trimmed === "/" ? "" : trimmed };
|
|
143
|
+
}
|
|
144
|
+
function decide(req) {
|
|
145
|
+
const { locale, rest } = split(req.pathname);
|
|
146
|
+
if (locale === null && isInfrastructure(req.pathname, publicFiles))
|
|
147
|
+
return { kind: "pass" };
|
|
148
|
+
if (locale !== null) {
|
|
149
|
+
// The 404's own target passes, keeping the header it was sent with.
|
|
150
|
+
if (rest === `/${GONE}/${GONE}`)
|
|
151
|
+
return { kind: "serve", pathname: req.pathname };
|
|
152
|
+
// Next renders a cached page by running the proxy again, host-less, on
|
|
153
|
+
// the path it was rewritten to; that path must come out as it went in.
|
|
154
|
+
const [, first = "", ...more] = rest.split("/");
|
|
155
|
+
if (first.startsWith(HOST_MARK)) {
|
|
156
|
+
const named = parsePlaceParam(first);
|
|
157
|
+
if (!placeSlugs.includes(named.slug))
|
|
158
|
+
return { kind: "gone", locale, location: null };
|
|
159
|
+
const suffix = more.length ? `/${more.join("/")}` : "";
|
|
160
|
+
return suffixes.includes(suffix) ? { kind: "serve", pathname: req.pathname } : { kind: "gone", locale, location: first };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const slug = hostSlug(req.host);
|
|
164
|
+
if (!slug || topology.kind === "single") {
|
|
165
|
+
const moved = legacy(locale, rest);
|
|
166
|
+
if (moved)
|
|
167
|
+
return { kind: "moved", location: withQuery(moved, req.query) };
|
|
168
|
+
}
|
|
169
|
+
const page = isPage(rest, slug);
|
|
170
|
+
if (page) {
|
|
171
|
+
const asked = req.query.get("lang");
|
|
172
|
+
if (i18n.isLocale(asked)) {
|
|
173
|
+
// The visitor chose, so record it and take the query back out — a shared
|
|
174
|
+
// or bookmarked link should not keep re-asserting a language.
|
|
175
|
+
return { kind: "choose", locale: asked, location: withQuery(i18n.localePath(asked, rest || "/"), req.query) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (locale === null) {
|
|
179
|
+
const chosen = i18n.isLocale(req.cookieLang) ? req.cookieLang : i18n.negotiate(req.acceptLanguage);
|
|
180
|
+
if (page)
|
|
181
|
+
return { kind: "negotiate", location: withQuery(i18n.localePath(chosen, rest || "/"), req.query) };
|
|
182
|
+
// Junk without a language still gets a readable 404, in the language the
|
|
183
|
+
// visitor would have been sent to.
|
|
184
|
+
return { kind: "gone", locale: chosen, location: slug ? placeParam(slug, "host") : null };
|
|
185
|
+
}
|
|
186
|
+
// Every prefixed path on a place's host belongs to that place: `/fr/nope`
|
|
187
|
+
// is that place's 404, not the brand's.
|
|
188
|
+
if (slug) {
|
|
189
|
+
const param = placeParam(slug, "host");
|
|
190
|
+
return page ? { kind: "serve", pathname: `/${locale}/${param}${rest}` } : { kind: "gone", locale, location: param };
|
|
191
|
+
}
|
|
192
|
+
if (page)
|
|
193
|
+
return { kind: "serve", pathname: req.pathname };
|
|
194
|
+
const [, first = ""] = rest.split("/");
|
|
195
|
+
return { kind: "gone", locale, location: placeSlugs.includes(first) ? first : null };
|
|
196
|
+
}
|
|
197
|
+
return { hostSlug, decide };
|
|
198
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type JsonLdNode } from "@evinvest/marketing";
|
|
2
|
+
import { type Place } from "../place/types.js";
|
|
3
|
+
import { type PlaceView } from "../place/view.js";
|
|
4
|
+
import { type Site } from "../site.js";
|
|
5
|
+
/**
|
|
6
|
+
* schema.org, derived from the site, the place and the page's copy — never
|
|
7
|
+
* authored. One `@graph` per page with shared `@id`s, so Google collapses the
|
|
8
|
+
* entities instead of reading each as separate. Output is pinned against
|
|
9
|
+
* aquafix's goldens in `tests/fixtures/kitstart/json-ld.json`, which the Rust
|
|
10
|
+
* `kitstart` feature reads too.
|
|
11
|
+
*
|
|
12
|
+
* A storefront carries its address, pin and photo; a service-area business
|
|
13
|
+
* has none of the three in its type, so its node says where it goes
|
|
14
|
+
* (`areaServed`) and nothing about where it is. No `aggregateRating` from
|
|
15
|
+
* anything baked: only a live rating inside the API's 30-day window.
|
|
16
|
+
*/
|
|
17
|
+
/** One row of a price list, as the page's table prints it. */
|
|
18
|
+
export interface OfferInput {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Whole currency units, the same integer the table cell renders. */
|
|
21
|
+
price: number;
|
|
22
|
+
/** ISO 4217; `EUR` when left out. */
|
|
23
|
+
currency?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface QuestionAnswer {
|
|
26
|
+
q: string;
|
|
27
|
+
a: string;
|
|
28
|
+
}
|
|
29
|
+
/** The words one page's graph quotes. */
|
|
30
|
+
export interface PageGraphCopy {
|
|
31
|
+
/** The short place name the copy uses: "Royat". */
|
|
32
|
+
placeName: string;
|
|
33
|
+
title: string;
|
|
34
|
+
description: string;
|
|
35
|
+
/** Emitted only where the page renders the price list. */
|
|
36
|
+
offers?: readonly OfferInput[];
|
|
37
|
+
/** Emitted only where the page renders the FAQ. */
|
|
38
|
+
faq?: readonly QuestionAnswer[];
|
|
39
|
+
}
|
|
40
|
+
export declare const organizationId: <L extends string, P extends string>(site: Site<L, P>) => string;
|
|
41
|
+
export declare const businessId: <L extends string, P extends string>(site: Site<L, P>, slug: string) => string;
|
|
42
|
+
export declare const websiteId: <L extends string, P extends string>(site: Site<L, P>, slug: string) => string;
|
|
43
|
+
export declare function organizationNode<L extends string, P extends string>(site: Site<L, P>): JsonLdNode;
|
|
44
|
+
/**
|
|
45
|
+
* A radius as a `GeoCircle`; named communes as `City` for a service-area
|
|
46
|
+
* business (the design's shape, §3.4) and as `Place` for a storefront, which
|
|
47
|
+
* is what aquafix's indexed pages already say — its goldens do not move.
|
|
48
|
+
*/
|
|
49
|
+
export declare function areaServedNodes(place: Place<string>): JsonLdNode[];
|
|
50
|
+
/** The business behind one place. */
|
|
51
|
+
export declare function businessNode<L extends string, P extends string>(site: Site<L, P>, view: PlaceView<L>, now: Date): JsonLdNode;
|
|
52
|
+
/** One `Service` + `Offer` per price row: the same integer as the table cell, tax included. */
|
|
53
|
+
export declare function offerNodes<L extends string, P extends string>(site: Site<L, P>, place: Place<L>, offers: readonly OfferInput[]): JsonLdNode[];
|
|
54
|
+
export declare function faqPageNode(items: readonly QuestionAnswer[]): JsonLdNode;
|
|
55
|
+
/** Home → page. The chain is the route, so a new page cannot forget it. */
|
|
56
|
+
export declare function breadcrumbNode<L extends string, P extends string>(site: Site<L, P>, view: PlaceView<L>, page: P | "home", copy: Pick<PageGraphCopy, "placeName" | "title">): JsonLdNode;
|
|
57
|
+
/** The `@graph` for one of a place's pages in one language. */
|
|
58
|
+
export declare function placeGraph<L extends string, P extends string>(site: Site<L, P>, view: PlaceView<L>, page: P | "home", copy: PageGraphCopy, now: Date): JsonLdNode;
|