create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// signed-out-gate.generated.tsx — what the (authed) group shows a signed-out visitor.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology and sign-in mode (#7894).
|
|
4
|
+
// Do not edit: every scaffold rewrites it. A hub renders its own sign-in —
|
|
5
|
+
// the operator gate, or the account area's member gate; a spoke that signs in
|
|
6
|
+
// through its hub (`--hub-sign-in`) renders the hub-session gate.
|
|
7
|
+
import { localeHref } from "@working-theory/ui";
|
|
8
|
+
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
9
|
+
import type { AuthPanelProvider } from "@working-theory/ui/sections";
|
|
10
|
+
import { readLocales } from "@working-theory/web-i18n";
|
|
11
|
+
import { getTranslations } from "next-intl/server";
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
15
|
+
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
16
|
+
import { isAccountPath } from "../../../lib/auth-routes";
|
|
17
|
+
|
|
18
|
+
import { SignInGate } from "./sign-in-gate";
|
|
19
|
+
|
|
20
|
+
const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
|
|
21
|
+
|
|
22
|
+
export interface SignedOutGateProps {
|
|
23
|
+
locale: string;
|
|
24
|
+
/** The incoming request's headers (`x-pathname` names the requested page). */
|
|
25
|
+
requestHeaders: Headers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function SignedOutGate({
|
|
29
|
+
locale,
|
|
30
|
+
requestHeaders,
|
|
31
|
+
}: SignedOutGateProps): Promise<ReactElement> {
|
|
32
|
+
// No `x-pathname`, no locale read: a render outside the middleware keeps
|
|
33
|
+
// the operator gate without touching the locale config.
|
|
34
|
+
const pathname = requestHeaders.get("x-pathname");
|
|
35
|
+
const isAccount =
|
|
36
|
+
pathname !== null && isAccountPath(pathname, readLocales());
|
|
37
|
+
const t = await getTranslations({
|
|
38
|
+
locale,
|
|
39
|
+
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
40
|
+
});
|
|
41
|
+
const active = getActiveProviderIds();
|
|
42
|
+
const emailLink = active.includes("magic-link")
|
|
43
|
+
? { text: await resolveSignInCardText(locale) }
|
|
44
|
+
: undefined;
|
|
45
|
+
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
46
|
+
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
47
|
+
);
|
|
48
|
+
const signInPath = localeHref(locale, "/sign-in");
|
|
49
|
+
|
|
50
|
+
if (isAccount && providers.length === 0 && !emailLink) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
|
|
54
|
+
data-testid="account-sign-in-gate"
|
|
55
|
+
>
|
|
56
|
+
<Heading level={1} visual={4}>
|
|
57
|
+
{t("emptyHeadline")}
|
|
58
|
+
</Heading>
|
|
59
|
+
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
60
|
+
{t("emptyDescription")}
|
|
61
|
+
</Text>
|
|
62
|
+
{/* With no sign-in method on, /sign-in has nothing for a member
|
|
63
|
+
either, so the one action goes back to the site. Outline
|
|
64
|
+
action, 44px on a phone and 36px from md up. */}
|
|
65
|
+
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
66
|
+
<Link
|
|
67
|
+
href={localeHref(locale, "/")}
|
|
68
|
+
variant="bare"
|
|
69
|
+
data-testid="account-sign-in-gate-home"
|
|
70
|
+
>
|
|
71
|
+
{t("homeLink")}
|
|
72
|
+
</Link>
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<SignInGate
|
|
80
|
+
labels={{
|
|
81
|
+
emptyHeadline: t("emptyHeadline"),
|
|
82
|
+
emptyDescription: t("emptyDescription"),
|
|
83
|
+
signInLinkLabel: t("signInLink"),
|
|
84
|
+
}}
|
|
85
|
+
signInPath={signInPath}
|
|
86
|
+
providers={providers}
|
|
87
|
+
{...(emailLink ? { emailLink } : {})}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -51,6 +51,9 @@ jest.mock("../../../../../lib/paid-sections.generated", () => {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
jest.mock("@working-theory/ui", () => ({
|
|
54
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
55
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
56
|
+
React.createElement("div", null, children),
|
|
54
57
|
// #7521 — the rest of the real barrel, so the listing extension finds
|
|
55
58
|
// `localeHref`; the two below still override.
|
|
56
59
|
...jest.requireActual("@working-theory/ui"),
|
|
@@ -24,6 +24,9 @@ jest.mock("next/navigation", () => ({
|
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
26
|
jest.mock("@working-theory/ui", () => ({
|
|
27
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
28
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
29
|
+
React.createElement("div", null, children),
|
|
27
30
|
Heading: ({
|
|
28
31
|
children,
|
|
29
32
|
level,
|
|
@@ -19,6 +19,9 @@ import { render, screen } from "@testing-library/react";
|
|
|
19
19
|
|
|
20
20
|
// ── @working-theory/ui mock ─────────────────────────────────────────────────
|
|
21
21
|
jest.mock("@working-theory/ui", () => ({
|
|
22
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
23
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
24
|
+
React.createElement("div", null, children),
|
|
22
25
|
// #4446/#4447: renderPage's own fallback/empty-state title composes Heading.
|
|
23
26
|
Heading: ({
|
|
24
27
|
children,
|
|
@@ -63,14 +66,22 @@ jest.mock("@working-theory/ui", () => ({
|
|
|
63
66
|
"data-fallback-email": props.fallbackEmail ?? "",
|
|
64
67
|
"data-terms-href": props.termsHref,
|
|
65
68
|
},
|
|
66
|
-
React.createElement(
|
|
69
|
+
React.createElement(
|
|
70
|
+
"button",
|
|
71
|
+
{ "data-testid": "contact-submit" },
|
|
72
|
+
props.submitLabel ?? "Send",
|
|
73
|
+
),
|
|
67
74
|
),
|
|
68
75
|
ContactFallback: (props: { email?: string; text?: { noEmail?: string } }) =>
|
|
69
76
|
React.createElement(
|
|
70
77
|
"div",
|
|
71
78
|
{ "data-testid": "contact-fallback" },
|
|
72
79
|
props.email
|
|
73
|
-
? React.createElement(
|
|
80
|
+
? React.createElement(
|
|
81
|
+
"a",
|
|
82
|
+
{ href: `mailto:${props.email}` },
|
|
83
|
+
props.email,
|
|
84
|
+
)
|
|
74
85
|
: props.text?.noEmail,
|
|
75
86
|
),
|
|
76
87
|
}));
|
|
@@ -111,8 +122,16 @@ import { readSiteContactEmail } from "../site-contact-email";
|
|
|
111
122
|
const mockCaptureStatus = leads.captureStatus as jest.Mock;
|
|
112
123
|
const mockContactEmail = readSiteContactEmail as jest.Mock;
|
|
113
124
|
|
|
114
|
-
const ACCEPTING = {
|
|
115
|
-
|
|
125
|
+
const ACCEPTING = {
|
|
126
|
+
ok: true,
|
|
127
|
+
status: 200,
|
|
128
|
+
body: { ok: true, data: { contact: true, newsletter: false } },
|
|
129
|
+
};
|
|
130
|
+
const REFUSING = {
|
|
131
|
+
ok: true,
|
|
132
|
+
status: 200,
|
|
133
|
+
body: { ok: true, data: { contact: false, newsletter: false } },
|
|
134
|
+
};
|
|
116
135
|
|
|
117
136
|
const source = {
|
|
118
137
|
frontmatter: {
|
|
@@ -124,7 +143,11 @@ const source = {
|
|
|
124
143
|
body: "",
|
|
125
144
|
};
|
|
126
145
|
|
|
127
|
-
const splitHero = {
|
|
146
|
+
const splitHero = {
|
|
147
|
+
id: "hero",
|
|
148
|
+
type: "hero",
|
|
149
|
+
data: { variant: "split", headline: "Hi" },
|
|
150
|
+
};
|
|
128
151
|
const contactFormSection = {
|
|
129
152
|
id: "contact-form",
|
|
130
153
|
type: "contact-form",
|
|
@@ -140,7 +163,9 @@ const contactFormSection = {
|
|
|
140
163
|
async function renderWith(sections: unknown[] | null) {
|
|
141
164
|
const element = await ContactPage({
|
|
142
165
|
params: Promise.resolve({ locale: "en" }),
|
|
143
|
-
getMdxSource: jest
|
|
166
|
+
getMdxSource: jest
|
|
167
|
+
.fn()
|
|
168
|
+
.mockResolvedValue(sections === null ? null : source),
|
|
144
169
|
getPageSections: jest.fn().mockResolvedValue(sections ?? []),
|
|
145
170
|
});
|
|
146
171
|
return render(element);
|
|
@@ -163,7 +188,7 @@ describe("ContactPage — snapshot regression", () => {
|
|
|
163
188
|
it("populated state renders deterministic HTML (renderPage sections + the form after it)", async () => {
|
|
164
189
|
const { container } = await renderWith([]);
|
|
165
190
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
166
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><h1>Contact Us</h1><p data-testid="contact-description">Get in touch.</p><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
|
|
191
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><div><h1>Contact Us</h1><p data-testid="contact-description" class="mt-block-tight text-lead">Get in touch.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
|
|
167
192
|
);
|
|
168
193
|
});
|
|
169
194
|
});
|
|
@@ -184,7 +209,9 @@ describe("ContactPage — placement (#6730 D8)", () => {
|
|
|
184
209
|
const { container } = await renderWith([splitHero]);
|
|
185
210
|
|
|
186
211
|
expect(
|
|
187
|
-
container.querySelector(
|
|
212
|
+
container.querySelector(
|
|
213
|
+
"[data-section-id='hero'] [data-testid='lead-capture-form']",
|
|
214
|
+
),
|
|
188
215
|
).not.toBeNull();
|
|
189
216
|
expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
|
|
190
217
|
});
|
|
@@ -194,7 +221,9 @@ describe("ContactPage — placement (#6730 D8)", () => {
|
|
|
194
221
|
{ id: "faq", type: "faq", data: { items: [] } },
|
|
195
222
|
]);
|
|
196
223
|
|
|
197
|
-
expect(
|
|
224
|
+
expect(
|
|
225
|
+
container.querySelector("main [data-testid='lead-capture-form']"),
|
|
226
|
+
).toBeNull();
|
|
198
227
|
expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
|
|
199
228
|
});
|
|
200
229
|
|
|
@@ -216,7 +245,9 @@ describe("ContactPage — no lead destination (#6730 D8)", () => {
|
|
|
216
245
|
await renderWith([]);
|
|
217
246
|
|
|
218
247
|
const fallback = screen.getByTestId("contact-fallback");
|
|
219
|
-
expect(
|
|
248
|
+
expect(
|
|
249
|
+
fallback.querySelector("a[href='mailto:hi@example.com']"),
|
|
250
|
+
).not.toBeNull();
|
|
220
251
|
expect(screen.queryByTestId("contact-submit")).toBeNull();
|
|
221
252
|
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
222
253
|
});
|
|
@@ -239,14 +270,35 @@ describe("ContactPage — no lead destination (#6730 D8)", () => {
|
|
|
239
270
|
const { container } = await renderWith([contactFormSection]);
|
|
240
271
|
|
|
241
272
|
expect(
|
|
242
|
-
container.querySelector(
|
|
273
|
+
container.querySelector(
|
|
274
|
+
"[data-section-id='contact-form'] [data-testid='contact-fallback']",
|
|
275
|
+
),
|
|
243
276
|
).not.toBeNull();
|
|
244
277
|
});
|
|
245
278
|
|
|
246
279
|
it.each([
|
|
247
|
-
[
|
|
248
|
-
|
|
249
|
-
|
|
280
|
+
[
|
|
281
|
+
"rejects (unreachable API)",
|
|
282
|
+
() => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED")),
|
|
283
|
+
],
|
|
284
|
+
[
|
|
285
|
+
"404 (older API)",
|
|
286
|
+
() =>
|
|
287
|
+
mockCaptureStatus.mockResolvedValue({
|
|
288
|
+
ok: false,
|
|
289
|
+
status: 404,
|
|
290
|
+
body: {},
|
|
291
|
+
}),
|
|
292
|
+
],
|
|
293
|
+
[
|
|
294
|
+
"5xx",
|
|
295
|
+
() =>
|
|
296
|
+
mockCaptureStatus.mockResolvedValue({
|
|
297
|
+
ok: false,
|
|
298
|
+
status: 503,
|
|
299
|
+
body: {},
|
|
300
|
+
}),
|
|
301
|
+
],
|
|
250
302
|
])("status %s → the form renders", async (_label, arrange) => {
|
|
251
303
|
arrange();
|
|
252
304
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `readSiteContactLines` (#7742 D4 / AC-1) — the closing slide's contact
|
|
3
|
-
* lines, read from a fixture `_site.mdx` through `AILK_CONTENT_ROOT`.
|
|
3
|
+
* lines, read from a fixture `_site.mdx` through `AILK_CONTENT_ROOT`. The
|
|
4
|
+
* locality line is read through the typed `SiteIdentitySchema` (#7824), so
|
|
5
|
+
* the fixtures declare complete, valid identities.
|
|
4
6
|
*/
|
|
5
7
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { tmpdir } from "node:os";
|
|
@@ -26,6 +28,18 @@ function writeSite(frontmatter: string): void {
|
|
|
26
28
|
writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n`);
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/** A complete PostalAddress, with the locality and region under test. */
|
|
32
|
+
function addressYaml(indent: string, locality: string, region: string): string[] {
|
|
33
|
+
return [
|
|
34
|
+
`${indent}address:`,
|
|
35
|
+
`${indent} streetAddress: 1 Main St`,
|
|
36
|
+
`${indent} addressLocality: ${locality}`,
|
|
37
|
+
`${indent} addressRegion: ${region}`,
|
|
38
|
+
`${indent} postalCode: '94105'`,
|
|
39
|
+
`${indent} addressCountry: US`,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
describe("readSiteContactLines (#7742 D4)", () => {
|
|
30
44
|
it("a LocalBusiness with an email gives its locality, then the mailto line", () => {
|
|
31
45
|
writeSite(
|
|
@@ -34,13 +48,10 @@ describe("readSiteContactLines (#7742 D4)", () => {
|
|
|
34
48
|
"siteIdentity:",
|
|
35
49
|
" kind: LocalBusiness",
|
|
36
50
|
" name: Acme",
|
|
51
|
+
" url: https://acme.test",
|
|
52
|
+
" telephone: '+14155550100'",
|
|
37
53
|
" email: hello@acme.test",
|
|
38
|
-
"
|
|
39
|
-
" streetAddress: 1 Main St",
|
|
40
|
-
" addressLocality: San Francisco",
|
|
41
|
-
" addressRegion: CA",
|
|
42
|
-
" postalCode: '94105'",
|
|
43
|
-
" addressCountry: US",
|
|
54
|
+
...addressYaml(" ", "San Francisco", "CA"),
|
|
44
55
|
].join("\n"),
|
|
45
56
|
);
|
|
46
57
|
expect(readSiteContactLines()).toEqual([
|
|
@@ -49,17 +60,100 @@ describe("readSiteContactLines (#7742 D4)", () => {
|
|
|
49
60
|
]);
|
|
50
61
|
});
|
|
51
62
|
|
|
63
|
+
it("a Person that declares an address gives its locality line (#7791, #7824)", () => {
|
|
64
|
+
writeSite(
|
|
65
|
+
[
|
|
66
|
+
"siteName: Wendi Henry",
|
|
67
|
+
"contactEmail: wendi@acme.test",
|
|
68
|
+
"siteIdentity:",
|
|
69
|
+
" kind: Person",
|
|
70
|
+
" name: Wendi Henry",
|
|
71
|
+
" url: https://wendi.test",
|
|
72
|
+
...addressYaml(" ", "Denver", "CO"),
|
|
73
|
+
].join("\n"),
|
|
74
|
+
);
|
|
75
|
+
expect(readSiteContactLines()).toEqual([
|
|
76
|
+
{ label: "Denver, CO" },
|
|
77
|
+
{ label: "wendi@acme.test", href: "mailto:wendi@acme.test" },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("a Person's homeLocation.address is not read — the schema accepts only `address` (#7824)", () => {
|
|
82
|
+
writeSite(
|
|
83
|
+
[
|
|
84
|
+
"siteName: Wendi Henry",
|
|
85
|
+
"siteIdentity:",
|
|
86
|
+
" kind: Person",
|
|
87
|
+
" name: Wendi Henry",
|
|
88
|
+
" url: https://wendi.test",
|
|
89
|
+
" homeLocation:",
|
|
90
|
+
...addressYaml(" ", "Denver", "CO"),
|
|
91
|
+
].join("\n"),
|
|
92
|
+
);
|
|
93
|
+
expect(readSiteContactLines()).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("a Person address that fails the PostalAddress schema gives no locality line (#7824)", () => {
|
|
97
|
+
writeSite(
|
|
98
|
+
[
|
|
99
|
+
"siteName: Wendi Henry",
|
|
100
|
+
"siteIdentity:",
|
|
101
|
+
" kind: Person",
|
|
102
|
+
" name: Wendi Henry",
|
|
103
|
+
" url: https://wendi.test",
|
|
104
|
+
" address:",
|
|
105
|
+
" addressLocality: Denver",
|
|
106
|
+
" addressRegion: CO",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
);
|
|
109
|
+
expect(readSiteContactLines()).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("a Person with no address gives no locality line (#7791)", () => {
|
|
113
|
+
writeSite(
|
|
114
|
+
[
|
|
115
|
+
"siteName: Wendi Henry",
|
|
116
|
+
"siteIdentity:",
|
|
117
|
+
" kind: Person",
|
|
118
|
+
" name: Wendi Henry",
|
|
119
|
+
" url: https://wendi.test",
|
|
120
|
+
].join("\n"),
|
|
121
|
+
);
|
|
122
|
+
expect(readSiteContactLines()).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("an Organization never gives a locality line, even with an address (#7791 sibling kinds)", () => {
|
|
126
|
+
writeSite(
|
|
127
|
+
[
|
|
128
|
+
"siteName: Acme",
|
|
129
|
+
"siteIdentity:",
|
|
130
|
+
" kind: Organization",
|
|
131
|
+
" name: Acme",
|
|
132
|
+
" url: https://acme.test",
|
|
133
|
+
...addressYaml(" ", "Denver", "CO"),
|
|
134
|
+
].join("\n"),
|
|
135
|
+
);
|
|
136
|
+
expect(readSiteContactLines()).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
52
139
|
it("an Organization with contactEmail gives one line", () => {
|
|
53
140
|
writeSite(
|
|
54
|
-
[
|
|
55
|
-
"
|
|
56
|
-
|
|
141
|
+
[
|
|
142
|
+
"siteName: Acme",
|
|
143
|
+
"contactEmail: team@acme.test",
|
|
144
|
+
"siteIdentity:",
|
|
145
|
+
" kind: Organization",
|
|
146
|
+
" name: Acme",
|
|
147
|
+
" url: https://acme.test",
|
|
148
|
+
].join("\n"),
|
|
57
149
|
);
|
|
58
150
|
expect(readSiteContactLines()).toEqual([{ label: "team@acme.test", href: "mailto:team@acme.test" }]);
|
|
59
151
|
});
|
|
60
152
|
|
|
61
153
|
it("neither gives no lines", () => {
|
|
62
|
-
writeSite(
|
|
154
|
+
writeSite(
|
|
155
|
+
["siteName: Acme", "siteIdentity:", " kind: Organization", " name: Acme", " url: https://acme.test"].join("\n"),
|
|
156
|
+
);
|
|
63
157
|
expect(readSiteContactLines()).toEqual([]);
|
|
64
158
|
});
|
|
65
159
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The deck's closing-slide contact lines (#7742 D4,
|
|
3
3
|
* docs/specs/7742-minimalism-b4-chrome-and-system-spec.md), from data the site
|
|
4
|
-
* already publishes
|
|
4
|
+
* already publishes:
|
|
5
5
|
*
|
|
6
|
-
* 1. a `LocalBusiness` identity's `address.addressLocality`
|
|
7
|
-
* `address.addressRegion`, joined by ", " ("San Francisco, CA")
|
|
6
|
+
* 1. a `LocalBusiness` or `Person` identity's `address.addressLocality`
|
|
7
|
+
* and `address.addressRegion`, joined by ", " ("San Francisco, CA") —
|
|
8
|
+
* read through the typed `SiteIdentitySchema` (#7824), so an invalid
|
|
9
|
+
* identity or address gives no line rather than a half-trusted one;
|
|
8
10
|
* 2. the published contact email (`readSiteContactEmail`, the address
|
|
9
11
|
* `/contact` shows), as a `mailto:` link.
|
|
10
12
|
*
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
import { readFileSync } from "node:fs";
|
|
18
20
|
import { join } from "node:path";
|
|
19
21
|
|
|
22
|
+
import { SiteIdentitySchema } from "@working-theory/site-config/schema";
|
|
20
23
|
import { parse as parseYaml } from "yaml";
|
|
21
24
|
|
|
22
25
|
import { resolveContentRoot } from "../../../lib/site-brand";
|
|
@@ -34,17 +37,18 @@ function nonEmpty(value: unknown): string | undefined {
|
|
|
34
37
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/** The
|
|
40
|
+
/** The locality line of a LocalBusiness or Person identity, or `undefined`. */
|
|
38
41
|
function readLocalityLine(): string | undefined {
|
|
39
42
|
const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
|
|
40
43
|
const match = FRONTMATTER_RE.exec(raw);
|
|
41
44
|
if (!match) return undefined;
|
|
42
45
|
const fm: unknown = parseYaml(match[1] ?? "");
|
|
43
46
|
if (!fm || typeof fm !== "object") return undefined;
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const parsed = SiteIdentitySchema.safeParse((fm as { siteIdentity?: unknown }).siteIdentity);
|
|
48
|
+
if (!parsed.success) return undefined;
|
|
49
|
+
const identity = parsed.data;
|
|
50
|
+
if (identity.kind !== "LocalBusiness" && identity.kind !== "Person") return undefined;
|
|
51
|
+
const parts = [nonEmpty(identity.address?.addressLocality), nonEmpty(identity.address?.addressRegion)].filter(
|
|
48
52
|
(part): part is string => part !== undefined,
|
|
49
53
|
);
|
|
50
54
|
return parts.length > 0 ? parts.join(", ") : undefined;
|