@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,101 @@
|
|
|
1
|
+
// Build-time only: read by `next.config.ts` and `vitest.config.ts`, never by
|
|
2
|
+
// app code. `assets/` is the one place a contact fact, the mark or a colour is
|
|
3
|
+
// written — the printed card, the vCards and the site all read it — so the
|
|
4
|
+
// site takes them at build time and inlines them rather than keeping a copy.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
/**
|
|
8
|
+
* The slice of TOML these two files use for the values read here: `[a.b]`
|
|
9
|
+
* headers and `key = "basic string"` lines; everything else (arrays, numbers,
|
|
10
|
+
* comments) is skipped — not the kit's `parseToml`, to keep `next.config.ts`
|
|
11
|
+
* free of the kit. `evinvest-palette` still validates brand.toml in full.
|
|
12
|
+
*/
|
|
13
|
+
export function readStringTables(text) {
|
|
14
|
+
const tables = { "": {} };
|
|
15
|
+
let current = "";
|
|
16
|
+
for (const raw of text.split("\n")) {
|
|
17
|
+
const line = raw.trim();
|
|
18
|
+
const header = /^\[([A-Za-z0-9_.-]+)\]$/.exec(line);
|
|
19
|
+
if (header?.[1]) {
|
|
20
|
+
current = header[1];
|
|
21
|
+
tables[current] ??= {};
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const pair = /^([A-Za-z0-9_-]+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(line);
|
|
25
|
+
if (pair?.[1] && pair[2] !== undefined) {
|
|
26
|
+
const table = (tables[current] ??= {});
|
|
27
|
+
table[pair[1]] = pair[2].replace(/\\(["\\])/g, "$1");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return tables;
|
|
31
|
+
}
|
|
32
|
+
function readToml(root, file) {
|
|
33
|
+
return readStringTables(readFileSync(join(root, file), "utf8"));
|
|
34
|
+
}
|
|
35
|
+
export function readCard(root) {
|
|
36
|
+
const toml = readToml(root, "assets/card.toml")[""] ?? {};
|
|
37
|
+
// Optional for the web: a brand may launch with no phone line and no domain
|
|
38
|
+
// yet. Present but blank is still an error — a typo, not a choice.
|
|
39
|
+
const field = (key) => {
|
|
40
|
+
if (!Object.hasOwn(toml, key))
|
|
41
|
+
return null;
|
|
42
|
+
const value = toml[key];
|
|
43
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
44
|
+
throw new Error(`assets/card.toml: \`${key}\` must be a non-empty string when present`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
const phone = field("phone");
|
|
49
|
+
// A national number cannot become `tel:+…` or `wa.me/…` without guessing a
|
|
50
|
+
// country, and a mistyped call target is the most expensive silent bug on
|
|
51
|
+
// this page.
|
|
52
|
+
if (phone !== null && !phone.startsWith("+")) {
|
|
53
|
+
throw new Error(`assets/card.toml: phone must be international, got ${JSON.stringify(phone)}`);
|
|
54
|
+
}
|
|
55
|
+
return { phone, email: field("email"), site: field("site") };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The inner markup of `assets/mark.svg` — the path, not the `<svg>` wrapper,
|
|
59
|
+
* which the component writes itself so it can size and colour it. The fill is
|
|
60
|
+
* `currentColor`, which an `<img>` could not inherit.
|
|
61
|
+
*/
|
|
62
|
+
export function readMarkPath(root) {
|
|
63
|
+
const svg = readFileSync(join(root, "assets/mark.svg"), "utf8");
|
|
64
|
+
const d = /\sd="([^"]+)"/.exec(svg)?.[1];
|
|
65
|
+
if (!d)
|
|
66
|
+
throw new Error("assets/mark.svg: no <path d=…> to inline");
|
|
67
|
+
return d;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The dark scope's values for the OG card. `ImageResponse` draws with inline
|
|
71
|
+
* styles and cannot read a custom property, so the card takes the palette's
|
|
72
|
+
* values at build time instead of a second copy of them in code.
|
|
73
|
+
*/
|
|
74
|
+
export function readOgPalette(root) {
|
|
75
|
+
const dark = readToml(root, "assets/brand.toml")["colors.dark"] ?? {};
|
|
76
|
+
const pick = (key) => {
|
|
77
|
+
const value = dark[key];
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
throw new Error(`assets/brand.toml: [colors.dark] ${key} is missing`);
|
|
80
|
+
return value;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
background: pick("background"),
|
|
84
|
+
card: pick("card"),
|
|
85
|
+
ink: pick("ink"),
|
|
86
|
+
inkSoft: pick("ink-soft"),
|
|
87
|
+
primary: pick("primary"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Everything inlined as `process.env.*`, under the names app code reads. */
|
|
91
|
+
export function buildEnv(root) {
|
|
92
|
+
const card = readCard(root);
|
|
93
|
+
return {
|
|
94
|
+
// An absent fact is inlined as "", which `cardFact` reads back as `null`.
|
|
95
|
+
SITE_CARD_PHONE: card.phone ?? "",
|
|
96
|
+
SITE_CARD_EMAIL: card.email ?? "",
|
|
97
|
+
SITE_CARD_SITE: card.site ?? "",
|
|
98
|
+
SITE_MARK_PATH: readMarkPath(root),
|
|
99
|
+
SITE_OG_PALETTE: JSON.stringify(readOgPalette(root)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/next/config` — for `next.config.ts` and
|
|
3
|
+
* `vitest.config.ts` only. Its own subpath because the config is loaded
|
|
4
|
+
* outside any React Server bundle, where `./next` (which reaches
|
|
5
|
+
* `server-only` modules) would throw on import.
|
|
6
|
+
*/
|
|
7
|
+
import type { NextConfig } from "next";
|
|
8
|
+
export { buildEnv, readCard, readMarkPath, readOgPalette, readStringTables, type CardFacts, type OgPalette } from "./build-env.js";
|
|
9
|
+
export interface LandingOptions {
|
|
10
|
+
/** The brand repo's root, where `assets/` lives: `process.cwd()` in `next.config.ts`. */
|
|
11
|
+
root: string;
|
|
12
|
+
/** Files the OG route reads at run time, which tracing cannot infer from a path. */
|
|
13
|
+
ogFiles?: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The Next config every landing shares, under the brand's own. What each line
|
|
17
|
+
* is for:
|
|
18
|
+
*
|
|
19
|
+
* - `output: "standalone"` — the image ships the server plus only the traced
|
|
20
|
+
* files, not the toolchain;
|
|
21
|
+
* - `env` — the card's facts, the mark and the OG palette inlined at build:
|
|
22
|
+
* the container carries no `assets/`;
|
|
23
|
+
* - `expireTime` — pages are cached (ISR) on the live data's TTL; this bounds
|
|
24
|
+
* the `stale-while-revalidate` tail a CDN may serve to a day, not a year;
|
|
25
|
+
* - `images.unoptimized` + the sharp exclusion — the optimiser would add a
|
|
26
|
+
* native dependency the Nix image cannot load, for sources already sized;
|
|
27
|
+
* - `isrFlushToDisk: false` — the server runs from the read-only Nix store, so
|
|
28
|
+
* the cache stays in memory, per pod.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withLanding(config: NextConfig, options: LandingOptions): NextConfig;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { buildEnv } from "./build-env.js";
|
|
2
|
+
export { buildEnv, readCard, readMarkPath, readOgPalette, readStringTables } from "./build-env.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Next config every landing shares, under the brand's own. What each line
|
|
5
|
+
* is for:
|
|
6
|
+
*
|
|
7
|
+
* - `output: "standalone"` — the image ships the server plus only the traced
|
|
8
|
+
* files, not the toolchain;
|
|
9
|
+
* - `env` — the card's facts, the mark and the OG palette inlined at build:
|
|
10
|
+
* the container carries no `assets/`;
|
|
11
|
+
* - `expireTime` — pages are cached (ISR) on the live data's TTL; this bounds
|
|
12
|
+
* the `stale-while-revalidate` tail a CDN may serve to a day, not a year;
|
|
13
|
+
* - `images.unoptimized` + the sharp exclusion — the optimiser would add a
|
|
14
|
+
* native dependency the Nix image cannot load, for sources already sized;
|
|
15
|
+
* - `isrFlushToDisk: false` — the server runs from the read-only Nix store, so
|
|
16
|
+
* the cache stays in memory, per pod.
|
|
17
|
+
*/
|
|
18
|
+
export function withLanding(config, options) {
|
|
19
|
+
return {
|
|
20
|
+
poweredByHeader: false,
|
|
21
|
+
output: "standalone",
|
|
22
|
+
expireTime: 86_400,
|
|
23
|
+
...config,
|
|
24
|
+
env: { ...buildEnv(options.root), ...config.env },
|
|
25
|
+
images: { unoptimized: true, ...config.images },
|
|
26
|
+
outputFileTracingIncludes: {
|
|
27
|
+
...(options.ogFiles ? { "/og": options.ogFiles } : {}),
|
|
28
|
+
...config.outputFileTracingIncludes,
|
|
29
|
+
},
|
|
30
|
+
outputFileTracingExcludes: {
|
|
31
|
+
"*": ["node_modules/sharp/**", "node_modules/@img/**"],
|
|
32
|
+
...config.outputFileTracingExcludes,
|
|
33
|
+
},
|
|
34
|
+
// `app/global-not-found.tsx` answers every path no route matches — the proxy
|
|
35
|
+
// sends dead paths there (`gone`); the root layout lives under `[locale]`.
|
|
36
|
+
experimental: { isrFlushToDisk: false, globalNotFound: true, ...config.experimental },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/next` — the handlers and loaders the brand's `app/`
|
|
3
|
+
* files take, so each route file is a few lines of literal segment config and
|
|
4
|
+
* one factory call. Server side only (route handlers, Server Components,
|
|
5
|
+
* `generateMetadata`); `next.config.ts` imports `./next/config` instead.
|
|
6
|
+
*/
|
|
7
|
+
export { brandMetadata, metadataBase, placeMetadata, statusMetadata, type PageMeta } from "./metadata.js";
|
|
8
|
+
export { createPlaceLoader, loadLocale, type PlaceParams } from "./load.js";
|
|
9
|
+
export { healthRoute, robotsRoute, sitemapRoute } from "./routes.js";
|
|
10
|
+
export { ogRoute, type OgCard, type OgFont, type OgOptions } from "./og.js";
|
|
11
|
+
export { ogPalette, type OgColours } from "./og-palette.js";
|
|
12
|
+
export { quoteRoute, type QuoteRouteDeps, type UnavailableCopy } from "./quote-route.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/next` — the handlers and loaders the brand's `app/`
|
|
3
|
+
* files take, so each route file is a few lines of literal segment config and
|
|
4
|
+
* one factory call. Server side only (route handlers, Server Components,
|
|
5
|
+
* `generateMetadata`); `next.config.ts` imports `./next/config` instead.
|
|
6
|
+
*/
|
|
7
|
+
export { brandMetadata, metadataBase, placeMetadata, statusMetadata } from "./metadata.js";
|
|
8
|
+
export { createPlaceLoader, loadLocale } from "./load.js";
|
|
9
|
+
export { healthRoute, robotsRoute, sitemapRoute } from "./routes.js";
|
|
10
|
+
export { ogRoute } from "./og.js";
|
|
11
|
+
export { ogPalette } from "./og-palette.js";
|
|
12
|
+
export { quoteRoute } from "./quote-route.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type PlaceView } from "../core/place/view.js";
|
|
2
|
+
import type { Site } from "../core/site.js";
|
|
3
|
+
import type { PlaceSource } from "../server/place-source.js";
|
|
4
|
+
export interface PlaceParams {
|
|
5
|
+
locale: string;
|
|
6
|
+
location: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The place and the view for one page, from the route params alone: the link
|
|
10
|
+
* mode is in the `location` param (`_royat` on a place's own host), so the
|
|
11
|
+
* page reads nothing from the request and is cached (ISR) on the live data's
|
|
12
|
+
* TTL. `notFound()` for an unknown language or place — a real 404 with
|
|
13
|
+
* `noindex`. A failing live source never throws here (`createPlaceSource`).
|
|
14
|
+
*
|
|
15
|
+
* Memoised per render on (locale, param): `generateMetadata`, the layout and
|
|
16
|
+
* the page ask for the same place once.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createPlaceLoader<L extends string, P extends string>(site: Site<L, P>, source: PlaceSource<L>): (params: Promise<PlaceParams> | PlaceParams) => Promise<PlaceView<L>>;
|
|
19
|
+
/** `[locale]` alone: the language, or the 404. */
|
|
20
|
+
export declare function loadLocale<L extends string, P extends string>(site: Site<L, P>, params: Promise<{
|
|
21
|
+
locale: string;
|
|
22
|
+
}> | {
|
|
23
|
+
locale: string;
|
|
24
|
+
}): Promise<L>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { notFound } from "next/navigation.js";
|
|
2
|
+
import { cache } from "react";
|
|
3
|
+
import { createPlaceView } from "../core/place/view.js";
|
|
4
|
+
import { parsePlaceParam } from "../core/routing.js";
|
|
5
|
+
/**
|
|
6
|
+
* The place and the view for one page, from the route params alone: the link
|
|
7
|
+
* mode is in the `location` param (`_royat` on a place's own host), so the
|
|
8
|
+
* page reads nothing from the request and is cached (ISR) on the live data's
|
|
9
|
+
* TTL. `notFound()` for an unknown language or place — a real 404 with
|
|
10
|
+
* `noindex`. A failing live source never throws here (`createPlaceSource`).
|
|
11
|
+
*
|
|
12
|
+
* Memoised per render on (locale, param): `generateMetadata`, the layout and
|
|
13
|
+
* the page ask for the same place once.
|
|
14
|
+
*/
|
|
15
|
+
export function createPlaceLoader(site, source) {
|
|
16
|
+
const load = cache(async (locale, param) => {
|
|
17
|
+
if (!site.i18n.isLocale(locale))
|
|
18
|
+
notFound();
|
|
19
|
+
const { slug, mode } = parsePlaceParam(param);
|
|
20
|
+
const place = await source.getPlace(slug, locale);
|
|
21
|
+
if (!place)
|
|
22
|
+
notFound();
|
|
23
|
+
return createPlaceView(site, place, locale, mode);
|
|
24
|
+
});
|
|
25
|
+
return async (params) => {
|
|
26
|
+
const { locale, location } = await params;
|
|
27
|
+
return load(locale, location);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** `[locale]` alone: the language, or the 404. */
|
|
31
|
+
export async function loadLocale(site, params) {
|
|
32
|
+
const { locale } = await params;
|
|
33
|
+
if (!site.i18n.isLocale(locale))
|
|
34
|
+
notFound();
|
|
35
|
+
return locale;
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { type PlaceView } from "../core/place/view.js";
|
|
3
|
+
import { type Site } from "../core/site.js";
|
|
4
|
+
/**
|
|
5
|
+
* `<head>` for the three kinds of page. Every string comes from the copy — the
|
|
6
|
+
* description here is the same field the OG card and the JSON-LD read. Output
|
|
7
|
+
* pinned in `tests/fixtures/kitstart/metadata.json` (aquafix's goldens, and
|
|
8
|
+
* the service-area and pre-launch sites).
|
|
9
|
+
*
|
|
10
|
+
* Each language version is its own indexable URL, self-canonical, naming the
|
|
11
|
+
* whole cluster in `hreflang` with `x-default` on the default locale. The
|
|
12
|
+
* canonical host is always the place's own (its subdomain, or the apex on a
|
|
13
|
+
* single site), so the apex fallback path never competes. An unpublished place
|
|
14
|
+
* answers `noindex`.
|
|
15
|
+
*
|
|
16
|
+
* A site with no domain yet is not on the web: every page is `noindex,
|
|
17
|
+
* nofollow`, and there is no canonical, no `hreflang` cluster and no
|
|
18
|
+
* `og:url` — a relative one would name no page, and a guessed host would name
|
|
19
|
+
* the wrong one.
|
|
20
|
+
*/
|
|
21
|
+
export interface PageMeta {
|
|
22
|
+
title: string;
|
|
23
|
+
description: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function placeMetadata<L extends string, P extends string>(site: Site<L, P>, view: PlaceView<L>, page: P | "home", copy: PageMeta): Metadata;
|
|
26
|
+
/** The apex's own page — the directory of a `subdomains` site. */
|
|
27
|
+
export declare function brandMetadata<L extends string, P extends string>(site: Site<L, P>, locale: L, copy: PageMeta): Metadata;
|
|
28
|
+
/** Status pages are never indexed and never in the sitemap. */
|
|
29
|
+
export declare function statusMetadata<L extends string, P extends string>(site: Site<L, P>, title: string): Metadata;
|
|
30
|
+
/** `metadataBase` for the root layout: the apex, or loopback before launch. */
|
|
31
|
+
export declare function metadataBase<L extends string, P extends string>(site: Site<L, P>): URL;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isPublished } from "../core/place/publication.js";
|
|
2
|
+
import { placeOrigin, siteOrigin } from "../core/place/view.js";
|
|
3
|
+
import { ogImageUrl } from "../core/seo/sitemap.js";
|
|
4
|
+
import { ogLocaleOf } from "../core/site.js";
|
|
5
|
+
const OG_SIZE = { width: 1200, height: 630 };
|
|
6
|
+
const PRE_LAUNCH = { index: false, follow: false };
|
|
7
|
+
function others(site, locale) {
|
|
8
|
+
return site.i18n.locales.filter(l => l !== locale).map(l => ogLocaleOf(site, l));
|
|
9
|
+
}
|
|
10
|
+
/** One key order for every page kind — the fixtures pin it. */
|
|
11
|
+
function head(site, page) {
|
|
12
|
+
return {
|
|
13
|
+
title: page.title,
|
|
14
|
+
description: page.description,
|
|
15
|
+
...(page.robots ? { robots: page.robots } : {}),
|
|
16
|
+
...(page.canonical !== null && page.languages ? { alternates: { canonical: page.canonical, languages: page.languages } } : {}),
|
|
17
|
+
openGraph: {
|
|
18
|
+
type: "website",
|
|
19
|
+
siteName: site.brand.name,
|
|
20
|
+
title: page.title,
|
|
21
|
+
description: page.description,
|
|
22
|
+
...(page.canonical !== null ? { url: page.canonical } : {}),
|
|
23
|
+
locale: ogLocaleOf(site, page.locale),
|
|
24
|
+
alternateLocale: others(site, page.locale),
|
|
25
|
+
images: [{ url: page.image, ...OG_SIZE }],
|
|
26
|
+
},
|
|
27
|
+
twitter: { card: "summary_large_image" },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function placeMetadata(site, view, page, copy) {
|
|
31
|
+
const { name } = site.brand;
|
|
32
|
+
const title = page === "home" ? `${name} — ${copy.title}` : `${copy.title} · ${name}`;
|
|
33
|
+
const suffix = site.pages[page];
|
|
34
|
+
const origin = placeOrigin(site, view.place.slug);
|
|
35
|
+
const published = isPublished(view.place, site.publication, site.brand);
|
|
36
|
+
return head(site, {
|
|
37
|
+
title,
|
|
38
|
+
description: copy.description,
|
|
39
|
+
locale: view.locale,
|
|
40
|
+
canonical: origin === null ? null : view.url(suffix),
|
|
41
|
+
languages: origin === null ? null : site.i18n.languageAlternates(suffix || "/", origin),
|
|
42
|
+
robots: origin === null ? PRE_LAUNCH : published ? { index: true, follow: true } : { index: false, follow: true },
|
|
43
|
+
image: ogImageUrl(site, { slug: view.place.slug, locale: view.locale, page }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The apex's own page — the directory of a `subdomains` site. */
|
|
47
|
+
export function brandMetadata(site, locale, copy) {
|
|
48
|
+
const origin = siteOrigin(site);
|
|
49
|
+
return head(site, {
|
|
50
|
+
title: copy.title,
|
|
51
|
+
description: copy.description,
|
|
52
|
+
locale,
|
|
53
|
+
canonical: origin === null ? null : `${origin}${site.i18n.localePath(locale, "/")}`,
|
|
54
|
+
languages: origin === null ? null : site.i18n.languageAlternates("/", origin),
|
|
55
|
+
robots: origin === null ? PRE_LAUNCH : undefined,
|
|
56
|
+
image: ogImageUrl(site, { locale }),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Status pages are never indexed and never in the sitemap. */
|
|
60
|
+
export function statusMetadata(site, title) {
|
|
61
|
+
return { title: `${title} · ${site.brand.name}`, robots: { index: false, follow: false } };
|
|
62
|
+
}
|
|
63
|
+
/** `metadataBase` for the root layout: the apex, or loopback before launch. */
|
|
64
|
+
export function metadataBase(site) {
|
|
65
|
+
return new URL(siteOrigin(site) ?? "http://localhost");
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OG card's colours, inlined at build by `withLanding` from
|
|
3
|
+
* `assets/brand.toml` `[colors.dark]` (`SITE_OG_PALETTE`). `ImageResponse`
|
|
4
|
+
* draws with inline styles and cannot read a custom property, so the card
|
|
5
|
+
* takes the palette's values rather than a second copy of them in code.
|
|
6
|
+
*/
|
|
7
|
+
export interface OgColours {
|
|
8
|
+
background: string;
|
|
9
|
+
card: string;
|
|
10
|
+
ink: string;
|
|
11
|
+
inkSoft: string;
|
|
12
|
+
primary: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function ogPalette(inlined?: string | undefined): OgColours;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function ogPalette(inlined = process.env["SITE_OG_PALETTE"]) {
|
|
2
|
+
if (inlined === undefined)
|
|
3
|
+
throw new Error("SITE_OG_PALETTE was not inlined — build through withLanding (next.config.ts)");
|
|
4
|
+
const raw = JSON.parse(inlined);
|
|
5
|
+
if (typeof raw !== "object" || raw === null)
|
|
6
|
+
throw new Error("SITE_OG_PALETTE is not an object");
|
|
7
|
+
const pick = (k) => {
|
|
8
|
+
const v = Reflect.get(raw, k);
|
|
9
|
+
if (typeof v !== "string")
|
|
10
|
+
throw new Error(`SITE_OG_PALETTE.${k} is missing`);
|
|
11
|
+
return v;
|
|
12
|
+
};
|
|
13
|
+
return { background: pick("background"), card: pick("card"), ink: pick("ink"), inkSoft: pick("inkSoft"), primary: pick("primary") };
|
|
14
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { Place } from "../core/place/types.js";
|
|
3
|
+
import { type Site } from "../core/site.js";
|
|
4
|
+
/**
|
|
5
|
+
* `app/og/route.tsx`: the OG card, drawn by the brand from the same fields the
|
|
6
|
+
* `<head>` reads. The query is normalised to the closed set of (place,
|
|
7
|
+
* language, page) before it is a cache key — an unknown one falls back to a
|
|
8
|
+
* known one — and each card is drawn once per process: satori and resvg are
|
|
9
|
+
* the most expensive thing the server does, and a crawler fetching every card
|
|
10
|
+
* must not make them the most frequent. Baked data only.
|
|
11
|
+
*/
|
|
12
|
+
export interface OgCard<L extends string, P extends string> {
|
|
13
|
+
locale: L;
|
|
14
|
+
/** The baked place, or `null` for the brand's own card. */
|
|
15
|
+
place: Place<L> | null;
|
|
16
|
+
page: P | "home";
|
|
17
|
+
}
|
|
18
|
+
export interface OgFont {
|
|
19
|
+
name: string;
|
|
20
|
+
data: ArrayBuffer | Buffer;
|
|
21
|
+
weight?: 400 | 500 | 600 | 700;
|
|
22
|
+
style?: "normal" | "italic";
|
|
23
|
+
}
|
|
24
|
+
export interface OgOptions<L extends string, P extends string> {
|
|
25
|
+
draw: (card: OgCard<L, P>) => ReactElement;
|
|
26
|
+
/** Read once per card; the brand ships them via `outputFileTracingIncludes`. */
|
|
27
|
+
fonts: () => Promise<OgFont[]>;
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function ogRoute<L extends string, P extends string>(site: Site<L, P>, options: OgOptions<L, P>): (request: Request) => Promise<Response>;
|
package/dist/next/og.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ImageResponse } from "next/og.js";
|
|
2
|
+
import { memoByKey } from "../core/memo.js";
|
|
3
|
+
import { bakedPlace } from "../core/site.js";
|
|
4
|
+
export function ogRoute(site, options) {
|
|
5
|
+
const width = options.width ?? 1200;
|
|
6
|
+
const height = options.height ?? 630;
|
|
7
|
+
// Read once per process, like the cards; a failed read is retried next time.
|
|
8
|
+
let fonts;
|
|
9
|
+
const loadFonts = () => {
|
|
10
|
+
fonts ??= options.fonts().catch((error) => {
|
|
11
|
+
fonts = undefined;
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
return fonts;
|
|
15
|
+
};
|
|
16
|
+
const card = memoByKey(async (key) => {
|
|
17
|
+
const [slug = "", lang = "", p = ""] = key.split("|");
|
|
18
|
+
const locale = site.i18n.isLocale(lang) ? lang : site.i18n.defaultLocale;
|
|
19
|
+
const page = site.pageKeys.find(k => k === p) ?? "home";
|
|
20
|
+
const faces = await loadFonts();
|
|
21
|
+
// An empty list would replace `next/og`'s bundled face with nothing.
|
|
22
|
+
const image = new ImageResponse(options.draw({ locale, place: bakedPlace(site, slug) ?? null, page }), {
|
|
23
|
+
width,
|
|
24
|
+
height,
|
|
25
|
+
...(faces.length > 0 ? { fonts: faces } : {}),
|
|
26
|
+
});
|
|
27
|
+
return image.arrayBuffer();
|
|
28
|
+
});
|
|
29
|
+
return async (request) => {
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
const lang = url.searchParams.get("lang");
|
|
32
|
+
const p = url.searchParams.get("p");
|
|
33
|
+
const key = [
|
|
34
|
+
bakedPlace(site, url.searchParams.get("l") ?? "")?.slug ?? "",
|
|
35
|
+
site.i18n.isLocale(lang) ? lang : site.i18n.defaultLocale,
|
|
36
|
+
site.pageKeys.find(k => k === p) ?? "home",
|
|
37
|
+
].join("|");
|
|
38
|
+
return new Response(await card(key), {
|
|
39
|
+
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600, s-maxage=86400" },
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { RateLimiter } from "../core/antispam.js";
|
|
2
|
+
import type { LeadStore } from "../core/lead.js";
|
|
3
|
+
import type { Place } from "../core/place/types.js";
|
|
4
|
+
import { type Site } from "../core/site.js";
|
|
5
|
+
import type { ServerEnv } from "../server/env.js";
|
|
6
|
+
import type { LeadNotifier } from "../server/notify.js";
|
|
7
|
+
/**
|
|
8
|
+
* `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
|
|
9
|
+
* a plain form POST answered with a 303, so a refresh does not resubmit.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const dynamic = "force-dynamic";
|
|
13
|
+
* export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export interface UnavailableCopy {
|
|
17
|
+
title: string;
|
|
18
|
+
heading: string;
|
|
19
|
+
body: string;
|
|
20
|
+
callLabel: string;
|
|
21
|
+
}
|
|
22
|
+
export interface QuoteRouteDeps<L extends string> {
|
|
23
|
+
env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy">;
|
|
24
|
+
/** Built once (and at boot, by the brand) so a missing sender fails startup, not a lead. */
|
|
25
|
+
notifier: () => LeadNotifier;
|
|
26
|
+
/** The self-contained 500's words, when the store refused the lead. */
|
|
27
|
+
unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
|
|
28
|
+
/** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
|
|
29
|
+
store?: () => LeadStore;
|
|
30
|
+
limiter?: RateLimiter;
|
|
31
|
+
/** Work after the response; `after` from `next/server` by default. */
|
|
32
|
+
defer?: (task: () => Promise<void> | void) => void;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
log?: Pick<Console, "warn" | "error">;
|
|
35
|
+
}
|
|
36
|
+
export declare function quoteRoute<L extends string, P extends string>(site: Site<L, P>, deps: QuoteRouteDeps<L>): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { after } from "next/server.js";
|
|
2
|
+
import { createAcceptLead } from "../core/accept.js";
|
|
3
|
+
import { analyticsSink, EVENTS } from "../core/analytics.js";
|
|
4
|
+
import { RateLimiter } from "../core/antispam.js";
|
|
5
|
+
import { createPlaceView } from "../core/place/view.js";
|
|
6
|
+
import { createRouting, THANKS } from "../core/routing.js";
|
|
7
|
+
import { bakedPlace, contactOf } from "../core/site.js";
|
|
8
|
+
import { clientKey } from "../server/client-key.js";
|
|
9
|
+
import { openLeadStore } from "../server/lead-store.js";
|
|
10
|
+
/** The Rust server's body limit; a quote is a few short fields. */
|
|
11
|
+
const MAX_BODY = 64 * 1024;
|
|
12
|
+
/** Outside production, with no `TRUSTED_PROXY`: the one hop a dev server has. */
|
|
13
|
+
const DEV_TRUST = { xffHops: 1 };
|
|
14
|
+
class TooLarge extends Error {
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The form, read with a hard cut-off: `Content-Length` is only the client's
|
|
18
|
+
* word, and a chunked body has none, so the bytes are counted as they arrive.
|
|
19
|
+
*/
|
|
20
|
+
async function readForm(request) {
|
|
21
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY)
|
|
22
|
+
throw new TooLarge();
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let size = 0;
|
|
25
|
+
if (request.body) {
|
|
26
|
+
const reader = request.body.getReader();
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done)
|
|
30
|
+
break;
|
|
31
|
+
size += value.byteLength;
|
|
32
|
+
if (size > MAX_BODY) {
|
|
33
|
+
await reader.cancel();
|
|
34
|
+
throw new TooLarge();
|
|
35
|
+
}
|
|
36
|
+
chunks.push(value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const body = new Uint8Array(new ArrayBuffer(size));
|
|
40
|
+
let at = 0;
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
body.set(chunk, at);
|
|
43
|
+
at += chunk.byteLength;
|
|
44
|
+
}
|
|
45
|
+
return new Response(body, { headers: { "content-type": request.headers.get("content-type") ?? "" } }).formData();
|
|
46
|
+
}
|
|
47
|
+
const escape = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
|
48
|
+
function seeOther(location) {
|
|
49
|
+
return new Response(null, { status: 303, headers: { Location: location } });
|
|
50
|
+
}
|
|
51
|
+
export function quoteRoute(site, deps) {
|
|
52
|
+
const accept = createAcceptLead(site);
|
|
53
|
+
const routing = createRouting(site);
|
|
54
|
+
const limiter = deps.limiter ?? new RateLimiter(5, 10 * 60_000);
|
|
55
|
+
const defer = deps.defer ?? after;
|
|
56
|
+
const log = deps.log ?? console;
|
|
57
|
+
let store;
|
|
58
|
+
let notifier;
|
|
59
|
+
const leadStore = () => {
|
|
60
|
+
store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
|
|
61
|
+
return store;
|
|
62
|
+
};
|
|
63
|
+
/** Where a page of the submitting place is — or of the brand, with none. */
|
|
64
|
+
function href(request, slug, locale, suffix) {
|
|
65
|
+
const place = slug ? bakedPlace(site, slug) : undefined;
|
|
66
|
+
if (!place)
|
|
67
|
+
return `/${locale}${suffix}`;
|
|
68
|
+
// Links follow the host the form was posted from: the place's own, or the
|
|
69
|
+
// apex fallback path.
|
|
70
|
+
const mode = routing.hostSlug(request.headers.get("host") ?? "") === place.slug ? "host" : "path";
|
|
71
|
+
return createPlaceView(site, place, locale, mode).href(suffix);
|
|
72
|
+
}
|
|
73
|
+
/** Self-contained: the thing that failed may be the thing that renders pages. */
|
|
74
|
+
function unavailable(slug, locale) {
|
|
75
|
+
const place = (slug ? bakedPlace(site, slug) : undefined) ?? null;
|
|
76
|
+
const phone = place ? contactOf(site, place).phone : site.brand.phone;
|
|
77
|
+
const words = deps.unavailable(locale, place);
|
|
78
|
+
const call = phone ? `<p><a href="tel:${escape(phone.replace(/[^\d+]/g, ""))}">${escape(words.callLabel)}</a></p>` : "";
|
|
79
|
+
const html = `<!doctype html><html lang="${escape(locale)}"><meta charset="utf-8"><meta name="robots" content="noindex"><title>${escape(words.title)}</title><body style="font-family:system-ui;max-width:36rem;margin:4rem auto;padding:0 1.25rem"><h1>${escape(words.heading)}</h1><p>${escape(words.body)}</p>${call}</body></html>`;
|
|
80
|
+
return new Response(html, { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
81
|
+
}
|
|
82
|
+
return async (request) => {
|
|
83
|
+
let form;
|
|
84
|
+
try {
|
|
85
|
+
form = await readForm(request);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return new Response(null, { status: error instanceof TooLarge ? 413 : 400 });
|
|
89
|
+
}
|
|
90
|
+
let env;
|
|
91
|
+
try {
|
|
92
|
+
env = deps.env();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// A broken setting must still answer with the phone, never a bare 500.
|
|
96
|
+
log.error("quote: the server environment is unusable", error);
|
|
97
|
+
const locale = form.get("locale");
|
|
98
|
+
return unavailable(null, typeof locale === "string" && site.i18n.isLocale(locale) ? locale : site.i18n.defaultLocale);
|
|
99
|
+
}
|
|
100
|
+
const outcome = await accept(form, clientKey(request.headers, env.trustedProxy ?? DEV_TRUST), {
|
|
101
|
+
insert: lead => leadStore().insert(lead),
|
|
102
|
+
defer,
|
|
103
|
+
notify: (lead, id) => {
|
|
104
|
+
notifier ??= deps.notifier();
|
|
105
|
+
return notifier.notify(lead, id);
|
|
106
|
+
},
|
|
107
|
+
capture: (lead, formId) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, lead.placeSlug).capture(EVENTS.leadSubmit, {
|
|
108
|
+
form_id: formId,
|
|
109
|
+
}),
|
|
110
|
+
limiter,
|
|
111
|
+
now: deps.now?.() ?? Date.now(),
|
|
112
|
+
log,
|
|
113
|
+
});
|
|
114
|
+
switch (outcome.kind) {
|
|
115
|
+
// A suspected bot is answered exactly as a person is.
|
|
116
|
+
case "stored":
|
|
117
|
+
return seeOther(href(request, outcome.lead.placeSlug, outcome.locale, THANKS));
|
|
118
|
+
case "invalid":
|
|
119
|
+
return seeOther(href(request, outcome.slug, outcome.locale, outcome.slug ? "#quote" : ""));
|
|
120
|
+
case "failed":
|
|
121
|
+
return unavailable(outcome.slug, outcome.locale);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import type { Site } from "../core/site.js";
|
|
3
|
+
import type { PlaceSource } from "../server/place-source.js";
|
|
4
|
+
/**
|
|
5
|
+
* Strict: with a live source configured, an unreachable one throws, and the
|
|
6
|
+
* crawler keeps its last copy — an empty or truncated sitemap would tell it
|
|
7
|
+
* the place is gone.
|
|
8
|
+
*/
|
|
9
|
+
export declare function sitemapRoute<L extends string, P extends string>(site: Site<L, P>, source: PlaceSource<L>): () => Promise<MetadataRoute.Sitemap>;
|
|
10
|
+
export declare function robotsRoute<L extends string, P extends string>(site: Site<L, P>): () => Promise<MetadataRoute.Robots>;
|
|
11
|
+
/**
|
|
12
|
+
* The readiness probe: the process answers. `check` is for a brand that wants
|
|
13
|
+
* more (the lead store's `health()`); a failing check answers 503.
|
|
14
|
+
*/
|
|
15
|
+
export declare function healthRoute(check?: () => Promise<void>): () => Promise<Response>;
|