create-ailk 0.20.0 → 0.22.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 +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A person's own page (#8650 D3). Reads the Person MDX for the given slug and
|
|
2
|
+
// emits a JSON-LD <script>. Unknown slugs 404 through renderPage. Renders
|
|
3
|
+
// `sections[]` when the frontmatter carries them (the /for composed
|
|
4
|
+
// pattern), MDX prose otherwise.
|
|
5
|
+
import {
|
|
6
|
+
getMdxSource as defaultGetMdxSource,
|
|
7
|
+
renderPage,
|
|
8
|
+
renderPageMetadata,
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
10
|
+
import type { Metadata } from "next";
|
|
11
|
+
import type { ReactElement } from "react";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
15
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function generateMetadata({
|
|
19
|
+
params,
|
|
20
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
21
|
+
const { locale, slug } = await params;
|
|
22
|
+
return renderPageMetadata({ type: "Person", slug, locale });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function Page({
|
|
26
|
+
params,
|
|
27
|
+
getMdxSource = defaultGetMdxSource,
|
|
28
|
+
}: Props): Promise<ReactElement> {
|
|
29
|
+
const { locale, slug } = await params;
|
|
30
|
+
// Read once to pick the body mode. A null source still goes to renderPage,
|
|
31
|
+
// which owns the unknown-slug branch (#7537).
|
|
32
|
+
const source = await getMdxSource("Person", slug, locale);
|
|
33
|
+
|
|
34
|
+
const composed =
|
|
35
|
+
source !== null &&
|
|
36
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
37
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
|
|
38
|
+
|
|
39
|
+
return renderPage({
|
|
40
|
+
type: "Person",
|
|
41
|
+
slug,
|
|
42
|
+
locale,
|
|
43
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
44
|
+
getMdxSource: async () => source,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The about routes (#8650 spec, AC-1 through AC-4):
|
|
3
|
+
*
|
|
4
|
+
* - AC-1: `/about/<slug>` renders a `ProfilePage` whose `mainEntity` is a
|
|
5
|
+
* `Person`, valid under the schema validator, with an About breadcrumb; an
|
|
6
|
+
* unknown slug 404s through `renderPage`.
|
|
7
|
+
* - AC-2: `/about` lists every `Person` page as a generated `team` section,
|
|
8
|
+
* each member linking to its own `/about/<slug>` page, before a trailing
|
|
9
|
+
* `cta` section.
|
|
10
|
+
* - AC-3: the routes map, sitemap and `llms.txt` all advertise a `Person`
|
|
11
|
+
* page at `/about/<slug>`, never a bare `/<slug>`.
|
|
12
|
+
* - AC-4: with zero `Person` pages, `/about` renders byte-identical to
|
|
13
|
+
* calling `renderPage` directly (C5 — a fresh scaffold is such a site).
|
|
14
|
+
*
|
|
15
|
+
* MDX sources are inline stubs (no fs reads) for AC-2/AC-4, and a real
|
|
16
|
+
* `createMdxAdapter` over a temp content root for AC-1 (the spec's own
|
|
17
|
+
* method) — both run unchanged inside a scaffold.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { render } from "@testing-library/react";
|
|
24
|
+
import { createMdxAdapter } from "@working-theory/content-adapters";
|
|
25
|
+
import * as pageRenderer from "@working-theory/page-renderer";
|
|
26
|
+
import { validatePageGraph } from "@working-theory/schema-validator";
|
|
27
|
+
import React from "react";
|
|
28
|
+
|
|
29
|
+
jest.mock("next/navigation", () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
notFound: jest.fn(() => {
|
|
32
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
jest.mock("next-mdx-remote/rsc", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
MDXRemote: ({ source }: { source: string }) =>
|
|
39
|
+
React.createElement("div", { "data-testid": "mdx-rendered" }, source),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import AboutPage from "../page";
|
|
43
|
+
import AboutPersonPage from "../[slug]/page";
|
|
44
|
+
import { createSitemapHandler } from "../../../sitemap.xml/route";
|
|
45
|
+
import { createLlmsHandler } from "../../../llms.txt/route";
|
|
46
|
+
import type { ContentAdapter } from "@working-theory/content-adapters";
|
|
47
|
+
|
|
48
|
+
type JsonLd = Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
function jsonLdScripts(container: HTMLElement): JsonLd[] {
|
|
51
|
+
return [
|
|
52
|
+
...container.querySelectorAll("script[type='application/ld+json']"),
|
|
53
|
+
].map((s) => JSON.parse(s.textContent ?? "") as JsonLd);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("about/[slug] — a person's own page (AC-1)", () => {
|
|
57
|
+
let root: string;
|
|
58
|
+
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
root = mkdtempSync(join(tmpdir(), "ailk-8650-"));
|
|
61
|
+
mkdirSync(join(root, "en", "Person"), { recursive: true });
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(root, "en", "Person", "sam-henry.mdx"),
|
|
64
|
+
[
|
|
65
|
+
"---",
|
|
66
|
+
"type: Person",
|
|
67
|
+
"slug: sam-henry",
|
|
68
|
+
"title: Sam Henry",
|
|
69
|
+
"description: Co-founder and partner.",
|
|
70
|
+
"schema:",
|
|
71
|
+
" jobTitle: Partner",
|
|
72
|
+
"---",
|
|
73
|
+
"",
|
|
74
|
+
"Sam Henry, co-founder and partner.",
|
|
75
|
+
"",
|
|
76
|
+
].join("\n"),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(() => {
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("emits one ProfilePage graph whose Person carries @id #person, valid under the schema validator", async () => {
|
|
85
|
+
const adapter = createMdxAdapter({ contentRoot: root, defaultLocale: "en" });
|
|
86
|
+
const element = await AboutPersonPage({
|
|
87
|
+
params: Promise.resolve({ locale: "en", slug: "sam-henry" }),
|
|
88
|
+
getMdxSource: (type, slug, locale) =>
|
|
89
|
+
adapter.getPageSource(type, slug, locale),
|
|
90
|
+
});
|
|
91
|
+
const { container } = render(element);
|
|
92
|
+
const scripts = jsonLdScripts(container);
|
|
93
|
+
expect(scripts).toHaveLength(1);
|
|
94
|
+
const graph = scripts[0]!["@graph"] as JsonLd[];
|
|
95
|
+
expect(validatePageGraph(graph as never, "Person")).toEqual(
|
|
96
|
+
expect.objectContaining({ valid: true }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const profile = graph.find((b) => b["@type"] === "ProfilePage")!;
|
|
100
|
+
const person = profile["mainEntity"] as JsonLd;
|
|
101
|
+
expect(person["@type"]).toBe("Person");
|
|
102
|
+
expect(person["@id"]).toBe("#person");
|
|
103
|
+
expect(person["name"]).toBe("Sam Henry");
|
|
104
|
+
expect(person["jobTitle"]).toBe("Partner");
|
|
105
|
+
|
|
106
|
+
const breadcrumb = graph.find((b) => b["@type"] === "BreadcrumbList")!;
|
|
107
|
+
const items = breadcrumb["itemListElement"] as Array<{ item: string }>;
|
|
108
|
+
expect(items[1]!.item).toMatch(/\/en\/about$/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("404s an unknown slug through renderPage, with no translations namespace", async () => {
|
|
112
|
+
const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
|
|
113
|
+
const adapter = createMdxAdapter({ contentRoot: root, defaultLocale: "en" });
|
|
114
|
+
await expect(
|
|
115
|
+
AboutPersonPage({
|
|
116
|
+
params: Promise.resolve({ locale: "en", slug: "nobody" }),
|
|
117
|
+
getMdxSource: (type, slug, locale) =>
|
|
118
|
+
adapter.getPageSource(type, slug, locale),
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
121
|
+
expect(renderPageSpy).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(
|
|
123
|
+
renderPageSpy.mock.calls[0]![0].translationsNamespace,
|
|
124
|
+
).toBeUndefined();
|
|
125
|
+
renderPageSpy.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const ABOUT_WITH_CTA = {
|
|
130
|
+
frontmatter: {
|
|
131
|
+
type: "AboutPage",
|
|
132
|
+
slug: "about",
|
|
133
|
+
title: "About",
|
|
134
|
+
description: "About us.",
|
|
135
|
+
sections: [
|
|
136
|
+
{
|
|
137
|
+
id: "intro",
|
|
138
|
+
type: "content-block",
|
|
139
|
+
data: { headline: "About us", body: "We build things." },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "closing",
|
|
143
|
+
type: "cta",
|
|
144
|
+
data: {
|
|
145
|
+
headline: "Let's talk",
|
|
146
|
+
primaryCta: { label: "Contact us", href: "/contact" },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
body: "",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const SAM_SOURCE = {
|
|
155
|
+
frontmatter: {
|
|
156
|
+
type: "Person",
|
|
157
|
+
slug: "sam-henry",
|
|
158
|
+
title: "Sam Henry",
|
|
159
|
+
description: "Co-founder and partner.",
|
|
160
|
+
schema: { jobTitle: "Partner" },
|
|
161
|
+
},
|
|
162
|
+
body: "",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const WENDI_SOURCE = {
|
|
166
|
+
frontmatter: {
|
|
167
|
+
type: "Person",
|
|
168
|
+
slug: "wendi-henry",
|
|
169
|
+
title: "About Wendi",
|
|
170
|
+
description: "Co-founder and partner.",
|
|
171
|
+
schema: { name: "Wendi Henry" },
|
|
172
|
+
},
|
|
173
|
+
body: "",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
describe("about — the generated people list (AC-2)", () => {
|
|
177
|
+
it("lists every Person page as one team section, before the closing cta, linking to /about/<slug>", async () => {
|
|
178
|
+
const getMdxSource = jest.fn(
|
|
179
|
+
async (type: string, slug: string): Promise<unknown> => {
|
|
180
|
+
if (type === "AboutPage") return ABOUT_WITH_CTA;
|
|
181
|
+
if (type === "Person" && slug === "sam-henry") return SAM_SOURCE;
|
|
182
|
+
if (type === "Person" && slug === "wendi-henry") return WENDI_SOURCE;
|
|
183
|
+
return null;
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
const getMdxSlugs = jest
|
|
187
|
+
.fn()
|
|
188
|
+
.mockResolvedValue(["wendi-henry", "sam-henry", "ghost-member"]);
|
|
189
|
+
|
|
190
|
+
const element = await AboutPage({
|
|
191
|
+
params: Promise.resolve({ locale: "en" }),
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double over a narrower DI surface than the real multi-type loader
|
|
193
|
+
getMdxSource: getMdxSource as any,
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double
|
|
195
|
+
getMdxSlugs: getMdxSlugs as any,
|
|
196
|
+
});
|
|
197
|
+
const { container } = render(element);
|
|
198
|
+
|
|
199
|
+
const cards = container.querySelectorAll('[data-testid="team-member-card"]');
|
|
200
|
+
// The null-source "ghost-member" slug is skipped — two cards, not three.
|
|
201
|
+
expect(cards).toHaveLength(2);
|
|
202
|
+
|
|
203
|
+
const links = container.querySelectorAll('[data-testid^="member-page-link-"]');
|
|
204
|
+
const hrefs = [...links].map((l) => l.getAttribute("href"));
|
|
205
|
+
expect(hrefs).toEqual(["/about/sam-henry", "/about/wendi-henry"]);
|
|
206
|
+
for (const link of links) {
|
|
207
|
+
expect(link.textContent).toBe("View profile");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Wendi's card shows schema.name, not title; Sam's shows title (no
|
|
211
|
+
// schema.name override) with his jobTitle as the role.
|
|
212
|
+
expect(cards[0]!.textContent).toContain("Sam Henry");
|
|
213
|
+
expect(cards[0]!.textContent).toContain("Partner");
|
|
214
|
+
expect(cards[1]!.textContent).toContain("Wendi Henry");
|
|
215
|
+
expect(cards[1]!.textContent).not.toContain("About Wendi");
|
|
216
|
+
expect(cards[1]!.textContent).not.toContain("Partner");
|
|
217
|
+
|
|
218
|
+
// The team section sits before the closing cta section in document order.
|
|
219
|
+
const html = container.innerHTML;
|
|
220
|
+
expect(html.indexOf("team-member-card")).toBeLessThan(
|
|
221
|
+
html.indexOf("Let's talk"),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("about — unchanged with zero Person pages (AC-4, C5)", () => {
|
|
227
|
+
it("renders byte-identical to a direct renderPage call", async () => {
|
|
228
|
+
const aboutSource = {
|
|
229
|
+
frontmatter: {
|
|
230
|
+
type: "AboutPage",
|
|
231
|
+
slug: "about",
|
|
232
|
+
title: "About",
|
|
233
|
+
description: "About us.",
|
|
234
|
+
},
|
|
235
|
+
body: "About us copy.",
|
|
236
|
+
};
|
|
237
|
+
const getMdxSource = jest.fn().mockResolvedValue(aboutSource);
|
|
238
|
+
const getMdxSlugs = jest.fn().mockResolvedValue([]);
|
|
239
|
+
|
|
240
|
+
const element = await AboutPage({
|
|
241
|
+
params: Promise.resolve({ locale: "en" }),
|
|
242
|
+
getMdxSource,
|
|
243
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double over a narrower DI surface than the real multi-type loader
|
|
244
|
+
getMdxSlugs: getMdxSlugs as any,
|
|
245
|
+
});
|
|
246
|
+
const { container } = render(element);
|
|
247
|
+
|
|
248
|
+
const direct = await pageRenderer.renderPage({
|
|
249
|
+
type: "AboutPage",
|
|
250
|
+
slug: "about",
|
|
251
|
+
locale: "en",
|
|
252
|
+
translationsNamespace: "about",
|
|
253
|
+
getMdxSource,
|
|
254
|
+
});
|
|
255
|
+
const { container: directContainer } = render(direct);
|
|
256
|
+
|
|
257
|
+
expect(container.innerHTML).toBe(directContainer.innerHTML);
|
|
258
|
+
expect(
|
|
259
|
+
container.querySelectorAll('[data-testid="team-member-card"]'),
|
|
260
|
+
).toHaveLength(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug> (AC-3)", () => {
|
|
265
|
+
// The route handlers construct a Web `Response`, which jsdom (this file's
|
|
266
|
+
// test environment, needed by the React-rendering describes above) does
|
|
267
|
+
// not implement. A minimal same-shape stand-in, scoped to this describe,
|
|
268
|
+
// is enough for the one method these tests call: `.text()`.
|
|
269
|
+
let originalResponse: unknown;
|
|
270
|
+
|
|
271
|
+
beforeAll(() => {
|
|
272
|
+
originalResponse = (globalThis as { Response?: unknown }).Response;
|
|
273
|
+
class ResponseShim {
|
|
274
|
+
private readonly _body: string;
|
|
275
|
+
constructor(body: string, _init?: unknown) {
|
|
276
|
+
this._body = body;
|
|
277
|
+
}
|
|
278
|
+
async text(): Promise<string> {
|
|
279
|
+
return this._body;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
(globalThis as { Response: unknown }).Response = ResponseShim;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
afterAll(() => {
|
|
286
|
+
(globalThis as { Response?: unknown }).Response = originalResponse;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
function makeStubAdapter(): ContentAdapter {
|
|
290
|
+
// eslint-disable-next-line no-restricted-syntax -- jest.fn() stubs are not assignable to ContentAdapter's generic return types; partial stub is intentional, tracked in #1073
|
|
291
|
+
return {
|
|
292
|
+
listPages: jest.fn().mockImplementation(async (type: string) => {
|
|
293
|
+
if (type === "Person") {
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
type: "Person",
|
|
297
|
+
slug: "sam-henry",
|
|
298
|
+
title: "Sam Henry",
|
|
299
|
+
description: "Co-founder and partner.",
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
return [];
|
|
304
|
+
}),
|
|
305
|
+
listDetail: jest.fn().mockResolvedValue([]),
|
|
306
|
+
getPageBySlug: jest.fn().mockResolvedValue(null),
|
|
307
|
+
getDetailBySlug: jest.fn().mockResolvedValue(null),
|
|
308
|
+
searchPages: jest.fn().mockResolvedValue([]),
|
|
309
|
+
extractStructuredData: jest.fn().mockResolvedValue(null),
|
|
310
|
+
createPage: jest.fn(),
|
|
311
|
+
updatePage: jest.fn(),
|
|
312
|
+
deletePage: jest.fn(),
|
|
313
|
+
getPageVersion: jest.fn(),
|
|
314
|
+
} as unknown as ContentAdapter;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
beforeEach(() => {
|
|
318
|
+
process.env.NEXT_PUBLIC_ENABLED_LOCALES = "en";
|
|
319
|
+
process.env.NEXT_PUBLIC_DEFAULT_LOCALE = "en";
|
|
320
|
+
process.env.NEXT_PUBLIC_BASE_URL = "https://site.example";
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterEach(() => {
|
|
324
|
+
delete process.env.NEXT_PUBLIC_ENABLED_LOCALES;
|
|
325
|
+
delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
|
|
326
|
+
delete process.env.NEXT_PUBLIC_BASE_URL;
|
|
327
|
+
jest.resetAllMocks();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
|
|
331
|
+
const stubAdapter = makeStubAdapter();
|
|
332
|
+
const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter });
|
|
333
|
+
const response = await GET();
|
|
334
|
+
const text = await response.text();
|
|
335
|
+
const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
|
|
336
|
+
(m) => m[1],
|
|
337
|
+
);
|
|
338
|
+
expect(locs).toContain("https://site.example/about/sam-henry");
|
|
339
|
+
expect(locs).not.toContain("https://site.example/sam-henry");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("llms.txt: names a URL ending in /about/sam-henry", async () => {
|
|
343
|
+
const stubAdapter = makeStubAdapter();
|
|
344
|
+
const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter });
|
|
345
|
+
const response = await GET();
|
|
346
|
+
const text = await response.text();
|
|
347
|
+
expect(text).toContain("URL: https://site.example/about/sam-henry");
|
|
348
|
+
expect(text).not.toContain("URL: https://site.example/sam-henry\n");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
// About page. Reads the AboutPage MDX frontmatter, renders sections via
|
|
2
2
|
// SectionRenderer, and emits a JSON-LD <script>.
|
|
3
|
+
//
|
|
4
|
+
// #8650 D4 — `/about` additionally lists every `Person` page in the request's
|
|
5
|
+
// locale as one generated `team` section (the `/alternatives` `withSpokes`
|
|
6
|
+
// precedent, #6878 D9), each member linking to its own `/about/<slug>` page.
|
|
7
|
+
// With zero `Person` pages the parsed sections are returned unchanged, so a
|
|
8
|
+
// fresh scaffold (which seeds no `Person` file) renders exactly as before.
|
|
3
9
|
import type {
|
|
10
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
4
11
|
getMdxSource as defaultGetMdxSource,
|
|
5
12
|
getPageSections as defaultGetPageSections} from "@working-theory/page-renderer";
|
|
6
13
|
import {
|
|
14
|
+
getMdxSlugs as loadMdxSlugs,
|
|
15
|
+
getMdxSource as loadMdxSource,
|
|
16
|
+
getPageSections as loadPageSections,
|
|
7
17
|
renderPage,
|
|
8
18
|
renderPageMetadata
|
|
9
19
|
} from "@working-theory/page-renderer";
|
|
10
20
|
import type { Metadata } from "next";
|
|
21
|
+
import { getTranslations } from "next-intl/server";
|
|
11
22
|
import type { ReactElement } from "react";
|
|
12
23
|
|
|
13
24
|
interface Props {
|
|
14
25
|
params: Promise<{ locale: string }>;
|
|
15
26
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
27
|
getPageSections?: typeof defaultGetPageSections;
|
|
28
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
17
29
|
}
|
|
18
30
|
|
|
31
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
32
|
+
|
|
19
33
|
export async function generateMetadata({
|
|
20
34
|
params,
|
|
21
35
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
@@ -27,14 +41,82 @@ export default async function Page({
|
|
|
27
41
|
params,
|
|
28
42
|
getMdxSource,
|
|
29
43
|
getPageSections,
|
|
44
|
+
getMdxSlugs,
|
|
30
45
|
}: Props): Promise<ReactElement> {
|
|
31
46
|
const { locale } = await params;
|
|
47
|
+
const loadSections = getPageSections ?? loadPageSections;
|
|
48
|
+
const loadSlugs = getMdxSlugs ?? loadMdxSlugs;
|
|
49
|
+
const loadSource = getMdxSource ?? loadMdxSource;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Append one generated `team` section listing every `Person` page (C3) —
|
|
53
|
+
* the same "the visible list and the sections that feed JSON-LD always
|
|
54
|
+
* agree" property `withSpokes` gives `/alternatives` (#6878 D9), since
|
|
55
|
+
* `renderPage` builds both the body and the JSON-LD from these sections.
|
|
56
|
+
* Returns `declared` unchanged whenever there is nothing to list, so a
|
|
57
|
+
* site with no `Person` page renders byte-identical to today (AC-4, C5).
|
|
58
|
+
*/
|
|
59
|
+
async function withPeople(frontmatter: unknown): Promise<Section[]> {
|
|
60
|
+
const declared = await loadSections(frontmatter);
|
|
61
|
+
const slugs = [...(await loadSlugs("Person", locale))].sort();
|
|
62
|
+
if (slugs.length === 0) return declared;
|
|
63
|
+
|
|
64
|
+
const t = await getTranslations({ locale, namespace: "about" });
|
|
65
|
+
const label = t("viewProfile");
|
|
66
|
+
|
|
67
|
+
const sources = await Promise.all(
|
|
68
|
+
slugs.map((slug) => loadSource("Person", slug, locale)),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const members = sources.flatMap((source, i) => {
|
|
72
|
+
if (source === null) return [];
|
|
73
|
+
const slug = slugs[i]!;
|
|
74
|
+
const fm = source.frontmatter as {
|
|
75
|
+
title: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
image?: string;
|
|
78
|
+
schema?: { name?: string; jobTitle?: string };
|
|
79
|
+
};
|
|
80
|
+
const name =
|
|
81
|
+
typeof fm.schema?.name === "string" ? fm.schema.name : fm.title;
|
|
82
|
+
const role =
|
|
83
|
+
typeof fm.schema?.jobTitle === "string" ? fm.schema.jobTitle : "";
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
name,
|
|
87
|
+
role,
|
|
88
|
+
...(typeof fm.description === "string" && {
|
|
89
|
+
bio: fm.description,
|
|
90
|
+
}),
|
|
91
|
+
...(typeof fm.image === "string" && {
|
|
92
|
+
avatar: { src: fm.image, alt: name },
|
|
93
|
+
}),
|
|
94
|
+
links: [{ type: "page" as const, url: `/about/${slug}`, label }],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (members.length === 0) return declared;
|
|
100
|
+
|
|
101
|
+
const people: Section = {
|
|
102
|
+
type: "team" as const,
|
|
103
|
+
id: "people",
|
|
104
|
+
data: { variant: "with-large-images" as const, members },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const last = declared.at(-1);
|
|
108
|
+
if (last !== undefined && last.type === "cta") {
|
|
109
|
+
return [...declared.slice(0, -1), people, last];
|
|
110
|
+
}
|
|
111
|
+
return [...declared, people];
|
|
112
|
+
}
|
|
113
|
+
|
|
32
114
|
return renderPage({
|
|
33
115
|
type: "AboutPage",
|
|
34
116
|
slug: "about",
|
|
35
117
|
locale,
|
|
36
118
|
translationsNamespace: "about",
|
|
119
|
+
getPageSections: withPeople,
|
|
37
120
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
38
|
-
...(getPageSections !== undefined && { getPageSections }),
|
|
39
121
|
});
|
|
40
122
|
}
|