create-ailk 0.3.0 → 0.5.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.
Files changed (155) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +48 -10
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +88 -1
  6. package/dist/lib/extract-module-bundle.js +375 -80
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.d.ts +20 -0
  11. package/dist/lib/fetch-template.js +133 -0
  12. package/dist/lib/module-license-gate.d.ts +8 -0
  13. package/dist/lib/module-license-gate.js +12 -4
  14. package/dist/module-architecture.d.ts +116 -7
  15. package/dist/module-architecture.js +465 -94
  16. package/dist/parse-args.d.ts +8 -1
  17. package/dist/parse-args.js +49 -2
  18. package/dist/programmatic.d.ts +36 -1
  19. package/dist/programmatic.js +216 -5
  20. package/dist/site-chrome.d.ts +80 -0
  21. package/dist/site-chrome.js +135 -0
  22. package/dist/surfaces.d.ts +21 -6
  23. package/dist/surfaces.js +140 -23
  24. package/dist/template-manifest.d.ts +144 -0
  25. package/dist/template-manifest.js +337 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +45 -0
  28. package/package.json +10 -2
  29. package/site-templates/_base/ailk-template.json +6 -0
  30. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  31. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/_base/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  35. package/site-templates/marketing-site/ailk-template.json +11 -0
  36. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  37. package/site-templates/marketing-site/provenance.json +4 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  39. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  40. package/site-templates/marketing-site-free/ailk-template.json +9 -0
  41. package/site-templates/marketing-site-free/provenance.json +4 -0
  42. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  43. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  44. package/site-templates/minimalism-site/ailk-template.json +21 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  49. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  50. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  51. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  53. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  54. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  55. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  56. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  57. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  58. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  60. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  65. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  68. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  69. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  70. package/site-templates/minimalism-site/provenance.json +4 -0
  71. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  72. package/site-templates/saas-product/ailk-template.json +16 -0
  73. package/site-templates/saas-product/provenance.json +10 -0
  74. package/templates/.claude/skills/README.md +16 -25
  75. package/templates/.env.example +32 -7
  76. package/templates/.github/workflows/ci.yml +48 -0
  77. package/templates/README.md +3 -1
  78. package/templates/apps/api/CLAUDE.md +4 -1
  79. package/templates/apps/api/eslint.config.mjs +69 -0
  80. package/templates/apps/api/package.json +6 -2
  81. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  82. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  83. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +173 -0
  84. package/templates/apps/api/src/config/modules.ts +1 -1
  85. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  86. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  87. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  88. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  89. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  90. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  91. package/templates/apps/api/src/server.ts +122 -3
  92. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  93. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  94. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  95. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  96. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  97. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  98. package/templates/apps/mcp/CLAUDE.md +1 -1
  99. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  100. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  101. package/templates/apps/mcp/eslint.config.mjs +69 -0
  102. package/templates/apps/mcp/package.json +6 -2
  103. package/templates/apps/mcp/src/server.ts +2 -2
  104. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  105. package/templates/apps/mcp/src/tools/index.ts +2 -2
  106. package/templates/apps/web/.env.example +3 -3
  107. package/templates/apps/web/CLAUDE.md +2 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  109. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  111. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  112. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  113. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  115. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  119. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  120. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  121. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  122. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  123. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  124. package/templates/apps/web/components/google-analytics.tsx +18 -1
  125. package/templates/apps/web/eslint.config.mjs +69 -0
  126. package/templates/apps/web/jest.config.cjs +12 -15
  127. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  128. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  129. package/templates/apps/web/lib/nav-config.ts +27 -7
  130. package/templates/apps/web/lib/server-api.ts +29 -13
  131. package/templates/apps/web/lib/site-chrome.ts +29 -0
  132. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  133. package/templates/apps/web/messages/en.json +86 -1
  134. package/templates/apps/web/next.config.mjs +20 -0
  135. package/templates/apps/web/package.json +6 -2
  136. package/templates/apps/web/sentry.client.config.ts +131 -0
  137. package/templates/apps/web/sentry.edge.config.ts +72 -0
  138. package/templates/apps/web/sentry.server.config.ts +72 -0
  139. package/templates/apps/web/src/lib/routes.ts +13 -18
  140. package/templates/brand_assets/README.md +24 -0
  141. package/templates/content/en/AboutPage/about.mdx +160 -15
  142. package/templates/content/en/ContactPage/contact.mdx +90 -13
  143. package/templates/content/en/FAQPage/faq.mdx +62 -22
  144. package/templates/content/en/HomePage/home.mdx +244 -20
  145. package/templates/content/en/LandingPage/blog.mdx +83 -0
  146. package/templates/content/en/LandingPage/features.mdx +103 -0
  147. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  148. package/templates/database/CHANGELOG.md +21 -0
  149. package/templates/database/__tests__/seed.test.ts +1 -1
  150. package/templates/database/eslint.config.mjs +69 -0
  151. package/templates/database/package.json +7 -3
  152. package/templates/database/seed.ts +1 -1
  153. package/templates/package.json +1 -14
  154. package/versions.json +52 -0
  155. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * LeadsTable — the lead inbox's paginated table (#5678 Deliverable 2).
