create-ailk 0.27.0 → 0.28.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 +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/env-catalog.js +20 -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/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- 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 +223 -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 +236 -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 +278 -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/versions.json +48 -48
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) — the /alternatives (#6878 D9) and /templates
|
|
3
|
+
* (#10051 D7) shape, shared by the content hubs that list one child page
|
|
4
|
+
* type each (/for, /case-studies, /team, …).
|
|
5
|
+
*
|
|
6
|
+
* The hub adds a generated card per child page of its type that the hub's
|
|
7
|
+
* own sections do not already link: into the authored card grid that already
|
|
8
|
+
* lists its children when there is one, else as one new `items` section
|
|
9
|
+
* before any trailing closing CTA (#7745 D4). `renderPage` builds the CollectionPage
|
|
10
|
+
* ItemList JSON-LD from the same sections, so the visible cards and the
|
|
11
|
+
* ItemList always list the same pages. A child the hub already links keeps
|
|
12
|
+
* its hand-written card and gets no second one, so a hub whose MDX already
|
|
13
|
+
* links every child renders exactly as before.
|
|
14
|
+
*
|
|
15
|
+
* A prose hub (`bodyMode: "mdx-body"`) never reaches renderPage's
|
|
16
|
+
* SectionRenderer, so its generated section renders after the MDX body
|
|
17
|
+
* through renderPage's `trailing` slot.
|
|
18
|
+
*
|
|
19
|
+
* Base tree: no paid import, no `oss: false`.
|
|
20
|
+
*/
|
|
21
|
+
import type {
|
|
22
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
23
|
+
getMdxSource as defaultGetMdxSource,
|
|
24
|
+
getPageSections as defaultGetPageSections,
|
|
25
|
+
renderPage,
|
|
26
|
+
SchemaOrgType,
|
|
27
|
+
} from "@working-theory/page-renderer";
|
|
28
|
+
import {
|
|
29
|
+
getMdxSlugs as loadMdxSlugs,
|
|
30
|
+
getMdxSource as loadMdxSource,
|
|
31
|
+
getPageSections as loadPageSections,
|
|
32
|
+
} from "@working-theory/page-renderer";
|
|
33
|
+
import { SectionRenderer } from "@working-theory/ui/sections";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
type DetailPage,
|
|
37
|
+
insertBeforeClosingCtas,
|
|
38
|
+
listDetailPages,
|
|
39
|
+
} from "./route-family";
|
|
40
|
+
|
|
41
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
42
|
+
|
|
43
|
+
/** One hub: its list page, its child type, and how its body renders. */
|
|
44
|
+
export interface HubConfig {
|
|
45
|
+
/** The list page type, e.g. `UseCasesList`. */
|
|
46
|
+
listType: SchemaOrgType;
|
|
47
|
+
/** The hub's slug, translations namespace and route segment, e.g. `for`. */
|
|
48
|
+
slug: string;
|
|
49
|
+
/** The child page type, e.g. `UseCase`. */
|
|
50
|
+
childType: SchemaOrgType;
|
|
51
|
+
/** The card link label. */
|
|
52
|
+
linkLabel: string;
|
|
53
|
+
/** The generated section's id. */
|
|
54
|
+
sectionId: string;
|
|
55
|
+
/**
|
|
56
|
+
* The hub's existing body rule: `sections` always, `mdx-body` always, or
|
|
57
|
+
* `composed` — sections when the frontmatter carries `sections[]`, the MDX
|
|
58
|
+
* body otherwise.
|
|
59
|
+
*/
|
|
60
|
+
body: "sections" | "mdx-body" | "composed";
|
|
61
|
+
/** Which child pages the hub lists (default: all). */
|
|
62
|
+
includeChild?: (page: DetailPage) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* One listed page's card (default: title, description and a link to
|
|
65
|
+
* `/<slug>/<child>` labelled `linkLabel`).
|
|
66
|
+
*/
|
|
67
|
+
card?: (page: DetailPage) => HubCard;
|
|
68
|
+
/** A last pass over the hub's final sections (default: none). */
|
|
69
|
+
mapSections?: (sections: Section[]) => Section[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One generated card. */
|
|
73
|
+
export interface HubCard {
|
|
74
|
+
title: string;
|
|
75
|
+
description?: string | undefined;
|
|
76
|
+
link?: { label: string; href: string } | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A hub route's injectable loaders (tests pass their own). */
|
|
80
|
+
export interface HubLoaders {
|
|
81
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
82
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
83
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A link's path for matching: an absolute http(s) URL's pathname, the query
|
|
88
|
+
* and hash dropped, a leading `/` ensured and trailing `/`s trimmed.
|
|
89
|
+
*/
|
|
90
|
+
function linkPath(href: string): string | null {
|
|
91
|
+
let path = href.trim();
|
|
92
|
+
if (/^https?:\/\//i.test(path)) {
|
|
93
|
+
try {
|
|
94
|
+
path = new URL(path).pathname;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
path = path.split(/[?#]/)[0] ?? "";
|
|
100
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
101
|
+
let end = path.length;
|
|
102
|
+
while (end > 0 && path[end - 1] === "/") end -= 1;
|
|
103
|
+
return path.slice(0, end);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A BCP-47-shaped first path segment: `en`, `pt-BR`, `zh-Hant`. */
|
|
107
|
+
const LOCALE_SEGMENT = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
|
|
108
|
+
|
|
109
|
+
/** The path without one leading locale segment (`/en/for/a` → `/for/a`). */
|
|
110
|
+
function withoutLocale(path: string): string {
|
|
111
|
+
const end = path.indexOf("/", 1);
|
|
112
|
+
if (end === -1) return path;
|
|
113
|
+
return LOCALE_SEGMENT.test(path.slice(1, end)) ? path.slice(end) : path;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a link path names `target`, with or without a locale prefix. */
|
|
117
|
+
function linksTo(path: string, target: string): boolean {
|
|
118
|
+
return path === target || withoutLocale(path) === target;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every link path an MDX body carries: markdown link targets and `href`
|
|
123
|
+
* attributes, the forms a prose hub links its pages with.
|
|
124
|
+
*/
|
|
125
|
+
export function bodyLinkPaths(body: string): string[] {
|
|
126
|
+
const hrefs = [
|
|
127
|
+
...body.matchAll(/\]\(\s*<?([^)\s>]+)/g),
|
|
128
|
+
...body.matchAll(/\bhref\s*=\s*\{?\s*["']([^"']+)["']/g),
|
|
129
|
+
].map((match) => match[1] ?? "");
|
|
130
|
+
return hrefs
|
|
131
|
+
.map(linkPath)
|
|
132
|
+
.filter((path): path is string => path !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every link path the sections carry: any `href` or `url` string at any
|
|
137
|
+
* depth, so an `items` card link, a `team` member's `url` and a CTA all count.
|
|
138
|
+
*/
|
|
139
|
+
export function linkedPaths(sections: readonly unknown[]): string[] {
|
|
140
|
+
const paths: string[] = [];
|
|
141
|
+
const walk = (value: unknown): void => {
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
value.forEach(walk);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (value === null || typeof value !== "object") return;
|
|
147
|
+
for (const [key, child] of Object.entries(value)) {
|
|
148
|
+
if ((key === "href" || key === "url") && typeof child === "string") {
|
|
149
|
+
const path = linkPath(child);
|
|
150
|
+
if (path !== null) paths.push(path);
|
|
151
|
+
} else {
|
|
152
|
+
walk(child);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
walk(sections);
|
|
157
|
+
return paths;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The generated section: one card per child page the hub's rendered content
|
|
162
|
+
* does not already link (`linked`, from `linkedPaths` or `bodyLinkPaths`; a
|
|
163
|
+
* locale prefix or an absolute URL still counts as a link), or `null` when
|
|
164
|
+
* there is none.
|
|
165
|
+
*/
|
|
166
|
+
export function childCardsSection(
|
|
167
|
+
linked: readonly string[],
|
|
168
|
+
pages: readonly DetailPage[],
|
|
169
|
+
config: Pick<HubConfig, "slug" | "linkLabel" | "sectionId" | "card">,
|
|
170
|
+
): Section | null {
|
|
171
|
+
// A child counts as linked at its route path or at its card's own href (a
|
|
172
|
+
// hub whose public base differs from its route, #10043).
|
|
173
|
+
const items = pages.flatMap((page) => {
|
|
174
|
+
const card = config.card?.(page) ?? defaultCard(page, config);
|
|
175
|
+
const targets = [`/${config.slug}/${page.slug}`];
|
|
176
|
+
if (card.link !== undefined) targets.push(card.link.href);
|
|
177
|
+
const linkedAlready = linked.some((path) =>
|
|
178
|
+
targets.some((t) => linksTo(path, t)),
|
|
179
|
+
);
|
|
180
|
+
return linkedAlready ? [] : [card];
|
|
181
|
+
});
|
|
182
|
+
if (items.length === 0) return null;
|
|
183
|
+
return { type: "items", id: config.sectionId, data: { items } } as Section;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function defaultCard(
|
|
187
|
+
{ slug, frontmatter }: DetailPage,
|
|
188
|
+
config: Pick<HubConfig, "slug" | "linkLabel">,
|
|
189
|
+
): HubCard {
|
|
190
|
+
const fm = frontmatter as { title?: string; description?: string };
|
|
191
|
+
return {
|
|
192
|
+
title: fm.title ?? slug,
|
|
193
|
+
description: fm.description ?? "",
|
|
194
|
+
link: { label: config.linkLabel, href: `/${config.slug}/${slug}` },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type CardItem = { link?: { href?: string; variant?: string } };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Place the generated cards. When an authored `items` section already lists
|
|
202
|
+
* children of this hub (a card linking `/<hub>/…`), the cards continue that
|
|
203
|
+
* grid, taking its card link variant, so the hub shows one list. Otherwise
|
|
204
|
+
* the generated section goes before the closing CTAs (#7745 D4).
|
|
205
|
+
*/
|
|
206
|
+
export function placeChildCards(
|
|
207
|
+
declared: Section[],
|
|
208
|
+
generated: Section,
|
|
209
|
+
hubSlug: string,
|
|
210
|
+
): Section[] {
|
|
211
|
+
const prefix = `/${hubSlug}/`;
|
|
212
|
+
const listsChildren = (section: Section): boolean =>
|
|
213
|
+
section.type === "items" &&
|
|
214
|
+
((section.data as { items?: CardItem[] }).items ?? []).some((item) => {
|
|
215
|
+
const href = item.link?.href;
|
|
216
|
+
const path = href === undefined ? null : linkPath(href);
|
|
217
|
+
// Raw first: a short hub slug (`for`) itself looks like a locale.
|
|
218
|
+
return (
|
|
219
|
+
path !== null &&
|
|
220
|
+
(path.startsWith(prefix) || withoutLocale(path).startsWith(prefix))
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
let at = declared.length - 1;
|
|
224
|
+
while (at >= 0 && !listsChildren(declared[at] as Section)) at -= 1;
|
|
225
|
+
if (at === -1) return insertBeforeClosingCtas(declared, generated);
|
|
226
|
+
|
|
227
|
+
const host = declared[at] as Section & { data: { items: CardItem[] } };
|
|
228
|
+
const variant = host.data.items.find((item) => item.link?.variant)?.link
|
|
229
|
+
?.variant;
|
|
230
|
+
const added = (generated.data as { items: CardItem[] }).items.map((item) =>
|
|
231
|
+
variant === undefined ? item : { ...item, link: { ...item.link, variant } },
|
|
232
|
+
);
|
|
233
|
+
const merged = {
|
|
234
|
+
...host,
|
|
235
|
+
data: { ...host.data, items: [...host.data.items, ...added] },
|
|
236
|
+
} as Section;
|
|
237
|
+
return [...declared.slice(0, at), merged, ...declared.slice(at + 1)];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasSections(frontmatter: unknown): boolean {
|
|
241
|
+
const sections = (frontmatter as { sections?: unknown }).sections;
|
|
242
|
+
return Array.isArray(sections) && sections.length > 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type RenderPageInput = Parameters<typeof renderPage>[0];
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Everything a hub's `renderPage` call needs beyond its literal `type` and
|
|
249
|
+
* `slug`, which stay written out in each page.tsx where the content-render
|
|
250
|
+
* lint reads them.
|
|
251
|
+
*/
|
|
252
|
+
export async function hubPageInput(
|
|
253
|
+
config: HubConfig,
|
|
254
|
+
locale: string,
|
|
255
|
+
loaders: HubLoaders,
|
|
256
|
+
): Promise<Omit<RenderPageInput, "type" | "slug">> {
|
|
257
|
+
const load = loaders.getMdxSource ?? loadMdxSource;
|
|
258
|
+
const base = {
|
|
259
|
+
locale,
|
|
260
|
+
translationsNamespace: config.slug,
|
|
261
|
+
...(loaders.getMdxSource !== undefined && {
|
|
262
|
+
getMdxSource: loaders.getMdxSource,
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const source = await load(config.listType, config.slug, locale);
|
|
267
|
+
const mdxBody =
|
|
268
|
+
config.body === "mdx-body" ||
|
|
269
|
+
(config.body === "composed" &&
|
|
270
|
+
(source === null || !hasSections(source.frontmatter)));
|
|
271
|
+
if (source === null) {
|
|
272
|
+
// The hub's existing empty-state rule, unchanged.
|
|
273
|
+
return {
|
|
274
|
+
...base,
|
|
275
|
+
...(mdxBody && { bodyMode: "mdx-body" as const }),
|
|
276
|
+
...(loaders.getPageSections !== undefined && {
|
|
277
|
+
getPageSections: loaders.getPageSections,
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const declared = await (loaders.getPageSections ?? loadPageSections)(
|
|
283
|
+
source.frontmatter,
|
|
284
|
+
);
|
|
285
|
+
const pages = await listDetailPages(
|
|
286
|
+
config.childType,
|
|
287
|
+
locale,
|
|
288
|
+
load,
|
|
289
|
+
loaders.getMdxSlugs ?? loadMdxSlugs,
|
|
290
|
+
);
|
|
291
|
+
// A prose hub renders its MDX body, never its declared sections, so its
|
|
292
|
+
// existing links are read from the body and its cards stand alone after it.
|
|
293
|
+
const linked = mdxBody
|
|
294
|
+
? bodyLinkPaths(source.body)
|
|
295
|
+
: linkedPaths(declared);
|
|
296
|
+
const generated = childCardsSection(
|
|
297
|
+
linked,
|
|
298
|
+
config.includeChild ? pages.filter(config.includeChild) : pages,
|
|
299
|
+
config,
|
|
300
|
+
);
|
|
301
|
+
const placed =
|
|
302
|
+
generated === null
|
|
303
|
+
? declared
|
|
304
|
+
: mdxBody
|
|
305
|
+
? insertBeforeClosingCtas(declared, generated)
|
|
306
|
+
: placeChildCards(declared, generated, config.slug);
|
|
307
|
+
const sections = config.mapSections ? config.mapSections(placed) : placed;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
...base,
|
|
311
|
+
getPageSections: async () => sections,
|
|
312
|
+
...(mdxBody && { bodyMode: "mdx-body" as const }),
|
|
313
|
+
...(mdxBody &&
|
|
314
|
+
generated !== null && {
|
|
315
|
+
trailing: <SectionRenderer sections={[generated]} locale={locale} />,
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -7,3 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
/** The scheduling module's booking pages (/meet, /private) exist. */
|
|
9
9
|
export const SCHEDULING_PAGES: boolean = false;
|
|
10
|
+
|
|
11
|
+
/** The marketing module's public pages exist (#10040): false on a hub. */
|
|
12
|
+
export const MARKETING_PAGES: boolean = true;
|
|
13
|
+
|
|
14
|
+
/** The docs-blog module's /docs and /blog pages exist (#10040). */
|
|
15
|
+
export const DOCS_BLOG_PAGES: boolean = false;
|
|
16
|
+
|
|
17
|
+
/** Any public page exists: robots, sitemap, llms.txt and the noindex header read this (#10040 D6/D7). */
|
|
18
|
+
export const PUBLIC_PAGES: boolean = true;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// nav-label-check.ts — find nav keys a locale's messages file does not carry
|
|
2
|
+
// (#10045 D2). Run before `next build` by `scripts/check-nav-labels.ts`.
|
|
3
|
+
//
|
|
4
|
+
// A locale's catalog is `messages/<locale>.json` plus the site overlay
|
|
5
|
+
// `messages/site/<locale>.json` (falling back to `messages/site/en.json`),
|
|
6
|
+
// matching how `i18n/request.ts` loads them, so a key the site adds in its
|
|
7
|
+
// overlay counts as present.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { navMessageKeys } from "./nav-label-keys";
|
|
13
|
+
|
|
14
|
+
export interface MissingNavLabel {
|
|
15
|
+
key: string;
|
|
16
|
+
/** The `messages/<locale>.json` file the key is missing from. */
|
|
17
|
+
file: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readJson(file: string): unknown {
|
|
21
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every (key, messages file) pair where a used nav key has no text. */
|
|
25
|
+
export function findMissingNavLabels(
|
|
26
|
+
messagesDir: string,
|
|
27
|
+
keys: Iterable<string>,
|
|
28
|
+
): MissingNavLabel[] {
|
|
29
|
+
const wanted = [...new Set(keys)].sort();
|
|
30
|
+
const englishOverlay = join(messagesDir, "site", "en.json");
|
|
31
|
+
const missing: MissingNavLabel[] = [];
|
|
32
|
+
const catalogs = readdirSync(messagesDir, { withFileTypes: true })
|
|
33
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
34
|
+
.map((entry) => entry.name)
|
|
35
|
+
.sort();
|
|
36
|
+
for (const name of catalogs) {
|
|
37
|
+
const file = join(messagesDir, name);
|
|
38
|
+
const ownOverlay = join(messagesDir, "site", name);
|
|
39
|
+
const overlay = existsSync(ownOverlay) ? ownOverlay : englishOverlay;
|
|
40
|
+
const present = new Set([
|
|
41
|
+
...navMessageKeys(readJson(file)),
|
|
42
|
+
...navMessageKeys(readJson(overlay)),
|
|
43
|
+
]);
|
|
44
|
+
for (const key of wanted) {
|
|
45
|
+
if (!present.has(key)) missing.push({ key, file });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return missing;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One line per missing key, naming the key and the file. */
|
|
52
|
+
export function formatMissingNavLabels(
|
|
53
|
+
missing: readonly MissingNavLabel[],
|
|
54
|
+
): string {
|
|
55
|
+
return missing
|
|
56
|
+
.map(
|
|
57
|
+
({ key, file }) =>
|
|
58
|
+
`nav key "${key}" is used by the site nav but missing from ${file} (namespace "nav")`,
|
|
59
|
+
)
|
|
60
|
+
.join("\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// nav-label-keys.ts — the set of `nav` message keys the site chrome resolves
|
|
2
|
+
// (#10045).
|
|
3
|
+
//
|
|
4
|
+
// The header and footer render translated strings, but `nav-config.ts`
|
|
5
|
+
// carries only message keys. Every key is looked up in the `nav` namespace by
|
|
6
|
+
// name — there is no hand-written list of allowed keys — so adding a page to
|
|
7
|
+
// the nav is one entry in `nav-config.ts` plus its text in
|
|
8
|
+
// `messages/<locale>.json` (or the site overlay, `messages/site/<locale>.json`).
|
|
9
|
+
// `scripts/check-nav-labels.ts` fails the build when a key used here is
|
|
10
|
+
// missing from a locale's catalog.
|
|
11
|
+
|
|
12
|
+
import { footerColumns, navItems } from "./nav-config";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every field on a nav-config entry that names a `nav` message key:
|
|
16
|
+
* `labelKey` (an item or action), `headingKey` (a flyout group or footer
|
|
17
|
+
* column) and `descKey` (a flyout feature's description). These are the only
|
|
18
|
+
* three key-bearing fields in `@working-theory/ui`'s chrome types.
|
|
19
|
+
*/
|
|
20
|
+
const KEY_FIELDS: ReadonlySet<string> = new Set([
|
|
21
|
+
"labelKey",
|
|
22
|
+
"headingKey",
|
|
23
|
+
"descKey",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `nav` keys the layout itself resolves by name, beside the ones
|
|
28
|
+
* `nav-config.ts` declares: the session actions, the centred footer's FAQ
|
|
29
|
+
* link and the menu controls.
|
|
30
|
+
*/
|
|
31
|
+
export const LAYOUT_NAV_KEYS: readonly string[] = [
|
|
32
|
+
"account",
|
|
33
|
+
"signIn",
|
|
34
|
+
"signOut",
|
|
35
|
+
"signUp",
|
|
36
|
+
"faq",
|
|
37
|
+
"openMenu",
|
|
38
|
+
"closeMenu",
|
|
39
|
+
"moreLinks",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Every key-bearing field value in the given nav configs, at any depth. */
|
|
43
|
+
export function collectNavLabelKeys(
|
|
44
|
+
...configs: readonly unknown[]
|
|
45
|
+
): Set<string> {
|
|
46
|
+
const keys = new Set<string>();
|
|
47
|
+
const walk = (value: unknown): void => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
for (const entry of value) walk(entry);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value !== "object" || value === null) return;
|
|
53
|
+
for (const [field, child] of Object.entries(value)) {
|
|
54
|
+
if (KEY_FIELDS.has(field)) {
|
|
55
|
+
if (typeof child === "string") keys.add(child);
|
|
56
|
+
} else {
|
|
57
|
+
walk(child);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
for (const config of configs) walk(config);
|
|
62
|
+
return keys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The string keys of a message catalog's `nav` namespace. */
|
|
66
|
+
export function navMessageKeys(catalog: unknown): string[] {
|
|
67
|
+
if (typeof catalog !== "object" || catalog === null) return [];
|
|
68
|
+
const nav = (catalog as Record<string, unknown>)["nav"];
|
|
69
|
+
if (typeof nav !== "object" || nav === null) return [];
|
|
70
|
+
return Object.entries(nav)
|
|
71
|
+
.filter(([, value]) => typeof value === "string")
|
|
72
|
+
.map(([key]) => key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every `nav` key the site chrome uses: `nav-config.ts`'s keys and the layout's own. */
|
|
76
|
+
export function usedNavLabelKeys(): Set<string> {
|
|
77
|
+
return new Set([...collectNavLabelKeys(navItems, footerColumns), ...LAYOUT_NAV_KEYS]);
|
|
78
|
+
}
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
* is success; a 503 `lead_capture_not_configured` or an unreachable API
|
|
33
33
|
* (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
|
|
34
34
|
* route) it posts `leads.subscribe`, as before.
|
|
35
|
+
*
|
|
36
|
+
* The bot check (#9255 D4): each write path runs `runBotCheck` after its
|
|
37
|
+
* consent refusal and immediately before its hub call, so one signup is
|
|
38
|
+
* checked exactly once. A bot gets the generic error and a check that cannot
|
|
39
|
+
* decide `unavailable`; neither is sent.
|
|
35
40
|
*/
|
|
36
41
|
|
|
37
42
|
import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
@@ -41,8 +46,9 @@ import { cookies, headers } from "next/headers";
|
|
|
41
46
|
import { getLocale } from "next-intl/server";
|
|
42
47
|
import { z } from "zod";
|
|
43
48
|
|
|
44
|
-
import {
|
|
49
|
+
import { clientIpFromHeaders, takeVisitorLimit } from "../src/lib/rate-limit";
|
|
45
50
|
|
|
51
|
+
import { runBotCheck } from "./bot-check";
|
|
46
52
|
import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
|
|
47
53
|
import { inPageView, readServedMagnet } from "./in-page-magnet";
|
|
48
54
|
|
|
@@ -54,6 +60,8 @@ const RATE_LIMITED = "Too many signups from here. Please try again in a few minu
|
|
|
54
60
|
// spend the bucket every visitor shares.
|
|
55
61
|
const RATE_LIMIT_MAX_CALLS = 30;
|
|
56
62
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
63
|
+
/** Outside a request scope there is no address: the shared bucket. */
|
|
64
|
+
const NO_HEADERS = { get: () => null };
|
|
57
65
|
|
|
58
66
|
/** The form's one field; zod's default message is the field error shown. */
|
|
59
67
|
const newsletterFormSchema = z.object({ email: z.string().email() });
|
|
@@ -73,6 +81,18 @@ function isApiUnreachable(res: { status: number }): boolean {
|
|
|
73
81
|
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
/** #9255 D4 — the spoke edge's bot check; `null` to go on to the write. */
|
|
85
|
+
async function botRefusal(): Promise<LeadCaptureFormState | null> {
|
|
86
|
+
const botCheck = await runBotCheck({
|
|
87
|
+
source: "bot-check",
|
|
88
|
+
target: "subscribeNewsletterAction",
|
|
89
|
+
scope: "write",
|
|
90
|
+
});
|
|
91
|
+
if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
|
|
92
|
+
if (botCheck === "unavailable") return { kind: "unavailable" };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
76
96
|
/**
|
|
77
97
|
* The signup through the served newsletter magnet, or `null` when it does not
|
|
78
98
|
* map (absent, draft, a Free build with no flow route, or not a one-field
|
|
@@ -102,6 +122,8 @@ async function submitThroughMagnet(
|
|
|
102
122
|
for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
|
|
103
123
|
}
|
|
104
124
|
const payload = { stepKey: view.stepKey, values, locale };
|
|
125
|
+
const refused = await botRefusal();
|
|
126
|
+
if (refused !== null) return refused;
|
|
105
127
|
try {
|
|
106
128
|
const res = init
|
|
107
129
|
? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
|
|
@@ -142,15 +164,15 @@ export async function subscribeNewsletterAction(
|
|
|
142
164
|
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
143
165
|
}
|
|
144
166
|
|
|
145
|
-
// No readable address:
|
|
146
|
-
//
|
|
147
|
-
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
167
|
+
// No readable address: the shared, wider bucket, never a skip (#9967 D1,
|
|
168
|
+
// the contact action's rule).
|
|
148
169
|
if (
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
!takeVisitorLimit("newsletter", requestHeaders ?? NO_HEADERS, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
171
|
+
.allowed
|
|
151
172
|
) {
|
|
152
173
|
return { kind: "error", message: RATE_LIMITED };
|
|
153
174
|
}
|
|
175
|
+
const visitor = clientIpFromHeaders(requestHeaders ?? NO_HEADERS);
|
|
154
176
|
|
|
155
177
|
let init: { headers: Record<string, string> } | undefined;
|
|
156
178
|
if (requestHeaders) {
|
|
@@ -179,6 +201,9 @@ export async function subscribeNewsletterAction(
|
|
|
179
201
|
// The Free path renders the seeded config, whose consent box gates the post.
|
|
180
202
|
if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
181
203
|
|
|
204
|
+
const refused = await botRefusal();
|
|
205
|
+
if (refused !== null) return refused;
|
|
206
|
+
|
|
182
207
|
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
183
208
|
const body = { siteId, source: "newsletter", email: parsed.data.email };
|
|
184
209
|
try {
|