create-ailk 0.8.0 → 0.9.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 +68 -0
- package/component-catalog.json +279 -45
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +92 -4
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +19 -9
- package/dist/lib/module-license-gate.js +19 -8
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +64 -21
- package/dist/parse-args.d.ts +12 -2
- package/dist/parse-args.js +74 -13
- package/dist/programmatic.d.ts +30 -6
- package/dist/programmatic.js +206 -17
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +141 -8
- package/dist/starter-grid.d.ts +32 -16
- package/dist/starter-grid.js +129 -33
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +200 -83
- package/dist/template-manifest.d.ts +30 -13
- package/dist/template-manifest.js +49 -24
- package/package.json +5 -4
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
- package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/designs/standard/DESIGN.md +98 -6
- package/site-templates/marketing-site/DESIGN.md +98 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
- package/site-templates/marketing-site-free/DESIGN.md +98 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/saas-product/DESIGN.md +98 -6
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
- package/site-templates/shapes/person-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
- package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +85 -76
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +23 -0
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +28 -11
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +98 -6
- package/templates/README.md +50 -38
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +35 -19
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +14 -13
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +23 -9
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +40 -1
- package/templates/content/en/HomePage/home.mdx +2 -6
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +2 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ComparisonsList hub (#6878 D9) — the /alternatives page. Reads
|
|
2
|
+
// ComparisonsList MDX via the content adapter, injects a generated `items`
|
|
3
|
+
// section listing every ComparisonPage spoke (so the visible card grid and
|
|
4
|
+
// the CollectionPage's ItemList JSON-LD — built from the same sections —
|
|
5
|
+
// always agree, D9), and emits via the dispatcher.
|
|
6
|
+
//
|
|
7
|
+
// notFound() when the hub seed is absent, so no empty hub is ever served
|
|
8
|
+
// (unlike the /for hub's empty-state branch): an add-page run always writes
|
|
9
|
+
// the seed alongside the first spoke (D11), so a fresh scaffold — which
|
|
10
|
+
// excludes this gated surface entirely — never reaches this branch in
|
|
11
|
+
// production, only a mis-authored site would.
|
|
12
|
+
//
|
|
13
|
+
// Gated off by default (surfaces.ts `alternatives` surface): no roster names
|
|
14
|
+
// this route, so a fresh scaffold excludes it.
|
|
15
|
+
import type {
|
|
16
|
+
getMdxSource as defaultGetMdxSource,
|
|
17
|
+
getPageSections as defaultGetPageSections,
|
|
18
|
+
} from "@working-theory/page-renderer";
|
|
19
|
+
import {
|
|
20
|
+
getMdxSlugs,
|
|
21
|
+
getMdxSource as loadMdxSource,
|
|
22
|
+
getPageSections as loadPageSections,
|
|
23
|
+
renderPage,
|
|
24
|
+
renderPageMetadata,
|
|
25
|
+
} from "@working-theory/page-renderer";
|
|
26
|
+
import type { Metadata } from "next";
|
|
27
|
+
import { notFound } from "next/navigation";
|
|
28
|
+
import type { ReactElement } from "react";
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
params: Promise<{ locale: string }>;
|
|
32
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
33
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
37
|
+
|
|
38
|
+
export async function generateMetadata({
|
|
39
|
+
params,
|
|
40
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
41
|
+
const { locale } = await params;
|
|
42
|
+
return renderPageMetadata({
|
|
43
|
+
type: "ComparisonsList",
|
|
44
|
+
slug: "alternatives",
|
|
45
|
+
locale,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default async function Page({
|
|
50
|
+
params,
|
|
51
|
+
getMdxSource,
|
|
52
|
+
getPageSections,
|
|
53
|
+
}: Props): Promise<ReactElement> {
|
|
54
|
+
const { locale } = await params;
|
|
55
|
+
const load = getMdxSource ?? loadMdxSource;
|
|
56
|
+
const loadSections = getPageSections ?? loadPageSections;
|
|
57
|
+
|
|
58
|
+
const source = await load("ComparisonsList", "alternatives", locale);
|
|
59
|
+
if (source === null) {
|
|
60
|
+
notFound();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append one generated `items` section listing every ComparisonPage spoke
|
|
65
|
+
* — D9's "the visible list and the CollectionPage's ItemList always
|
|
66
|
+
* agree" property, since `renderPage` builds JSON-LD from these same
|
|
67
|
+
* sections. Absent when there are zero spokes: the hub still renders its
|
|
68
|
+
* own authored sections and answers 200 on its seed alone.
|
|
69
|
+
*/
|
|
70
|
+
async function withSpokes(frontmatter: unknown): Promise<Section[]> {
|
|
71
|
+
const declared = await loadSections(frontmatter);
|
|
72
|
+
const slugs = await getMdxSlugs("ComparisonPage", locale);
|
|
73
|
+
if (slugs.length === 0) return declared;
|
|
74
|
+
|
|
75
|
+
const items = await Promise.all(
|
|
76
|
+
slugs.map(async (slug) => {
|
|
77
|
+
const spoke = await load("ComparisonPage", slug, locale);
|
|
78
|
+
const fm = (spoke?.frontmatter ?? {}) as {
|
|
79
|
+
title?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
title: fm.title ?? slug,
|
|
84
|
+
description: fm.description ?? "",
|
|
85
|
+
link: { label: "Read the comparison", href: `/vs/${slug}` },
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
...declared,
|
|
92
|
+
{ type: "items" as const, id: "comparison-spokes", data: { items } },
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return renderPage({
|
|
97
|
+
type: "ComparisonsList",
|
|
98
|
+
slug: "alternatives",
|
|
99
|
+
locale,
|
|
100
|
+
translationsNamespace: "alternatives",
|
|
101
|
+
getPageSections: withSpokes,
|
|
102
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// UseCase detail page (#6658 D4
|
|
2
|
-
// the /
|
|
1
|
+
// UseCase detail page (#6658 D4; route renamed to /for by #6878 D2) — one
|
|
2
|
+
// customer page at /for/<slug>, listed by the /for hub. Reads the UseCase MDX
|
|
3
|
+
// for the given slug, renders via
|
|
3
4
|
// @working-theory/page-renderer, and emits a JSON-LD WebPage whose mainEntity
|
|
4
5
|
// is an Article. Unknown slugs 404. Composed `sections[]` render as sections
|
|
5
6
|
// (#6721); a prose page renders its MDX body.
|
|
@@ -40,7 +41,7 @@ export default async function Page({
|
|
|
40
41
|
}: Props): Promise<ReactElement> {
|
|
41
42
|
const { locale, slug } = await params;
|
|
42
43
|
// A page that composes `sections[]` (the starter seeds, #6721) renders
|
|
43
|
-
// them; a prose page renders its MDX body — the same rule the /
|
|
44
|
+
// them; a prose page renders its MDX body — the same rule the /for hub
|
|
44
45
|
// applies. JSON-LD is built from the frontmatter either way.
|
|
45
46
|
const source = await (getMdxSource ?? loadMdxSource)("UseCase", slug, locale);
|
|
46
47
|
const composed =
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML snapshot regression tests for for/page.tsx → @working-theory/page-renderer migration
|
|
3
|
+
* (route renamed from use-cases/ by #6878 D2).
|
|
4
|
+
*
|
|
5
|
+
* Two-commit strategy:
|
|
6
|
+
* Commit 1: this file added; snapshots captured against pre-migration route (RED-passing).
|
|
7
|
+
* Commit 2: route migrated; same snapshots verify diff=0 (GREEN).
|
|
8
|
+
*
|
|
9
|
+
* Two states tested:
|
|
10
|
+
* - empty: getMdxSource returns null → empty-state branch
|
|
11
|
+
* - populated: getMdxSource returns a minimal MdxPageSource → full render
|
|
12
|
+
*
|
|
13
|
+
* Failure = snapshot diff > 0 bytes. DO NOT regenerate snapshots to pass.
|
|
14
|
+
*/
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { render } from "@testing-library/react";
|
|
17
|
+
|
|
18
|
+
// ── @working-theory/ui mock ─────────────────────────────────────────────────────────────
|
|
19
|
+
jest.mock("@working-theory/ui", () => ({
|
|
20
|
+
// #4446/#4447: renderPage's own fallback/empty-state title now composes
|
|
21
|
+
// Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
|
|
22
|
+
Heading: ({
|
|
23
|
+
children,
|
|
24
|
+
level,
|
|
25
|
+
}: {
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
level: number;
|
|
28
|
+
}): React.ReactElement => React.createElement(`h${level}`, null, children),
|
|
29
|
+
SectionRenderer: ({ sections }: { sections: unknown[] }) =>
|
|
30
|
+
React.createElement("div", {
|
|
31
|
+
"data-testid": "section-renderer",
|
|
32
|
+
"data-section-count": String(sections.length),
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import ForPage from "../page";
|
|
37
|
+
|
|
38
|
+
const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
|
|
39
|
+
|
|
40
|
+
describe("ForPage — snapshot regression", () => {
|
|
41
|
+
it("empty state renders deterministic HTML", async () => {
|
|
42
|
+
const element = await ForPage({
|
|
43
|
+
params: Promise.resolve({ locale: "en" }),
|
|
44
|
+
getMdxSource: getMdxSourceEmpty,
|
|
45
|
+
});
|
|
46
|
+
const { container } = render(element);
|
|
47
|
+
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
48
|
+
`"<main id="main"><h1>Customers</h1><p data-testid="section-empty-state">Customer pages coming soon.</p></main>"`,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("populated state renders deterministic HTML", async () => {
|
|
53
|
+
const source = {
|
|
54
|
+
frontmatter: {
|
|
55
|
+
type: "UseCasesList" as const,
|
|
56
|
+
slug: "for",
|
|
57
|
+
title: "Use Cases",
|
|
58
|
+
description: "How teams use our platform.",
|
|
59
|
+
},
|
|
60
|
+
body: "",
|
|
61
|
+
};
|
|
62
|
+
const getMdxSourcePopulated = jest.fn().mockResolvedValue(source);
|
|
63
|
+
|
|
64
|
+
const element = await ForPage({
|
|
65
|
+
params: Promise.resolve({ locale: "en" }),
|
|
66
|
+
getMdxSource: getMdxSourcePopulated,
|
|
67
|
+
});
|
|
68
|
+
const { container } = render(element);
|
|
69
|
+
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
70
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><h1>Use Cases</h1><p data-testid="for-description">How teams use our platform.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></main>"`,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Customers (/for) hub page (#6878 D2 — renamed from /use-cases). Reads
|
|
2
|
+
// UseCasesList MDX via the content adapter and emits a JSON-LD CollectionPage
|
|
3
|
+
// via the dispatcher. List-only — no detail route.
|
|
3
4
|
import type {
|
|
4
5
|
getMdxSource as defaultGetMdxSource,
|
|
5
6
|
} from "@working-theory/page-renderer";
|
|
@@ -22,7 +23,7 @@ export async function generateMetadata({
|
|
|
22
23
|
const { locale } = await params;
|
|
23
24
|
return renderPageMetadata({
|
|
24
25
|
type: "UseCasesList",
|
|
25
|
-
slug: "
|
|
26
|
+
slug: "for",
|
|
26
27
|
locale,
|
|
27
28
|
});
|
|
28
29
|
}
|
|
@@ -33,19 +34,19 @@ export default async function Page({
|
|
|
33
34
|
}: Props): Promise<ReactElement> {
|
|
34
35
|
const { locale } = await params;
|
|
35
36
|
// A UseCasesList page that composes `sections[]` (the Free template's
|
|
36
|
-
// /
|
|
37
|
+
// /for hub, operator ruling 2026-09-23) renders them; a prose-only page
|
|
37
38
|
// keeps the MDX-body render. The CollectionPage JSON-LD is built from the
|
|
38
39
|
// sections either way (buildUseCasesList).
|
|
39
|
-
const source = await (getMdxSource ?? loadMdxSource)("UseCasesList", "
|
|
40
|
+
const source = await (getMdxSource ?? loadMdxSource)("UseCasesList", "for", locale);
|
|
40
41
|
const composed =
|
|
41
42
|
source !== null &&
|
|
42
43
|
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
43
44
|
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
|
|
44
45
|
return renderPage({
|
|
45
46
|
type: "UseCasesList",
|
|
46
|
-
slug: "
|
|
47
|
+
slug: "for",
|
|
47
48
|
locale,
|
|
48
|
-
translationsNamespace: "
|
|
49
|
+
translationsNamespace: "for",
|
|
49
50
|
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
50
51
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
51
52
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `GlossaryList` MDX with slug `glossary` and emits a JSON-LD CollectionPage
|
|
3
3
|
// whose mainEntity is a DefinedTermSet built from its `glossary-index`
|
|
4
4
|
// sections. A module-less surface a template enables by naming it in its
|
|
5
|
-
// roster; with no content the route 404s. Mirrors
|
|
5
|
+
// roster; with no content the route 404s. Mirrors for/page.tsx: a
|
|
6
6
|
// sections-composed page renders its sections, a prose-only page its MDX body.
|
|
7
7
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
8
8
|
import {
|
|
@@ -50,10 +50,12 @@ import { auth } from "../../auth";
|
|
|
50
50
|
import { GoogleAnalytics } from "../../components/google-analytics";
|
|
51
51
|
import { SignOutTrigger } from "../../components/sign-out-trigger";
|
|
52
52
|
import { isAuthPath } from "../../lib/auth-routes";
|
|
53
|
+
import { assertEnabledLocale } from "../../lib/locale-guard";
|
|
53
54
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
54
55
|
import { getOgImageUrl } from "../../lib/og";
|
|
55
|
-
import { SITE_NAME
|
|
56
|
+
import { SITE_NAME } from "../../lib/site-brand";
|
|
56
57
|
import { siteChrome } from "../../lib/site-chrome";
|
|
58
|
+
import { siteLogo } from "../../lib/site-logo";
|
|
57
59
|
// Registers the `_site.mdx` identity with page-renderer at module load, so
|
|
58
60
|
// every page rendered under this layout carries the sitewide JSON-LD (#6597).
|
|
59
61
|
import "../../lib/site-identity";
|
|
@@ -211,6 +213,9 @@ export default async function LocaleLayout({
|
|
|
211
213
|
params,
|
|
212
214
|
}: Props): Promise<ReactElement> {
|
|
213
215
|
const { locale } = await params;
|
|
216
|
+
// #6107 — a segment that is not an enabled locale is a 404, before any
|
|
217
|
+
// message load or session read.
|
|
218
|
+
assertEnabledLocale(locale);
|
|
214
219
|
const messages = await getMessages();
|
|
215
220
|
const themeMode = await resolveThemeMode();
|
|
216
221
|
const consentDecided = await hasConsentChoice();
|
|
@@ -227,6 +232,7 @@ export default async function LocaleLayout({
|
|
|
227
232
|
// The Free marketing-site chrome (#5469 D5): the Product flyout's three
|
|
228
233
|
// entries + descriptions, the top-level items, and the Get started CTA.
|
|
229
234
|
product: tNav("product"),
|
|
235
|
+
services: tNav("services"),
|
|
230
236
|
useCases: tNav("useCases"),
|
|
231
237
|
useCasesDesc: tNav("useCasesDesc"),
|
|
232
238
|
footerSitePagesHeading: tNav("footerSitePagesHeading"),
|
|
@@ -238,14 +244,20 @@ export default async function LocaleLayout({
|
|
|
238
244
|
feature4: tNav("feature4"),
|
|
239
245
|
feature5: tNav("feature5"),
|
|
240
246
|
allFeatures: tNav("allFeatures"),
|
|
241
|
-
// #
|
|
247
|
+
// #6878 D6 — one flyout rule for every shape: the offering column
|
|
248
|
+
// (Features or Services) beside the Customers column (the /for
|
|
249
|
+
// persona pages), plus the Customers footer column.
|
|
242
250
|
flyoutFeaturesHeading: tNav("flyoutFeaturesHeading"),
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
251
|
+
flyoutServicesHeading: tNav("flyoutServicesHeading"),
|
|
252
|
+
flyoutCustomersHeading: tNav("flyoutCustomersHeading"),
|
|
253
|
+
footerCustomersHeading: tNav("footerCustomersHeading"),
|
|
254
|
+
allCustomers: tNav("allCustomers"),
|
|
255
|
+
customer1: tNav("customer1"),
|
|
256
|
+
customer2: tNav("customer2"),
|
|
257
|
+
customer3: tNav("customer3"),
|
|
258
|
+
service1: tNav("service1"),
|
|
259
|
+
service2: tNav("service2"),
|
|
260
|
+
service3: tNav("service3"),
|
|
249
261
|
howItWorks: tNav("howItWorks"),
|
|
250
262
|
howItWorksDesc: tNav("howItWorksDesc"),
|
|
251
263
|
features: tNav("features"),
|
|
@@ -253,6 +265,7 @@ export default async function LocaleLayout({
|
|
|
253
265
|
faq: tNav("faq"),
|
|
254
266
|
faqDesc: tNav("faqDesc"),
|
|
255
267
|
pricing: tNav("pricing"),
|
|
268
|
+
portfolio: tNav("portfolio"),
|
|
256
269
|
about: tNav("about"),
|
|
257
270
|
// The footer's column groups (#6468): their two headings + Contact.
|
|
258
271
|
contact: tNav("contact"),
|
|
@@ -292,10 +305,13 @@ export default async function LocaleLayout({
|
|
|
292
305
|
// enforcement, the callbacks are the hook for @working-theory/consent.
|
|
293
306
|
const tConsent = await getTranslations({ locale, namespace: "consent" });
|
|
294
307
|
|
|
295
|
-
// Right-side action cluster (#4984). Signed out → the
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
308
|
+
// Right-side action cluster (#4984). Signed out → the auth pair: an
|
|
309
|
+
// outline-strong "Sign in" → /sign-in beside a primary "Sign up" → /sign-up
|
|
310
|
+
// (the operator's auth-pair idiom, #4370). Every shape ships the (auth)
|
|
311
|
+
// route group, so both routes exist in every scaffold; whether the header
|
|
312
|
+
// shows them is the design's nav setting (#6797 — an earlier revision of
|
|
313
|
+
// this comment said the Free site had no sign-up flow; it renders both and
|
|
314
|
+
// the service shapes now roster both). Signed in → the user's name (a
|
|
299
315
|
// non-navigating label) + an outline-strong "Sign out"; both authored as
|
|
300
316
|
// `kind: "flow"` so Nav hands their triggers back through
|
|
301
317
|
// `renderFlowTrigger` (see NAV_CLIENT_ACTION_FLOW above).
|
|
@@ -327,7 +343,7 @@ export default async function LocaleLayout({
|
|
|
327
343
|
const hasFlowAction = navActions.some((action) => action.kind === "flow");
|
|
328
344
|
|
|
329
345
|
// Shared brand mark injected into both Nav and Footer chrome slots. The
|
|
330
|
-
// mark comes from site-
|
|
346
|
+
// mark comes from site-logo.tsx (the mark the /sign-in card also renders);
|
|
331
347
|
// both add the shipped wordmark beside it — the Nav composes mark + wordmark
|
|
332
348
|
// on desktop and mark-only below `md` (#5469 D5), the columns Footer shows
|
|
333
349
|
// the full lockup at every width (#6507).
|
|
@@ -372,11 +388,10 @@ export default async function LocaleLayout({
|
|
|
372
388
|
// `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
|
|
373
389
|
const isAuthRoute = isAuthPath(pathname, enabledLocales);
|
|
374
390
|
|
|
375
|
-
// Placeholder social handles
|
|
376
|
-
//
|
|
391
|
+
// Placeholder social handles (#5469 D5): `{{COMPANY_HANDLE}}` is a
|
|
392
|
+
// create-ailk marker substituted at scaffold time
|
|
377
393
|
// (packages/create-ailk/src/programmatic.ts), so a customer's site never
|
|
378
|
-
// carries the kit vendor's own identity here.
|
|
379
|
-
// typed, so the placeholder does not go stale.
|
|
394
|
+
// carries the kit vendor's own identity here.
|
|
380
395
|
const socialLinks = [
|
|
381
396
|
{ platform: "facebook", href: "https://facebook.com/{{COMPANY_HANDLE}}" },
|
|
382
397
|
{ platform: "instagram", href: "https://instagram.com/{{COMPANY_HANDLE}}" },
|
|
@@ -400,8 +415,9 @@ export default async function LocaleLayout({
|
|
|
400
415
|
items: col.items.map((item) => ({ ...item, label: labelFor(item.labelKey) })),
|
|
401
416
|
}));
|
|
402
417
|
|
|
403
|
-
//
|
|
404
|
-
|
|
418
|
+
// The site's own name (#6942) — `SITE_NAME` resolves from `_site.mdx`'s
|
|
419
|
+
// `siteName:`, so a customer's footer never carries the kit vendor's name.
|
|
420
|
+
const copyrightText = `© ${new Date().getFullYear()} ${SITE_NAME}. All rights reserved.`;
|
|
405
421
|
|
|
406
422
|
// Footer legal links (#5612 D3). The centered variant folds in FAQ — the
|
|
407
423
|
// roster's third always-scaffolded core route (`/faq`, alongside `/privacy`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Newsletter page (#6658 D5) — a NewsletterSignup at /newsletter. A
|
|
2
2
|
// module-less surface a template enables by naming it in its roster; with no
|
|
3
|
-
// content the route 404s. Mirrors
|
|
3
|
+
// content the route 404s. Mirrors for/page.tsx: a sections-composed
|
|
4
4
|
// page renders its sections, a prose-only page its MDX body.
|
|
5
5
|
//
|
|
6
6
|
// A signup only when there is somewhere to keep it (#6730 D9). The API says
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Portfolio item page (#6687). Reads the PortfolioItem MDX for the given slug and emits a
|
|
2
2
|
// JSON-LD <script>. Unknown slugs trigger 404. Renders `sections[]` when the
|
|
3
|
-
// frontmatter carries them (the
|
|
3
|
+
// frontmatter carries them (the /for composed pattern), MDX prose otherwise.
|
|
4
4
|
// NO_HAND_IMPLEMENT_OK: /build-starter operator-directed live pass (#6687).
|
|
5
5
|
import {
|
|
6
6
|
getMdxSource as defaultGetMdxSource,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Portfolio list page (#6687). Reads PortfolioList MDX via the content adapter and emits
|
|
2
2
|
// a JSON-LD CollectionPage via the dispatcher.
|
|
3
3
|
// NO_HAND_IMPLEMENT_OK: /build-starter operator-directed live pass (#6687) — the
|
|
4
|
-
//
|
|
4
|
+
// /for composed pattern mirrored onto the portfolio hub: sections when the
|
|
5
5
|
// frontmatter carries `sections[]`, MDX prose otherwise (existing prose pages unchanged).
|
|
6
6
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
7
7
|
import {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /vs/[slug] (#6878 D9) — the ComparisonPage detail route.
|
|
3
|
+
* `renderPage`/`renderPageMetadata`/`getMdxSlugs` are mocked at the
|
|
4
|
+
* `@working-theory/page-renderer` boundary; `getMdxSource` is supplied
|
|
5
|
+
* per-call through the route's DI prop.
|
|
6
|
+
*/
|
|
7
|
+
// The mock return value is never rendered — every assertion below inspects
|
|
8
|
+
// `mockRenderPage.mock.calls` directly — so it carries no `ReactElement`
|
|
9
|
+
// cast; `renderPage`'s real return type is `Promise<ReactElement>` in
|
|
10
|
+
// production, unaffected by this mock's loose type here.
|
|
11
|
+
const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
|
|
12
|
+
const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
|
|
13
|
+
const mockGetMdxSlugs = jest.fn((..._args: unknown[]) => Promise.resolve<string[]>([]));
|
|
14
|
+
|
|
15
|
+
jest.mock("@working-theory/page-renderer", () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
getMdxSlugs: (...args: unknown[]) => mockGetMdxSlugs(...args),
|
|
18
|
+
renderPage: (...args: unknown[]) => mockRenderPage(...args),
|
|
19
|
+
renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
|
|
20
|
+
getMdxSource: jest.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import ComparisonPage, {
|
|
24
|
+
generateMetadata,
|
|
25
|
+
generateStaticParams,
|
|
26
|
+
} from "../page";
|
|
27
|
+
|
|
28
|
+
beforeEach(() => jest.clearAllMocks());
|
|
29
|
+
|
|
30
|
+
describe("ComparisonPage detail — metadata + static params", () => {
|
|
31
|
+
it("resolves metadata for the given slug", async () => {
|
|
32
|
+
await generateMetadata({
|
|
33
|
+
params: Promise.resolve({ locale: "en", slug: "acme" }),
|
|
34
|
+
});
|
|
35
|
+
expect(mockRenderPageMetadata).toHaveBeenCalledWith({
|
|
36
|
+
type: "ComparisonPage",
|
|
37
|
+
slug: "acme",
|
|
38
|
+
locale: "en",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("enumerates every ComparisonPage slug for static generation", async () => {
|
|
43
|
+
mockGetMdxSlugs.mockResolvedValue(["acme", "globex"]);
|
|
44
|
+
const params = await generateStaticParams({ params: { locale: "en" } });
|
|
45
|
+
expect(mockGetMdxSlugs).toHaveBeenCalledWith("ComparisonPage", "en");
|
|
46
|
+
expect(params).toEqual([{ slug: "acme" }, { slug: "globex" }]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("ComparisonPage detail — sections vs mdx-body", () => {
|
|
51
|
+
it("renders in sections mode (D10: hero, decision-split, comparison-table, cta) when frontmatter carries sections", async () => {
|
|
52
|
+
const getMdxSource = jest.fn().mockResolvedValue({
|
|
53
|
+
frontmatter: {
|
|
54
|
+
type: "ComparisonPage",
|
|
55
|
+
slug: "acme",
|
|
56
|
+
title: "vs. Acme",
|
|
57
|
+
description: "How we compare to Acme.",
|
|
58
|
+
sections: [
|
|
59
|
+
{ id: "hero", type: "hero", data: {} },
|
|
60
|
+
{ id: "compare", type: "decision-split", data: {} },
|
|
61
|
+
{ id: "table", type: "comparison-table", data: {} },
|
|
62
|
+
{
|
|
63
|
+
id: "cta",
|
|
64
|
+
type: "cta",
|
|
65
|
+
data: { primaryCta: { label: "See all alternatives", href: "/alternatives" } },
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
body: "",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await ComparisonPage({
|
|
73
|
+
params: Promise.resolve({ locale: "en", slug: "acme" }),
|
|
74
|
+
getMdxSource,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
|
|
78
|
+
type: string;
|
|
79
|
+
slug: string;
|
|
80
|
+
bodyMode?: string;
|
|
81
|
+
};
|
|
82
|
+
expect(passedInput.type).toBe("ComparisonPage");
|
|
83
|
+
expect(passedInput.slug).toBe("acme");
|
|
84
|
+
expect(passedInput.bodyMode).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("falls back to mdx-body mode for a prose-only comparison page", async () => {
|
|
88
|
+
const getMdxSource = jest.fn().mockResolvedValue({
|
|
89
|
+
frontmatter: {
|
|
90
|
+
type: "ComparisonPage",
|
|
91
|
+
slug: "acme",
|
|
92
|
+
title: "vs. Acme",
|
|
93
|
+
description: "How we compare to Acme.",
|
|
94
|
+
},
|
|
95
|
+
body: "Prose comparison.",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await ComparisonPage({
|
|
99
|
+
params: Promise.resolve({ locale: "en", slug: "acme" }),
|
|
100
|
+
getMdxSource,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const passedInput = mockRenderPage.mock.calls[0]?.[0] as {
|
|
104
|
+
bodyMode?: string;
|
|
105
|
+
populatedDescriptionTestId?: string;
|
|
106
|
+
};
|
|
107
|
+
expect(passedInput.bodyMode).toBe("mdx-body");
|
|
108
|
+
expect(passedInput.populatedDescriptionTestId).toBe(
|
|
109
|
+
"comparison-description",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ComparisonPage detail (#6878 D9) — one head-to-head comparison at
|
|
2
|
+
// /vs/<slug>, listed by the /alternatives hub. Reads the ComparisonPage MDX
|
|
3
|
+
// for the given slug, renders via @working-theory/page-renderer, and emits a
|
|
4
|
+
// JSON-LD WebPage. Unknown slugs 404. Composed `sections[]` (D10 — the
|
|
5
|
+
// generic detail chassis, no dedicated comparison section: hero,
|
|
6
|
+
// decision-split, comparison-table, cta) render as sections; a prose page
|
|
7
|
+
// renders its MDX body — the same rule the /for detail page applies.
|
|
8
|
+
//
|
|
9
|
+
// Gated off by default (surfaces.ts `alternatives` surface): no roster names
|
|
10
|
+
// this route, so a fresh scaffold excludes it. `add-page` installs it (D11)
|
|
11
|
+
// the first time a site adds a comparison.
|
|
12
|
+
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
13
|
+
import {
|
|
14
|
+
getMdxSource as loadMdxSource,
|
|
15
|
+
getMdxSlugs,
|
|
16
|
+
renderPage,
|
|
17
|
+
renderPageMetadata,
|
|
18
|
+
} from "@working-theory/page-renderer";
|
|
19
|
+
import type { Metadata } from "next";
|
|
20
|
+
import type { ReactElement } from "react";
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
24
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function generateMetadata({
|
|
28
|
+
params,
|
|
29
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
30
|
+
const { locale, slug } = await params;
|
|
31
|
+
return renderPageMetadata({ type: "ComparisonPage", slug, locale });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function generateStaticParams({
|
|
35
|
+
params,
|
|
36
|
+
}: {
|
|
37
|
+
params: { locale: string };
|
|
38
|
+
}): Promise<{ slug: string }[]> {
|
|
39
|
+
const slugs = await getMdxSlugs("ComparisonPage", params.locale);
|
|
40
|
+
return slugs.map((slug) => ({ slug }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default async function Page({
|
|
44
|
+
params,
|
|
45
|
+
getMdxSource,
|
|
46
|
+
}: Props): Promise<ReactElement> {
|
|
47
|
+
const { locale, slug } = await params;
|
|
48
|
+
const source = await (getMdxSource ?? loadMdxSource)(
|
|
49
|
+
"ComparisonPage",
|
|
50
|
+
slug,
|
|
51
|
+
locale,
|
|
52
|
+
);
|
|
53
|
+
const composed =
|
|
54
|
+
source !== null &&
|
|
55
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
56
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
57
|
+
0;
|
|
58
|
+
return renderPage({
|
|
59
|
+
type: "ComparisonPage",
|
|
60
|
+
slug,
|
|
61
|
+
locale,
|
|
62
|
+
...(composed
|
|
63
|
+
? {}
|
|
64
|
+
: {
|
|
65
|
+
bodyMode: "mdx-body" as const,
|
|
66
|
+
populatedDescriptionTestId: "comparison-description",
|
|
67
|
+
}),
|
|
68
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
69
|
+
});
|
|
70
|
+
}
|