3
+ *
4
+ * App-local composition: `Table` primitive + `EmptyState` when there are no
5
+ * rows + a single forward-only "next page" link. Every string is
6
+ * caller-supplied (translatable) and every value arrives pre-formatted — this
7
+ * component formats nothing itself (Rule 10: formatting happens in the page,
8
+ * via `../../waitlist/format.js`).
9
+ *
10
+ * Deliberately NOT an import of the structurally identical
11
+ * `../../waitlist/components/SignupsTable.tsx`, despite sharing its shape:
12
+ * that file is an `ownedFiles` entry of a PAID module slice
13
+ * (`packages/create-ailk/src/module-architecture.ts` — the
14
+ * `apps/web/app/[locale]/(authed)/waitlist/**` block, which belongs to the
15
+ * `multi-tenant` slice, not `signal-collectors` as the card's brief had it;
16
+ * verified at HEAD, the `SignupsTable.tsx` entry is on line 1035 inside
17
+ * `MODULE_SLICES["multi-tenant"].ownedFiles`). A scaffold built without that
18
+ * module has no such file. Lead capture — and therefore this inbox — ships at
19
+ * every level including Free, so importing it would make a base-tier page
20
+ * depend on a file the strip removes. The pattern is reused; the file is not.
21
+ *
22
+ * `EmptyState`-when-empty follows `ExperimentsTable`'s idiom rather than
23
+ * `SignupsTable`'s (which always renders a table): a headerless table is not
24
+ * an acceptable empty state for a page whose whole body is this one table.
25
+ *
26
+ * `ForbiddenState` and `format.ts`, by contrast, are safe to import: both are
27
+ * deliberately EXCLUDED from that same `ownedFiles` list (see the module
28
+ * file's own ESCALATION comment above the list — they are shared with the
29
+ * Free `(authed)/account` page, which already imports both), so both survive
30
+ * in a build without the paid slice.
31
+ */
32
+
33
+ import type { LeadInboxRow } from "@working-theory/api-client";
34
+ import {
35
+ EmptyState,
36
+ Link,
37
+ Table,
38
+ TableBody,
39
+ TableCaption,
40
+ TableCell,
41
+ TableHead,
42
+ TableHeader,
43
+ TableRow,
44
+ } from "@working-theory/ui/primitives";
45
+ import type { ReactElement } from "react";
46
+
47
+ export interface LeadsTableLabels {
48
+ caption: string;
49
+ columnName: string;
50
+ columnEmail: string;
51
+ columnSource: string;
52
+ columnCreated: string;
53
+ unavailable: string;
54
+ next: string;
55
+ emptyHeadline: string;
56
+ emptyDescription: string;
57
+ }
58
+
59
+ export interface LeadsTableProps {
60
+ locale: string;
61
+ /**
62
+ * `created` is pre-formatted by the caller (Rule 10 — this component never
63
+ * formats a value itself). `lead.name` is `null` when the capture form took
64
+ * no name; it renders `labels.unavailable`, the same sentinel handling the
65
+ * caller's own `format.ts` uses elsewhere on the authed surface.
66
+ *
67
+ * There is no page/URL column: the `Lead` record persists no page or
68
+ * referrer field, so `source` is the only capture-origin signal.
69
+ */
70
+ rows: Array<{
71
+ lead: LeadInboxRow;
72
+ created: string;
73
+ }>;
74
+ nextCursor: string | null;
75
+ labels: LeadsTableLabels;
76
+ }
77
+
78
+ function buildNextHref(locale: string, nextCursor: string): string {
79
+ const params = new URLSearchParams();
80
+ params.set("cursor", nextCursor);
81
+ return `/${locale}/leads?${params.toString()}`;
82
+ }
83
+
84
+ export function LeadsTable({
85
+ locale,
86
+ rows,
87
+ nextCursor,
88
+ labels,
89
+ }: LeadsTableProps): ReactElement {
90
+ if (rows.length === 0) {
91
+ return (
92
+ <div data-testid="leads-table-empty">
93
+ <EmptyState headline={labels.emptyHeadline} description={labels.emptyDescription} />
94
+ </div>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <div data-testid="leads-table-wrapper">
100
+ <Table data-testid="leads-table">
101
+ <TableCaption>{labels.caption}</TableCaption>
102
+ <TableHeader>
103
+ <TableRow>
104
+ <TableHead scope="col">{labels.columnName}</TableHead>
105
+ <TableHead scope="col">{labels.columnEmail}</TableHead>
106
+ <TableHead scope="col">{labels.columnSource}</TableHead>
107
+ <TableHead scope="col">{labels.columnCreated}</TableHead>
108
+ </TableRow>
109
+ </TableHeader>
110
+ <TableBody>
111
+ {rows.map(({ lead, created }) => (
112
+ <TableRow key={lead.id} data-testid={`leads-table-row-${lead.id}`}>
113
+ <TableCell>{lead.name ?? labels.unavailable}</TableCell>
114
+ <TableCell>{lead.email}</TableCell>
115
+ <TableCell>{lead.source}</TableCell>
116
+ <TableCell>{created}</TableCell>
117
+ </TableRow>
118
+ ))}
119
+ </TableBody>
120
+ </Table>
121
+ {nextCursor !== null ? (
122
+ <div className="mt-3 flex justify-end">
123
+ <Link
124
+ href={buildNextHref(locale, nextCursor)}
125
+ data-testid="leads-table-next"
126
+ // 24px hit-area floor (Rule 14) — a bare inline link's
127
+ // line-height box falls short on its own (#4665, caught on the
128
+ // worker's own re-inspection of the render evidence).
129
+ className="inline-flex min-h-6 items-center px-2"
130
+ >
131
+ {labels.next}
132
+ </Link>
133
+ </div>
134
+ ) : null}
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * /[locale]/leads — the signed-in site owner's lead inbox (#5678).
3
+ *
4
+ * RSC: reads `GET /v1/leads` through `@working-theory/api-client`'s `leads`
5
+ * namespace, forwarding the caller's cookie session
6
+ * (`../../../../lib/server-api`, #4665 D9) — never a hand-rolled `fetch`
7
+ * (`architecture.yaml`: `apps/web` may not reach `apps/api` directly). The
8
+ * route is owner-scoped server-side, so this page passes no site or workspace
9
+ * argument; the only query it owns is the forward-only `?cursor=`.
10
+ *
11
+ * A 401/403 renders `ForbiddenState` (no leak of why — the same shape the
12
+ * waitlist comparison page uses); every other error (a 400 on a malformed
13
+ * cursor, a 500) rethrows to the route's error boundary rather than being
14
+ * swallowed.
15
+ */
16
+
17
+ import { leads, type LeadInboxPage } from "@working-theory/api-client";
18
+ import { PageHeader } from "@working-theory/ui/blocks";
19
+ import { Container, Heading, Text } from "@working-theory/ui/primitives";
20
+ import type { Metadata } from "next";
21
+ import { getTranslations } from "next-intl/server";
22
+ import type { ReactElement } from "react";
23
+
24
+ import { ApiError, forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api";
25
+ import { ForbiddenState } from "../waitlist/components/ForbiddenState";
26
+ import { formatDateTime } from "../waitlist/format";
27
+
28
+ import { LeadsTable } from "./components/LeadsTable";
29
+
30
+ interface Props {
31
+ params: Promise<{ locale: string }>;
32
+ searchParams: Promise<{ cursor?: string }>;
33
+ }
34
+
35
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
36
+ const { locale } = await params;
37
+ const t = await getTranslations({ locale, namespace: "leadsInbox" });
38
+ return { title: t("title"), description: t("description") };
39
+ }
40
+
41
+ export default async function LeadsInboxPage({
42
+ params,
43
+ searchParams,
44
+ }: Props): Promise<ReactElement> {
45
+ const { locale } = await params;
46
+ const { cursor } = await searchParams;
47
+ const t = await getTranslations({ locale, namespace: "leadsInbox" });
48
+
49
+ const init = await forwardedCookieInit();
50
+
51
+ let page: LeadInboxPage;
52
+ try {
53
+ page = await unwrapApiResult(leads.list(cursor ? { cursor } : {}, init));
54
+ } catch (err) {
55
+ if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
56
+ return (
57
+ <ForbiddenState
58
+ headline={t("forbiddenHeadline")}
59
+ description={t("forbiddenDescription")}
60
+ />
61
+ );
62
+ }
63
+ // A 400 means the `?cursor=` in the URL did not resolve — it was typed,
64
+ // or it is a stale next-page link whose row has since gone. That is a bad
65
+ // address, not a broken page, so fall back to the first page rather than
66
+ // letting it reach the route's error boundary (PR #5705 security review,
67
+ // finding 3). A 400 from the FIRST-page read has no cursor to blame and
68
+ // would be a genuine contract break, so it is rethrown.
69
+ if (err instanceof ApiError && err.status === 400 && cursor) {
70
+ page = await unwrapApiResult(leads.list({}, init));
71
+ } else {
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ const rows = page.items.map((lead) => ({
77
+ lead,
78
+ created: formatDateTime(lead.createdAt, locale),
79
+ }));
80
+
81
+ return (
82
+ <main className="min-h-screen bg-surface text-fg" data-testid="leads-inbox-page">
83
+ <PageHeader />
84
+ <Container className="flex flex-col gap-6 py-section">
85
+ <div>
86
+ <Heading level={1}>{t("title")}</Heading>
87
+ <Text as="p" variant="body-sm" tone="muted" className="mt-1">
88
+ {t("description")}
89
+ </Text>
90
+ </div>
91
+
92
+ <LeadsTable
93
+ locale={locale}
94
+ rows={rows}
95
+ nextCursor={page.nextCursor}
96
+ labels={{
97
+ caption: t("table.caption"),
98
+ columnName: t("table.columnName"),
99
+ columnEmail: t("table.columnEmail"),
100
+ columnSource: t("table.columnSource"),
101
+ columnCreated: t("table.columnCreated"),
102
+ unavailable: t("table.unavailable"),
103
+ next: t("table.next"),
104
+ emptyHeadline: t("table.emptyHeadline"),
105
+ emptyDescription: t("table.emptyDescription"),
106
+ }}
107
+ />
108
+ </Container>
109
+ </main>
110
+ );
111
+ }
@@ -4,7 +4,7 @@
4
4
  * SignInGate — the `(authed)` group's own front door (#4665 D9).
5
5
  *
6
6
  * This gate composes the shipped `AuthPanel` section directly (so the
7
- * dashboard is reachable in place, unlike `apps-paid/workspace`, which redirects
7
+ * dashboard is reachable in place, unlike `apps-paid/admin`, which redirects
8
8
  * to "/" on no session) and ALSO links to `apps/web`'s own `/sign-in` page
9
9
  * (#4984) carrying `?next=<pathname>`, so a visitor who prefers the full
10
10
  * sign-in surface lands back on the exact page they requested.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tests for the generic `[slug]` content route (#5469 D2).
3
+ *
4
+ * - Populated: getMdxSource returns a LandingPage source → renderPage
5
+ * sections mode composes the page (JSON-LD + SectionRenderer), no
6
+ * notFound() call, and the frontmatter `schema` override reaches the
7
+ * emitted JSON-LD (the escape hatch, proven with a `HowTo` fixture).
8
+ * - Unknown slug: getMdxSource returns null → renderPage's own detail-route
9
+ * branch (no translationsNamespace passed) calls notFound().
10
+ * - Resolution: the route asks the adapter for exactly `LandingPage` — the
11
+ * one content type it serves.
12
+ */
13
+ import React from "react";
14
+ import { render } from "@testing-library/react";
15
+
16
+ jest.mock("next/navigation", () => ({
17
+ __esModule: true,
18
+ notFound: jest.fn(() => {
19
+ throw new Error("NEXT_NOT_FOUND");
20
+ }),
21
+ }));
22
+
23
+ jest.mock("@working-theory/ui", () => ({
24
+ Heading: ({
25
+ children,
26
+ level,
27
+ }: {
28
+ children: React.ReactNode;
29
+ level: number;
30
+ }): React.ReactElement =>
31
+ React.createElement(`h${level}`, null, children),
32
+ SectionRenderer: ({ sections }: { sections: unknown[] }) =>
33
+ React.createElement("div", {
34
+ "data-testid": "section-renderer",
35
+ "data-section-count": String(sections.length),
36
+ }),
37
+ }));
38
+
39
+ import { notFound } from "next/navigation";
40
+ import SlugPage from "../page";
41
+
42
+ const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
43
+
44
+ function makeSource(slug: string, schema?: Record<string, unknown>) {
45
+ return {
46
+ frontmatter: {
47
+ type: "LandingPage" as const,
48
+ slug,
49
+ title: `Title for ${slug}`,
50
+ description: `Description for ${slug}`,
51
+ ...(schema !== undefined && { schema }),
52
+ sections: [
53
+ {
54
+ id: "hero",
55
+ type: "hero" as const,
56
+ data: { headline: `Title for ${slug}` },
57
+ },
58
+ ],
59
+ },
60
+ body: "",
61
+ };
62
+ }
63
+
64
+ describe("[slug] — the generic LandingPage content route", () => {
65
+ beforeEach(() => {
66
+ mockNotFound.mockClear();
67
+ });
68
+
69
+ it("resolves the slug against LandingPage and renders through renderPage sections mode", async () => {
70
+ const getMdxSource = jest.fn().mockResolvedValue(makeSource("how-it-works"));
71
+
72
+ const element = await SlugPage({
73
+ params: Promise.resolve({ locale: "en", slug: "how-it-works" }),
74
+ getMdxSource,
75
+ });
76
+ const { container } = render(element);
77
+
78
+ expect(getMdxSource).toHaveBeenCalledWith("LandingPage", "how-it-works", "en");
79
+ expect(container.querySelector("[data-testid='section-renderer']")).not.toBeNull();
80
+ expect(container.querySelector("script[type='application/ld+json']")).not.toBeNull();
81
+ expect(mockNotFound).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("emits the frontmatter `schema` override in the JSON-LD (the HowTo escape hatch)", async () => {
85
+ const getMdxSource = jest
86
+ .fn()
87
+ .mockResolvedValue(makeSource("how-it-works", { "@type": "HowTo" }));
88
+
89
+ const element = await SlugPage({
90
+ params: Promise.resolve({ locale: "en", slug: "how-it-works" }),
91
+ getMdxSource,
92
+ });
93
+ const { container } = render(element);
94
+
95
+ const script = container.querySelector("script[type='application/ld+json']");
96
+ expect(script).not.toBeNull();
97
+ expect(script?.textContent ?? "").toContain('"HowTo"');
98
+ });
99
+
100
+ it("calls notFound() for a slug with no LandingPage source", async () => {
101
+ const getMdxSource = jest.fn().mockResolvedValue(null);
102
+
103
+ await expect(
104
+ SlugPage({
105
+ params: Promise.resolve({ locale: "en", slug: "not-a-page" }),
106
+ getMdxSource,
107
+ }),
108
+ ).rejects.toThrow("NEXT_NOT_FOUND");
109
+ expect(mockNotFound).toHaveBeenCalledTimes(1);
110
+ });
111
+ });
@@ -0,0 +1,65 @@
1
+ // The one generic content route (#5469 D2). Serves the Free marketing-site
2
+ // template's /how-it-works, /features and /blog teaser — and any other
3
+ // `content/<locale>/LandingPage/<slug>.mdx` a site adds — through exactly the
4
+ // render path the static routes use (`renderPage`, sections mode, JSON-LD
5
+ // from frontmatter — a page's `schema:` override rides the escape hatch,
6
+ // though the structured-data gate only accepts a `WebPage` primary block
7
+ // for a LandingPage, so the Free template's pages set none).
8
+ //
9
+ // Precedence, not gating: Next.js resolves a static segment before a dynamic
10
+ // one, so this route never shadows a scaffolded route directory. That is what
11
+ // lets /blog degrade — keyless there is no `blog/` directory and the teaser
12
+ // renders here; with the docs-blog module its `blog/[[...slug]]` directory
13
+ // wins and the teaser file is inert (#5469 D3).
14
+ //
15
+ // One content type, not two: `WebPage` is a schema.org @type the registry
16
+ // emits, not a `SchemaOrgType` content key the adapter accepts, so the three
17
+ // pages are `LandingPage` files (the coined slug-keyed landing surface whose
18
+ // builder emits a `WebPage` envelope) — the lesson recorded with #5469
19
+ // (site-template content composition) has the proving lines.
20
+ //
21
+ // Unknown slug → `notFound()` (renderPage's own detail-route branch: no
22
+ // `translationsNamespace` is passed, so a null source 404s rather than
23
+ // rendering an empty-state placeholder).
24
+ //
25
+ // Deliberately NO `generateStaticParams`: the locale layout reads cookies()
26
+ // and headers() (theme, session), so every route under it renders on
27
+ // request. A static-params export would make Next try to prerender this
28
+ // segment and fail any slug outside the build-time list with
29
+ // DYNAMIC_SERVER_USAGE (a 500, not the 404 an unknown slug must get).
30
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
31
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
32
+ import type { Metadata } from "next";
33
+ import type { ReactElement } from "react";
34
+
35
+ // The content type is written as a literal in both renderPage calls below
36
+ // (not a shared constant): `lib/__tests__/structured-data.test.tsx` and
37
+ // tools/lint-check-content-renders.ts read `renderPage({ type: "…" })` off
38
+ // the source to know which content this route emits JSON-LD for.
39
+
40
+ interface Props {
41
+ params: Promise<{ locale: string; slug: string }>;
42
+ /** Test-only DI override for getMdxSource. Production callers leave undefined. */
43
+ getMdxSource?: typeof defaultGetMdxSource;
44
+ }
45
+
46
+ export async function generateMetadata({
47
+ params,
48
+ }: Pick<Props, "params">): Promise<Metadata> {
49
+ const { locale, slug } = await params;
50
+ return renderPageMetadata({ type: "LandingPage", slug, locale });
51
+ }
52
+
53
+ export default async function Page({
54
+ params,
55
+ getMdxSource,
56
+ }: Props): Promise<ReactElement> {
57
+ const { locale, slug } = await params;
58
+ return renderPage({
59
+ type: "LandingPage",
60
+ slug,
61
+ locale,
62
+ populatedDescriptionTestId: `${slug}-description`,
63
+ ...(getMdxSource !== undefined && { getMdxSource }),
64
+ });
65
+ }
@@ -54,7 +54,7 @@ import {
54
54
  type FlowStepperMessages,
55
55
  type FlowStepSubmitPayload,
56
56
  type FlowStepSubmitResult,
57
- } from "@working-theory/ui/slices/signal-collectors";
57
+ } from "@working-theory/ui-signal-collectors";
58
58
  import type { AeoScoreResult, FlowConfig, FlowState } from "@working-theory/validation";
59
59
  import {
60
60
  deriveFlags,
@@ -65,7 +65,7 @@ interface MockFlowStepperProps {
65
65
 
66
66
  let captured: MockFlowStepperProps | undefined;
67
67
 
68
- jest.mock("@working-theory/ui/slices/signal-collectors", () => ({
68
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
69
69
  FlowStepper: (props: MockFlowStepperProps) => {
70
70
  captured = props;
71
71
  return require("react").createElement("div", {
@@ -14,10 +14,11 @@
14
14
  * navigates (no `onCompleted` reaches FlowStepper at all); no
15
15
  * `segment` on a `thankYou`-bearing flow resolves to `default`.
16
16
  *
17
- * `@working-theory/ui`'s FlowStepper is mocked as a minimal stub that calls
18
- * the real `onSubmitStep`/`onCompleted` props it receives — FlowStepper's
19
- * OWN submission/onCompleted-firing behavior is already covered by its own
20
- * package test (packages/ui/src/sections/__tests__/FlowStepper.test.tsx);
17
+ * `@working-theory/ui-signal-collectors`'s FlowStepper is mocked as a minimal
18
+ * stub that calls the real `onSubmitStep`/`onCompleted` props it receives —
19
+ * FlowStepper's OWN submission/onCompleted-firing behavior is already covered
20
+ * by its own package test
21
+ * (packages-paid/ui-signal-collectors/src/sections/__tests__/FlowStepper.test.tsx);
21
22
  * this file is scoped to FlowStepperClient's own responsibility — the
22
23
  * Server Action binding and the onCompleted → router.push wiring.
23
24
  */
@@ -69,7 +70,7 @@ const PII_PAYLOAD = {
69
70
 
70
71
  let capturedOnCompleted: MockFlowStepperProps["onCompleted"];
71
72
 
72
- jest.mock("@working-theory/ui/slices/signal-collectors", () => ({
73
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
73
74
  FlowStepper: (props: MockFlowStepperProps) => {
74
75
  capturedOnCompleted = props.onCompleted;
75
76
  async function handleSubmit(): Promise<void> {
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { flows } from "@working-theory/api-client";
33
- import type { FlowStepSubmitResult } from "@working-theory/ui/slices/signal-collectors";
33
+ import type { FlowStepSubmitResult } from "@working-theory/ui-signal-collectors";
34
34
  import {
35
35
  flowSlugSchema,
36
36
  submitFlowStepInputSchema,