create-ailk 0.27.0 → 0.28.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 +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Pricing render (#10044 D5) — what `/pricing` and every
|
|
3
|
+
* `/pricing/<offering>` page do with the workspace's Offerings shown set.
|
|
4
|
+
*
|
|
5
|
+
* Moved unchanged from `/pricing` (#8927, spec
|
|
6
|
+
* docs/specs/8927-pricing-renders-the-shown-set-spec.md): the shown-offerings
|
|
7
|
+
* read, shared per server instance for READ_TTL_MS, and its three pages —
|
|
8
|
+
* - unconnected (or production with no API origin) — the MDX page as
|
|
9
|
+
* written, with `?plan=` moving the authored recommendation (#8842, D7);
|
|
10
|
+
* - a current shown set — the workspace's Stripe cards in place of the
|
|
11
|
+
* page's authored `pricing` sections, `?plan=` moving the recommendation
|
|
12
|
+
* only for a `{plan}`-grammar value (D4, contract §3.7);
|
|
13
|
+
* - anything else (expired, empty, a failed read) — the site's contact
|
|
14
|
+
* email in place of those sections, never an error (D5).
|
|
15
|
+
* `pricingSections` runs that choice per page, narrowed to the page's own
|
|
16
|
+
* `plans` (#10044 D4); `plans: []` reads no Stripe data at all.
|
|
17
|
+
*
|
|
18
|
+
* Stripe text reaches markup only as React text through `offeringsPricing`,
|
|
19
|
+
* and JSON-LD only through renderPage's serializer (T4).
|
|
20
|
+
*
|
|
21
|
+
* Base tree: no paid import, no `oss: false`.
|
|
22
|
+
*/
|
|
23
|
+
import { offerings } from "@working-theory/api-client";
|
|
24
|
+
import { ContactFallback } from "@working-theory/ui";
|
|
25
|
+
import {
|
|
26
|
+
LOOKUP_KEY_PLAN_PATTERN,
|
|
27
|
+
type PageSection,
|
|
28
|
+
} from "@working-theory/validation";
|
|
29
|
+
import { getTranslations } from "next-intl/server";
|
|
30
|
+
import { createElement, type ReactNode } from "react";
|
|
31
|
+
|
|
32
|
+
import { contactFormText } from "../app/[locale]/contact/contact-form-text";
|
|
33
|
+
import { readSiteContactEmail } from "../app/[locale]/contact/site-contact-email";
|
|
34
|
+
|
|
35
|
+
import { browserApiOrigin } from "./api-origin.generated";
|
|
36
|
+
import {
|
|
37
|
+
offeringsPricing,
|
|
38
|
+
type OfferingsPricing,
|
|
39
|
+
type OfferingsPricingLabels,
|
|
40
|
+
} from "./offerings-pricing";
|
|
41
|
+
import type { DetailPage } from "./route-family";
|
|
42
|
+
|
|
43
|
+
type PricingSection = Extract<PageSection, { type: "pricing" }>;
|
|
44
|
+
type PricingData = PricingSection["data"];
|
|
45
|
+
type ItemsSection = Extract<PageSection, { type: "items" }>;
|
|
46
|
+
type CardItem = ItemsSection["data"]["items"][number];
|
|
47
|
+
|
|
48
|
+
/** The slug of `/pricing`'s own page — the hub, never an offering page. */
|
|
49
|
+
export const PRICING_HUB_SLUG = "pricing";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every `pricing` section with `recommended` set to `plan` (#8842). Each
|
|
53
|
+
* section matches the key against its own plans, so a second plan group
|
|
54
|
+
* keeps its own default unless it carries the same key.
|
|
55
|
+
*/
|
|
56
|
+
export function withRecommendedPlan(
|
|
57
|
+
sections: PageSection[],
|
|
58
|
+
plan: string,
|
|
59
|
+
): PageSection[] {
|
|
60
|
+
return sections.map((section) =>
|
|
61
|
+
section.type === "pricing"
|
|
62
|
+
? {
|
|
63
|
+
...section,
|
|
64
|
+
// `recommended` is a render-time field the section reads, not an
|
|
65
|
+
// authored one, so it is not in the validated section schema.
|
|
66
|
+
data: { ...section.data, recommended: plan } as typeof section.data,
|
|
67
|
+
}
|
|
68
|
+
: section,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type OfferingsRead =
|
|
73
|
+
| { kind: "unconnected" }
|
|
74
|
+
| { kind: "shown"; shownSet: unknown }
|
|
75
|
+
| { kind: "contact" };
|
|
76
|
+
|
|
77
|
+
const CONTACT: OfferingsRead = { kind: "contact" };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How long the Offerings read is shared across requests (D2): a view carries
|
|
81
|
+
* no visitor headers, so every view would otherwise count against this
|
|
82
|
+
* server's one per-IP limit at the API. A product switched off disappears
|
|
83
|
+
* within it. The contact page's window (#8731).
|
|
84
|
+
*/
|
|
85
|
+
const READ_TTL_MS = 15_000;
|
|
86
|
+
|
|
87
|
+
const reads = new Map<string, { at: number; read: Promise<OfferingsRead> }>();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `read()`'s result, shared per instance for READ_TTL_MS. Concurrent calls
|
|
91
|
+
* share one in-flight read, and a failed read is shared the same way, so a
|
|
92
|
+
* burst against an unreachable API is one call.
|
|
93
|
+
*/
|
|
94
|
+
function sharedRead(
|
|
95
|
+
key: string,
|
|
96
|
+
read: () => Promise<OfferingsRead>,
|
|
97
|
+
): Promise<OfferingsRead> {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const hit = reads.get(key);
|
|
100
|
+
if (hit && now - hit.at < READ_TTL_MS) return hit.read;
|
|
101
|
+
const fresh = read();
|
|
102
|
+
reads.set(key, { at: now, read: fresh });
|
|
103
|
+
return fresh;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether an API origin is configured: the three names `getApiBase()` in
|
|
108
|
+
* packages/api-client/src/http.ts reads, in step with it on a hub. Without one
|
|
109
|
+
* that function answers `localhost:3001`. The browser-facing origin comes from
|
|
110
|
+
* the topology seam (#7874 D12), which a spoke renders with no env read, so a
|
|
111
|
+
* spoke passes its own `env:check` (#9480). A spoke diverges on purpose: the
|
|
112
|
+
* browser-facing name set on one at runtime counts as no origin here.
|
|
113
|
+
*/
|
|
114
|
+
function apiOriginConfigured(): boolean {
|
|
115
|
+
return Boolean(
|
|
116
|
+
process.env.AILK_API_URL ||
|
|
117
|
+
browserApiOrigin() ||
|
|
118
|
+
process.env.NEXT_PUBLIC_API_URL,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The shown-offerings read (D2). A production deployment with no API has no
|
|
124
|
+
* connection to read, so it is unconnected without a call. The body is
|
|
125
|
+
* untrusted: anything but `unconnected` or `shown` — `empty`, an unknown
|
|
126
|
+
* status, a non-object body, a non-2xx, a throw — is the contact email.
|
|
127
|
+
*/
|
|
128
|
+
export function readShownOfferings(): Promise<OfferingsRead> {
|
|
129
|
+
if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
|
|
130
|
+
return Promise.resolve({ kind: "unconnected" });
|
|
131
|
+
}
|
|
132
|
+
return sharedRead("shown-offerings", async () => {
|
|
133
|
+
try {
|
|
134
|
+
const res = await offerings.shown();
|
|
135
|
+
if (!res.ok) return CONTACT;
|
|
136
|
+
const body: unknown = res.body;
|
|
137
|
+
if (body === null || typeof body !== "object") return CONTACT;
|
|
138
|
+
const { status, shownSet } = body as {
|
|
139
|
+
status?: unknown;
|
|
140
|
+
shownSet?: unknown;
|
|
141
|
+
};
|
|
142
|
+
if (status === "unconnected") return { kind: "unconnected" };
|
|
143
|
+
if (status === "shown") return { kind: "shown", shownSet };
|
|
144
|
+
return CONTACT;
|
|
145
|
+
} catch {
|
|
146
|
+
return CONTACT;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Keeps the `{amount}` placeholder for `period` to fill (D6). */
|
|
152
|
+
const KEEP_AMOUNT_PLACEHOLDER = { amount: "{amount}" };
|
|
153
|
+
/** Keeps both placeholders for `withRequired` to fill (#10028 D9). */
|
|
154
|
+
const KEEP_BUNDLE_PLACEHOLDERS = { plan: "{plan}", required: "{required}" };
|
|
155
|
+
const KEEP_DEPOSIT_PLACEHOLDER = { deposit: "{deposit}" };
|
|
156
|
+
|
|
157
|
+
/** The mapper's labels and the plans section's recommendation label (D6). */
|
|
158
|
+
export async function offeringsCopy(locale: string): Promise<{
|
|
159
|
+
labels: OfferingsPricingLabels;
|
|
160
|
+
recommendedLabel: string;
|
|
161
|
+
addonsHeadline: string;
|
|
162
|
+
}> {
|
|
163
|
+
const t = await getTranslations({ locale, namespace: "pricingOfferings" });
|
|
164
|
+
const period = (key: "monthly" | "annual" | "oneTime") => {
|
|
165
|
+
const phrase = t(key, KEEP_AMOUNT_PLACEHOLDER);
|
|
166
|
+
return (amount: string) => phrase.replace("{amount}", () => amount);
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
labels: {
|
|
170
|
+
cta: t("cta"),
|
|
171
|
+
recommended: t("recommended"),
|
|
172
|
+
monthly: period("monthly"),
|
|
173
|
+
annual: period("annual"),
|
|
174
|
+
oneTime: period("oneTime"),
|
|
175
|
+
withRequired: (plan, required) =>
|
|
176
|
+
t("withRequired", KEEP_BUNDLE_PLACEHOLDERS)
|
|
177
|
+
.replace("{plan}", () => plan)
|
|
178
|
+
.replace("{required}", () => required),
|
|
179
|
+
depositNote: (deposit) =>
|
|
180
|
+
t("depositNote", KEEP_DEPOSIT_PLACEHOLDER).replace(
|
|
181
|
+
"{deposit}",
|
|
182
|
+
() => deposit,
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
recommendedLabel: t("recommendedForYou"),
|
|
186
|
+
addonsHeadline: t("addonsHeadline"),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The authored section copy a Stripe section keeps — never a plan or price fact. */
|
|
191
|
+
const AUTHORED_COPY_FIELDS = [
|
|
192
|
+
"eyebrow",
|
|
193
|
+
"headline",
|
|
194
|
+
"description",
|
|
195
|
+
"header",
|
|
196
|
+
"headlineSize",
|
|
197
|
+
"toggleStyle",
|
|
198
|
+
"featureListStyle",
|
|
199
|
+
"gapAbove",
|
|
200
|
+
"separation",
|
|
201
|
+
"gap",
|
|
202
|
+
"surface",
|
|
203
|
+
"note",
|
|
204
|
+
"collapseFeaturesOnMobile",
|
|
205
|
+
] as const satisfies readonly (keyof PricingData)[];
|
|
206
|
+
|
|
207
|
+
function authoredCopy(
|
|
208
|
+
section: PricingSection | undefined,
|
|
209
|
+
extra: readonly (keyof PricingData)[] = [],
|
|
210
|
+
): Partial<PricingData> {
|
|
211
|
+
const copy: Partial<Record<keyof PricingData, unknown>> = {};
|
|
212
|
+
if (!section) return copy as Partial<PricingData>;
|
|
213
|
+
for (const field of [...AUTHORED_COPY_FIELDS, ...extra]) {
|
|
214
|
+
if (section.data[field] !== undefined) copy[field] = section.data[field];
|
|
215
|
+
}
|
|
216
|
+
return copy as Partial<PricingData>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isPricing(section: PageSection): section is PricingSection {
|
|
220
|
+
return section.type === "pricing";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Where a section with no slot goes: after a leading hero, else first. */
|
|
224
|
+
function insertionIndex(sections: PageSection[]): number {
|
|
225
|
+
return sections[0]?.type === "hero" ? 1 : 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The page's sections with the Stripe fragments in place of its authored
|
|
230
|
+
* `pricing` sections (D4). The first non-`addons` pricing section is the
|
|
231
|
+
* plans slot and the first `addons` one the add-ons slot; every other pricing
|
|
232
|
+
* section, and a slot whose fragment is `null`, is removed. A fragment with no
|
|
233
|
+
* slot is inserted after a leading hero (the add-ons directly after the
|
|
234
|
+
* plans). No other section moves.
|
|
235
|
+
*/
|
|
236
|
+
export function withOfferings(
|
|
237
|
+
sections: PageSection[],
|
|
238
|
+
pricing: OfferingsPricing,
|
|
239
|
+
plan: string | undefined,
|
|
240
|
+
recommendedLabel: string,
|
|
241
|
+
addonsHeadline: string,
|
|
242
|
+
): PageSection[] {
|
|
243
|
+
const plansSlot = sections.find(
|
|
244
|
+
(s): s is PricingSection => isPricing(s) && s.data.variant !== "addons",
|
|
245
|
+
);
|
|
246
|
+
const addonsSlot = sections.find(
|
|
247
|
+
(s): s is PricingSection => isPricing(s) && s.data.variant === "addons",
|
|
248
|
+
);
|
|
249
|
+
const recommend =
|
|
250
|
+
plan !== undefined && LOOKUP_KEY_PLAN_PATTERN.test(plan) ? plan : undefined;
|
|
251
|
+
const plansSection: PricingSection | null = pricing.plans && {
|
|
252
|
+
id: plansSlot?.id ?? "offerings-plans",
|
|
253
|
+
type: "pricing",
|
|
254
|
+
data: {
|
|
255
|
+
...authoredCopy(plansSlot),
|
|
256
|
+
...pricing.plans,
|
|
257
|
+
// A `?plan=` recommendation moves the mapper's highlight onto the named
|
|
258
|
+
// plan and wears `recommendedLabel` (#8842); the mapper's own
|
|
259
|
+
// "Recommended" pill label would ride along as a second label, so it is
|
|
260
|
+
// dropped from every card when a recommendation is in play.
|
|
261
|
+
...(recommend !== undefined &&
|
|
262
|
+
pricing.plans.plans.some(
|
|
263
|
+
(card) => card.key?.toLowerCase() === recommend.toLowerCase(),
|
|
264
|
+
)
|
|
265
|
+
? {
|
|
266
|
+
plans: pricing.plans.plans.map(
|
|
267
|
+
({ highlightLabel: _, ...card }) => card,
|
|
268
|
+
),
|
|
269
|
+
}
|
|
270
|
+
: {}),
|
|
271
|
+
// Render-time fields the section reads (#8842), not authored ones.
|
|
272
|
+
recommendedLabel,
|
|
273
|
+
...(recommend !== undefined ? { recommended: recommend } : {}),
|
|
274
|
+
} as PricingData,
|
|
275
|
+
};
|
|
276
|
+
const addonsSection: PricingSection | null = pricing.addons && {
|
|
277
|
+
id: addonsSlot?.id ?? "offerings-addons",
|
|
278
|
+
type: "pricing",
|
|
279
|
+
data: {
|
|
280
|
+
// An inserted add-ons section (no authored slot) sits tight under the
|
|
281
|
+
// plans and carries its own headline, so a lone tile is never an
|
|
282
|
+
// unlabeled band (design review B2).
|
|
283
|
+
...(addonsSlot ? {} : { headline: addonsHeadline, gapAbove: "tight" }),
|
|
284
|
+
...authoredCopy(addonsSlot, ["addonLayout"]),
|
|
285
|
+
...pricing.addons,
|
|
286
|
+
} as PricingData,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const placed: PageSection[] = [];
|
|
290
|
+
for (const section of sections) {
|
|
291
|
+
if (!isPricing(section)) placed.push(section);
|
|
292
|
+
else if (section === plansSlot && plansSection) placed.push(plansSection);
|
|
293
|
+
else if (section === addonsSlot && addonsSection)
|
|
294
|
+
placed.push(addonsSection);
|
|
295
|
+
}
|
|
296
|
+
if (plansSection && !plansSlot) {
|
|
297
|
+
placed.splice(insertionIndex(placed), 0, plansSection);
|
|
298
|
+
}
|
|
299
|
+
if (addonsSection && !addonsSlot) {
|
|
300
|
+
const plansAt = plansSection ? placed.indexOf(plansSection) : -1;
|
|
301
|
+
placed.splice(
|
|
302
|
+
plansAt >= 0 ? plansAt + 1 : insertionIndex(placed),
|
|
303
|
+
0,
|
|
304
|
+
addonsSection,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return placed;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The section the contact email is slotted into (D5). */
|
|
311
|
+
export const CONTACT_SECTION_ID = "offerings-contact";
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The page's sections with every `pricing` section removed and one
|
|
315
|
+
* `contact-form` section in the first one's place, else after a leading hero
|
|
316
|
+
* (D5). It contributes no JSON-LD, so the page publishes no `Offer`.
|
|
317
|
+
*/
|
|
318
|
+
export function withContactFallback(sections: PageSection[]): PageSection[] {
|
|
319
|
+
const first = sections.findIndex(isPricing);
|
|
320
|
+
const kept: PageSection[] = sections.filter((s) => !isPricing(s));
|
|
321
|
+
const contact = {
|
|
322
|
+
id: CONTACT_SECTION_ID,
|
|
323
|
+
type: "contact-form",
|
|
324
|
+
data: { fields: [] },
|
|
325
|
+
} as PageSection;
|
|
326
|
+
kept.splice(first >= 0 ? first : insertionIndex(kept), 0, contact);
|
|
327
|
+
return kept;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The slot the contact section renders into (D5). A slot whose section is
|
|
332
|
+
* absent renders nothing, so every Pricing page carries it and only the
|
|
333
|
+
* contact path places its section (#10044 D6).
|
|
334
|
+
*/
|
|
335
|
+
export async function pricingSectionSlots(
|
|
336
|
+
locale: string,
|
|
337
|
+
): Promise<Record<string, ReactNode>> {
|
|
338
|
+
const text = contactFormText(
|
|
339
|
+
await getTranslations({ locale, namespace: "contact" }),
|
|
340
|
+
);
|
|
341
|
+
return {
|
|
342
|
+
[CONTACT_SECTION_ID]: createElement(ContactFallback, {
|
|
343
|
+
email: readSiteContactEmail(),
|
|
344
|
+
text: {
|
|
345
|
+
heading: text.unavailableHeading,
|
|
346
|
+
body: text.unavailableBody,
|
|
347
|
+
noEmail: text.unavailableNoEmail,
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A page's `plans` frontmatter (#10044 D1) — its plan slugs, or `undefined`
|
|
355
|
+
* when the page names none. The adapter validated it; a non-array is none.
|
|
356
|
+
*/
|
|
357
|
+
export function pagePlans(frontmatter: unknown): string[] | undefined {
|
|
358
|
+
if (frontmatter === null || typeof frontmatter !== "object") return undefined;
|
|
359
|
+
const { plans } = frontmatter as { plans?: unknown };
|
|
360
|
+
if (!Array.isArray(plans)) return undefined;
|
|
361
|
+
return plans.filter((plan): plan is string => typeof plan === "string");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* One Pricing page's sections in its Offerings state (#10044 D5): `/pricing`'s
|
|
366
|
+
* three-state choice with the page's own `plans` passed through.
|
|
367
|
+
* - `plans: []` → the written sections, no Stripe read;
|
|
368
|
+
* - unconnected → the written sections (`?plan=` moving the recommendation);
|
|
369
|
+
* - shown → the page's Stripe cards in place of its `pricing` sections,
|
|
370
|
+
* when any of its plans is shown;
|
|
371
|
+
* - anything else → the contact email in their place.
|
|
372
|
+
*/
|
|
373
|
+
export async function pricingSections(
|
|
374
|
+
declared: PageSection[],
|
|
375
|
+
options: {
|
|
376
|
+
plans: readonly string[] | undefined;
|
|
377
|
+
plan: string | undefined;
|
|
378
|
+
locale: string;
|
|
379
|
+
},
|
|
380
|
+
): Promise<PageSection[]> {
|
|
381
|
+
const { plans, plan, locale } = options;
|
|
382
|
+
const asWritten = () =>
|
|
383
|
+
plan ? withRecommendedPlan(declared, plan) : declared;
|
|
384
|
+
if (plans !== undefined && plans.length === 0) return asWritten();
|
|
385
|
+
|
|
386
|
+
const read = await readShownOfferings();
|
|
387
|
+
if (read.kind === "unconnected") return asWritten();
|
|
388
|
+
if (read.kind === "shown") {
|
|
389
|
+
const { labels, recommendedLabel, addonsHeadline } =
|
|
390
|
+
await offeringsCopy(locale);
|
|
391
|
+
const pricing = offeringsPricing(read.shownSet, { locale, labels, plans });
|
|
392
|
+
if (pricing && (pricing.plans || pricing.addons)) {
|
|
393
|
+
return withOfferings(
|
|
394
|
+
declared,
|
|
395
|
+
pricing,
|
|
396
|
+
plan,
|
|
397
|
+
recommendedLabel,
|
|
398
|
+
addonsHeadline,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return withContactFallback(declared);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The offering pricing pages `/pricing` lists (#10044 D5): those whose
|
|
407
|
+
* frontmatter `slug` is their file slug — a page served at a URL its route
|
|
408
|
+
* does not answer is left off — and is not the hub's own, in input order.
|
|
409
|
+
*/
|
|
410
|
+
export function offeringPricingPages(pages: DetailPage[]): DetailPage[] {
|
|
411
|
+
return pages.filter(
|
|
412
|
+
(page) =>
|
|
413
|
+
page.frontmatter.slug === page.slug && page.slug !== PRICING_HUB_SLUG,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** One `/pricing` list card for an offering pricing page (#10044 D5). */
|
|
418
|
+
export function pricingPageCard(
|
|
419
|
+
page: DetailPage,
|
|
420
|
+
labels: { viewPricing: string },
|
|
421
|
+
): CardItem {
|
|
422
|
+
const { title, summary, description } = page.frontmatter;
|
|
423
|
+
const text = (value: unknown): string | undefined =>
|
|
424
|
+
typeof value === "string" && value.length > 0 ? value : undefined;
|
|
425
|
+
return {
|
|
426
|
+
title: text(title) ?? page.slug,
|
|
427
|
+
description: text(summary) ?? text(description) ?? "",
|
|
428
|
+
link: { label: labels.viewPricing, href: `/pricing/${page.slug}` },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
@@ -35,22 +35,24 @@
|
|
|
35
35
|
* and only as strong as the platform: it holds only where the site's edge
|
|
36
36
|
* overwrites `x-forwarded-for` and `x-real-ip` (Vercel does; a bare
|
|
37
37
|
* self-hosted Next server does not).
|
|
38
|
-
* - A BOT CHECK ON THE
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
38
|
+
* - A BOT CHECK ON THE WRITES (#9889, #9255). `POST /v1/leads/submit`,
|
|
39
|
+
* `/v1/leads/subscribe`, `/v1/leads/callback` and `/v1/schedule/book`
|
|
40
|
+
* ask Vercel BotID (`runBotCheck`, lib/bot-check.ts) for a verdict after
|
|
41
|
+
* every refusal above and before the hub is called. Only a human verdict
|
|
42
|
+
* forwards; a bot is `403 bot_detected`, and a check that throws, answers
|
|
43
|
+
* no boolean or takes over 5 seconds is `503 bot_check_unavailable` —
|
|
44
|
+
* either way the hub is never called. It runs only on Vercel
|
|
45
|
+
* (`VERCEL=1`); `AILK_BOT_CHECK=off` turns every check off and
|
|
46
|
+
* `AILK_BOOKING_BOT_CHECK=off` the booking's alone.
|
|
45
47
|
*
|
|
46
48
|
* NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
|
|
47
49
|
*/
|
|
48
50
|
import { isIP } from "node:net";
|
|
49
51
|
|
|
50
52
|
import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
|
|
51
|
-
import { checkBotId } from "botid/server";
|
|
52
53
|
|
|
53
54
|
import { browserApiOrigin } from "./api-origin.generated";
|
|
55
|
+
import { runBotCheck } from "./bot-check";
|
|
54
56
|
import { readConsentVisitorFromCookieHeader } from "./consent-visitor";
|
|
55
57
|
|
|
56
58
|
type RelayMethod = "GET" | "POST";
|
|
@@ -77,92 +79,33 @@ function route(method: RelayMethod, path: string): RelayRoute {
|
|
|
77
79
|
*/
|
|
78
80
|
const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
|
|
79
81
|
|
|
80
|
-
/** The
|
|
82
|
+
/** The three lead pairs (#9255 D2) and the booking pair (#9889 D1). */
|
|
83
|
+
const SUBMIT_ROUTE = route("POST", "/v1/leads/submit");
|
|
84
|
+
const SUBSCRIBE_ROUTE = route("POST", "/v1/leads/subscribe");
|
|
85
|
+
const CALLBACK_ROUTE = route("POST", "/v1/leads/callback");
|
|
81
86
|
const BOOK_ROUTE = route("POST", "/v1/schedule/book");
|
|
82
87
|
|
|
83
88
|
export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
SUBMIT_ROUTE,
|
|
90
|
+
SUBSCRIBE_ROUTE,
|
|
91
|
+
CALLBACK_ROUTE,
|
|
87
92
|
route("GET", "/v1/schedule/availability"),
|
|
88
93
|
BOOK_ROUTE,
|
|
89
94
|
SESSION_ROUTE,
|
|
90
95
|
]);
|
|
91
96
|
|
|
92
97
|
/**
|
|
93
|
-
* The entries the bot check guards (#9889 D1,
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* no challenge header and be refused as a bot.
|
|
98
|
+
* The entries the bot check guards (#9889 D1, #9255 D2): every write a
|
|
99
|
+
* browser posts here. The locale layout's `BotIdClient` protects every
|
|
100
|
+
* same-origin POST (`BOT_CLIENT_PROTECT`), so each carries BotID's challenge.
|
|
97
101
|
*/
|
|
98
102
|
export const BOT_CHECKED_ROUTES: readonly RelayRoute[] = Object.freeze([
|
|
103
|
+
SUBMIT_ROUTE,
|
|
104
|
+
SUBSCRIBE_ROUTE,
|
|
105
|
+
CALLBACK_ROUTE,
|
|
99
106
|
BOOK_ROUTE,
|
|
100
107
|
]);
|
|
101
108
|
|
|
102
|
-
/** The longest the relay waits for a verdict; the library's own fetch has no bound. */
|
|
103
|
-
const BOT_CHECK_TIMEOUT_MS = 5_000;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* True when the booking bot check runs (#9889 D2): on Vercel, unless
|
|
107
|
-
* `AILK_BOOKING_BOT_CHECK` is `off`. Unset, empty or any other value leaves
|
|
108
|
-
* it on, so a typo fails toward protection. Read on every call, never cached.
|
|
109
|
-
*/
|
|
110
|
-
export function bookingBotCheckOn(): boolean {
|
|
111
|
-
return process.env.VERCEL === "1" && !botCheckSwitchedOff();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function botCheckSwitchedOff(): boolean {
|
|
115
|
-
return process.env.AILK_BOOKING_BOT_CHECK?.trim().toLowerCase() === "off";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let warnedBotCheckInactive = false;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Called when the check is off. Off Vercel it cannot run (D2): a production
|
|
122
|
-
* site whose switch is not `off` logs that once per process, so an owner who
|
|
123
|
-
* believes it is on can find out. It never refuses.
|
|
124
|
-
*/
|
|
125
|
-
function warnBotCheckInactiveOnce(): void {
|
|
126
|
-
if (warnedBotCheckInactive || process.env.NODE_ENV !== "production") return;
|
|
127
|
-
if (botCheckSwitchedOff()) return;
|
|
128
|
-
warnedBotCheckInactive = true;
|
|
129
|
-
console.warn("[relay] booking bot check inactive", {
|
|
130
|
-
outcome: "not-on-vercel",
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function namedError(name: string): Error {
|
|
135
|
-
const err = new Error(name);
|
|
136
|
-
err.name = name;
|
|
137
|
-
return err;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* BotID's verdict on this request (D1, D3): `true` for a bot, `false` for a
|
|
142
|
-
* human. Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
|
|
143
|
-
* `InvalidVerdictError` when `isBot` is not a boolean, as `checkBotId` returns
|
|
144
|
-
* for an error answer from Vercel.
|
|
145
|
-
*/
|
|
146
|
-
async function isBotVerdict(): Promise<boolean> {
|
|
147
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
148
|
-
try {
|
|
149
|
-
const verdict = await Promise.race([
|
|
150
|
-
checkBotId(),
|
|
151
|
-
new Promise<never>((_resolve, reject) => {
|
|
152
|
-
timer = setTimeout(
|
|
153
|
-
() => reject(namedError("TimeoutError")),
|
|
154
|
-
BOT_CHECK_TIMEOUT_MS,
|
|
155
|
-
);
|
|
156
|
-
}),
|
|
157
|
-
]);
|
|
158
|
-
const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
|
|
159
|
-
if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
|
|
160
|
-
return isBot;
|
|
161
|
-
} finally {
|
|
162
|
-
clearTimeout(timer);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
109
|
/**
|
|
167
110
|
* The three lead routes (#8438 D11, D12): a visitor's banner choice is
|
|
168
111
|
* linked to a Person only through these — a booking has no consent writer.
|
|
@@ -552,33 +495,15 @@ export async function handleRelay(request: Request): Promise<Response> {
|
|
|
552
495
|
}
|
|
553
496
|
|
|
554
497
|
if (BOT_CHECKED_ROUTES.includes(entry)) {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
path: entry.path,
|
|
563
|
-
outcome: "bot-check-failed",
|
|
564
|
-
error: err instanceof Error ? err.name : "unknown",
|
|
565
|
-
});
|
|
566
|
-
return refusal(
|
|
567
|
-
503,
|
|
568
|
-
"bot_check_unavailable",
|
|
569
|
-
"The booking check is unavailable",
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
if (isBot) {
|
|
573
|
-
console.warn("[relay] bot refused", {
|
|
574
|
-
path: entry.path,
|
|
575
|
-
outcome: "bot-detected",
|
|
576
|
-
});
|
|
577
|
-
return refusal(403, "bot_detected", "Request refused");
|
|
578
|
-
}
|
|
579
|
-
} else {
|
|
580
|
-
warnBotCheckInactiveOnce();
|
|
498
|
+
const verdict = await runBotCheck({
|
|
499
|
+
source: "relay",
|
|
500
|
+
target: entry.path,
|
|
501
|
+
scope: entry === BOOK_ROUTE ? "booking" : "write",
|
|
502
|
+
});
|
|
503
|
+
if (verdict === "unavailable") {
|
|
504
|
+
return refusal(503, "bot_check_unavailable", "The bot check is unavailable");
|
|
581
505
|
}
|
|
506
|
+
if (verdict === "bot") return refusal(403, "bot_detected", "Request refused");
|
|
582
507
|
}
|
|
583
508
|
|
|
584
509
|
try {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-family helper (#10051 D5) — the generic half of a content hub
|
|
3
|
+
* that lists its detail pages automatically: read every detail page of one
|
|
4
|
+
* type, and slot a generated section in before the page's closing CTAs.
|
|
5
|
+
*
|
|
6
|
+
* The /templates hub (#10051) is its first caller. It is shaped for the next
|
|
7
|
+
* two: #10043 (a services category page listing its child `Service` pages)
|
|
8
|
+
* and #10044 (a pricing hub listing its offering `Pricing` pages). The
|
|
9
|
+
* /alternatives and /reports routes keep their own local copies (#10051
|
|
10
|
+
* § Out of scope).
|
|
11
|
+
*
|
|
12
|
+
* Base tree: no paid import, no `oss: false`.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
getMdxSlugs,
|
|
16
|
+
getMdxSource,
|
|
17
|
+
type getPageSections,
|
|
18
|
+
type SchemaOrgType,
|
|
19
|
+
} from "@working-theory/page-renderer";
|
|
20
|
+
|
|
21
|
+
type Section = Awaited<ReturnType<typeof getPageSections>>[number];
|
|
22
|
+
|
|
23
|
+
/** One detail page as a hub lists it: its slug and raw frontmatter. */
|
|
24
|
+
export interface DetailPage {
|
|
25
|
+
slug: string;
|
|
26
|
+
frontmatter: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every detail page of `type` for `locale`, loaded through `load` (the
|
|
31
|
+
* content adapter by default), a slug whose load is `null` dropped, sorted
|
|
32
|
+
* by slug so the list is stable. `listSlugs` defaults to the content adapter
|
|
33
|
+
* too; a route passes its own to stay injectable in tests.
|
|
34
|
+
*/
|
|
35
|
+
export async function listDetailPages(
|
|
36
|
+
type: SchemaOrgType,
|
|
37
|
+
locale: string,
|
|
38
|
+
load: typeof getMdxSource = getMdxSource,
|
|
39
|
+
listSlugs: typeof getMdxSlugs = getMdxSlugs,
|
|
40
|
+
): Promise<DetailPage[]> {
|
|
41
|
+
const slugs = await listSlugs(type, locale);
|
|
42
|
+
const pages = await Promise.all(
|
|
43
|
+
slugs.map(async (slug): Promise<DetailPage | null> => {
|
|
44
|
+
const source = await load(type, slug, locale);
|
|
45
|
+
if (source === null) return null;
|
|
46
|
+
return {
|
|
47
|
+
slug,
|
|
48
|
+
frontmatter: source.frontmatter as Record<string, unknown>,
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
return pages
|
|
53
|
+
.filter((page): page is DetailPage => page !== null)
|
|
54
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Insert `section` before the trailing run of `cta` sections — the page's
|
|
59
|
+
* closing call to action stays last — or append it when the page does not end
|
|
60
|
+
* in a CTA (the /reports rule, #7745 D4).
|
|
61
|
+
*/
|
|
62
|
+
export function insertBeforeClosingCtas(
|
|
63
|
+
sections: Section[],
|
|
64
|
+
section: Section,
|
|
65
|
+
): Section[] {
|
|
66
|
+
let at = sections.length;
|
|
67
|
+
while (at > 0 && sections[at - 1]?.type === "cta") at -= 1;
|
|
68
|
+
return [...sections.slice(0, at), section, ...sections.slice(at)];
|
|
69
|
+
}
|