create-ailk 0.11.5 → 0.12.1

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 (77) hide show
  1. package/component-catalog.json +35 -2
  2. package/component-catalog.md +9 -6
  3. package/dist/add-module.js +13 -0
  4. package/dist/module-architecture.d.ts +8 -0
  5. package/dist/module-architecture.js +33 -0
  6. package/dist/paid-sections.d.ts +23 -0
  7. package/dist/paid-sections.js +119 -0
  8. package/dist/programmatic.js +8 -0
  9. package/package.json +3 -3
  10. package/templates/.env.example +69 -3
  11. package/templates/apps/api/.env.example +22 -0
  12. package/templates/apps/api/CLAUDE.md +19 -7
  13. package/templates/apps/api/package.json +1 -0
  14. package/templates/apps/api/src/bin/spokes.ts +137 -0
  15. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  16. package/templates/apps/api/src/config/index.ts +97 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  18. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  19. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  20. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  21. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  22. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  23. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  24. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  25. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  26. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  27. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  28. package/templates/apps/api/src/server.ts +48 -0
  29. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  30. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  31. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  32. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  33. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  34. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  35. package/templates/apps/api/src/services/project-listings.ts +209 -18
  36. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  37. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  38. package/templates/apps/web/.env.example +14 -0
  39. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  40. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  41. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  42. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  43. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  44. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  45. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  46. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  47. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  48. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  49. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  50. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  51. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  53. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  54. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  55. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  56. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  57. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  58. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/jest.config.cjs +1 -1
  61. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  62. package/templates/apps/web/lib/listings.tsx +472 -0
  63. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  64. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  65. package/templates/apps/web/messages/en.json +10 -0
  66. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  67. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  68. package/templates/database/CHANGELOG.md +34 -0
  69. package/templates/database/__tests__/build.test.ts +131 -0
  70. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  71. package/templates/database/auth/schema.prisma +31 -0
  72. package/templates/database/config/schema.prisma +26 -0
  73. package/templates/database/inbox/schema.prisma +21 -0
  74. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  75. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  76. package/templates/database/package.json +3 -3
  77. package/versions.json +34 -34
@@ -29,7 +29,14 @@
29
29
  * on every such string, and on the Slack-supplied `decidedByName` too (lower
30
30
  * risk — only a member of the notified channel controls it — but the same
31
31
  * fix applies for defense in depth).
32
+ *
33
+ * Tenant binding (#7522): the posted actions block's `block_id` names the
34
+ * listing's own workspace (`listing_decision:<workspaceId>`), and
35
+ * `POST /v1/slack/actions` reads it back through `parseListingDecisionBlockId`
36
+ * — the producer and the parser live side by side here so the format cannot
37
+ * drift. Button `value`s stay the bare listing id.
32
38
  */
39
+ import { zWorkspaceId } from "@working-theory/ids";
33
40
  import { logger } from "@working-theory/observability";
34
41
 
35
42
  import { config } from "../config/index.js";
@@ -60,6 +67,47 @@ export type ProjectListingSlackInfo = {
60
67
  /** What an absent field renders as in a Slack field. */
61
68
  const ABSENT = "—";
62
69
 
70
+ /** The Approve/Reject actions block's `block_id` stem — declared once (#7522 D1). */
71
+ const LISTING_DECISION_BLOCK = "listing_decision";
72
+ const LISTING_DECISION_BLOCK_PREFIX = `${LISTING_DECISION_BLOCK}:`;
73
+
74
+ /**
75
+ * The instant (Unix seconds, a whole UTC hour) from which every posted
76
+ * Approve/Reject message names its workspace in `block_id` (#7522 D4). A
77
+ * click that names no workspace is honoured only for a listing whose message
78
+ * was posted before it (`slackTs` below it) or that has no message at all —
79
+ * a legacy-shaped click on a newer message cannot be a real click.
80
+ */
81
+ export const LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS = 1791000000; // 2026-10-03T04:00:00Z
82
+
83
+ /** The actions block's `block_id` for a listing in `workspaceId` (#7522 D1). */
84
+ export function listingDecisionBlockId(workspaceId: string): string {
85
+ return `${LISTING_DECISION_BLOCK_PREFIX}${workspaceId}`;
86
+ }
87
+
88
+ export type ListingDecisionBlock =
89
+ | { kind: "legacy" }
90
+ | { kind: "named"; workspaceId: string }
91
+ | { kind: "invalid" };
92
+
93
+ /**
94
+ * Reads a clicked action's `block_id` back (#7522 D2). Absent or the bare
95
+ * stem is the pre-#7522 message shape (`legacy`); the stem plus a valid
96
+ * workspace id is `named`; anything else — a malformed suffix included — is
97
+ * `invalid`, never `legacy`, so a bad workspace name cannot widen the scope.
98
+ */
99
+ export function parseListingDecisionBlockId(blockId: string | undefined): ListingDecisionBlock {
100
+ if (blockId === undefined || blockId === LISTING_DECISION_BLOCK) return { kind: "legacy" };
101
+ // The payload is JSON — a non-string `block_id` is possible on the wire.
102
+ if (typeof blockId !== "string" || !blockId.startsWith(LISTING_DECISION_BLOCK_PREFIX)) {
103
+ return { kind: "invalid" };
104
+ }
105
+ const workspaceId = blockId.slice(LISTING_DECISION_BLOCK_PREFIX.length);
106
+ return zWorkspaceId.safeParse(workspaceId).success
107
+ ? { kind: "named", workspaceId }
108
+ : { kind: "invalid" };
109
+ }
110
+
63
111
  type SlackApiResult = {
64
112
  ok: boolean;
65
113
  error?: string;
@@ -123,13 +171,16 @@ function infoBlocks(listing: ProjectListingSlackInfo): unknown[] {
123
171
 
124
172
  /**
125
173
  * Posts the new application to Slack (`chat.postMessage`) with Approve/Reject
126
- * buttons carrying the listing id as `value`. On success, stamps the
174
+ * buttons carrying the listing id as `value`, in an actions block whose
175
+ * `block_id` names the listing's workspace (#7522 D5). On success, stamps the
127
176
  * returned `channel`/`ts` onto the row so `updateListingMessage` can later
128
177
  * target the same message. No-op (logs `slack-skipped`) when Slack env is
129
178
  * absent; never throws — a Slack failure (missing config, API error, network
130
179
  * error) never fails the create that already succeeded.
131
180
  */
132
- export async function postListingApplication(listing: ProjectListingSlackInfo): Promise<void> {
181
+ export async function postListingApplication(
182
+ listing: ProjectListingSlackInfo & { workspaceId: string },
183
+ ): Promise<void> {
133
184
  const token = config.AILK_SLACK_BOT_TOKEN;
134
185
  const channel = config.AILK_SLACK_CHANNEL_ID;
135
186
  if (!token || !channel) {
@@ -149,7 +200,7 @@ export async function postListingApplication(listing: ProjectListingSlackInfo):
149
200
  ...infoBlocks(listing),
150
201
  {
151
202
  type: "actions",
152
- block_id: "listing_decision",
203
+ block_id: listingDecisionBlockId(listing.workspaceId),
153
204
  elements: [
154
205
  {
155
206
  type: "button",
@@ -56,6 +56,20 @@ CONTENT_ADAPTER=mdx
56
56
  # only, never NEXT_PUBLIC_ (see the repo-root .env.example, #7394).
57
57
  # NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
58
58
 
59
+ # =============================================================================
60
+ # Listicle directory (#7521) — server-side only, never NEXT_PUBLIC_
61
+ # =============================================================================
62
+ # The site's public listing key: the SAME value apps/api reads as
63
+ # AILK_LISTING_PUBLIC_KEY (single-tenant) or holds as a SiteListingConfig
64
+ # publicKey row (multi-tenant). Public by design — it names a site, it grants
65
+ # nothing — but it is read on the server only. When it is set and the paid
66
+ # @working-theory/ui-signal-collectors package is installed, the page a live
67
+ # listicle names as its landing page shows that listicle's accepted entries,
68
+ # and each entry gets a page at /<landingPage>/<slug>. Unset, no page reads
69
+ # the directory. One web deployment is one site: a hub's spokes are separate
70
+ # web deployments, each with its own key.
71
+ # AILK_LISTING_PUBLIC_KEY=
72
+
59
73
  # =============================================================================
60
74
  # apps/api internal-auth secret (server-side only) — #6876
61
75
  # =============================================================================
@@ -19,7 +19,41 @@ jest.mock("next/navigation", () => ({
19
19
  }),
20
20
  }));
21
21
 
22
+ // #7521 — the paid sections reach the listing extension through the generated
23
+ // paid-sections registry, which a keyless scaffold renders as a stub answering
24
+ // null. This suite supplies stand-ins through the registry itself, so it
25
+ // passes in the monorepo and in every scaffold alike; the real sections are
26
+ // `listing-directory.test.tsx`'s (ui-signal-collectors).
27
+ // eslint-disable-next-line no-restricted-syntax -- the generated registry is the paid-package boundary; a scaffold renders it with or without the package
28
+ jest.mock("../../../../../lib/paid-sections.generated", () => {
29
+ const { createElement, Fragment } = jest.requireActual<typeof import("react")>("react");
30
+ type Entry = { name: string; profile: Array<{ name: string; label: string; value: string }> };
31
+ return {
32
+ loadUiCommerce: async () => null,
33
+ loadUiSignalCollectors: async () => ({
34
+ ListingShelves: () => createElement("div", { "data-testid": "listing-shelves" }),
35
+ ListingDetail: ({ entry }: { entry: Entry }) =>
36
+ createElement(
37
+ Fragment,
38
+ null,
39
+ createElement("h1", null, entry.name),
40
+ createElement(
41
+ "dl",
42
+ null,
43
+ ...entry.profile.flatMap((row) => [
44
+ createElement("dt", { key: `${row.name}-dt` }, row.label),
45
+ createElement("dd", { key: `${row.name}-dd` }, row.value),
46
+ ]),
47
+ ),
48
+ ),
49
+ }),
50
+ };
51
+ });
52
+
22
53
  jest.mock("@working-theory/ui", () => ({
54
+ // #7521 — the rest of the real barrel, so the listing extension finds
55
+ // `localeHref`; the two below still override.
56
+ ...jest.requireActual("@working-theory/ui"),
23
57
  Heading: ({
24
58
  children,
25
59
  level,
@@ -108,3 +142,106 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
108
142
  expect(mockNotFound).toHaveBeenCalledTimes(1);
109
143
  });
110
144
  });
145
+
146
+ // ── #7521 — a non-meet hub serves a listicle's accepted entry ──────────────
147
+ //
148
+ // Through the REAL listing extension: the key is set, the API read is a
149
+ // fetch stub, and the paid sections are the registry stand-ins mocked above.
150
+ describe("[slug]/[detail] — a listicle entry at /<landingPage>/<slug> (#7521)", () => {
151
+ const priorKey = process.env["AILK_LISTING_PUBLIC_KEY"];
152
+ const priorLocales = process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
153
+ const priorFetch = global.fetch;
154
+
155
+ beforeEach(() => {
156
+ mockNotFound.mockClear();
157
+ process.env["AILK_LISTING_PUBLIC_KEY"] = "pk-detail-test";
158
+ process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = "en";
159
+ global.fetch = jest.fn(async () => ({
160
+ ok: true,
161
+ status: 200,
162
+ json: async () => ({
163
+ ok: true,
164
+ data: {
165
+ landingPage: "/tools",
166
+ entryType: "Organization",
167
+ entries: [
168
+ {
169
+ id: "prjl_acme",
170
+ slug: "acme",
171
+ name: "Acme Tools",
172
+ summary: "Tools for teams.",
173
+ url: null,
174
+ logoMarkUrl: null,
175
+ featured: false,
176
+ status: "active",
177
+ windowEndsAt: null,
178
+ decidedAt: null,
179
+ waitlistCount: 0,
180
+ profile: [{ name: "stage", label: "Stage", value: "Live", kind: "select" }],
181
+ },
182
+ ],
183
+ },
184
+ }),
185
+ })) as jest.Mock;
186
+ });
187
+
188
+ afterEach(() => {
189
+ if (priorKey === undefined) delete process.env["AILK_LISTING_PUBLIC_KEY"];
190
+ else process.env["AILK_LISTING_PUBLIC_KEY"] = priorKey;
191
+ if (priorLocales === undefined) delete process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
192
+ else process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = priorLocales;
193
+ global.fetch = priorFetch;
194
+ });
195
+
196
+ it("renders the accepted entry with its profile and entry-type JSON-LD", async () => {
197
+ // `/tools` is a LandingPage, so the hub guard lets the directory read run.
198
+ const getMdxSource = jest
199
+ .fn()
200
+ .mockImplementation(async (type: string) =>
201
+ type === "LandingPage" ? makeSource("tools") : null,
202
+ );
203
+
204
+ const element = await DetailPage({
205
+ params: Promise.resolve({ locale: "en", slug: "tools", detail: "acme" }),
206
+ getMdxSource,
207
+ });
208
+ const { container } = render(element);
209
+
210
+ expect(container.querySelector("h1")?.textContent).toBe("Acme Tools");
211
+ expect(container.querySelector("dt")?.textContent).toBe("Stage");
212
+ expect(container.querySelector("dd")?.textContent).toBe("Live");
213
+ const ld = JSON.parse(
214
+ container.querySelector("script[type='application/ld+json']")?.textContent ?? "{}",
215
+ ) as Record<string, unknown>;
216
+ expect(ld["@type"]).toBe("Organization");
217
+ expect(String((global.fetch as jest.Mock).mock.calls[0][0])).toContain(
218
+ "/v1/project-listings/directory?siteKey=pk-detail-test&landingPage=%2Ftools&locale=en",
219
+ );
220
+ expect(mockNotFound).not.toHaveBeenCalled();
221
+ expect(getMdxSource).toHaveBeenCalledWith("LandingPage", "tools", "en");
222
+ expect(getMdxSource).not.toHaveBeenCalledWith("BookCall", expect.anything(), expect.anything());
223
+ });
224
+
225
+ it("404s for an entry the directory does not hold", async () => {
226
+ await expect(
227
+ DetailPage({
228
+ params: Promise.resolve({ locale: "en", slug: "tools", detail: "nope" }),
229
+ getMdxSource: jest.fn().mockResolvedValue(makeSource("tools")),
230
+ }),
231
+ ).rejects.toThrow("NEXT_NOT_FOUND");
232
+ });
233
+
234
+ it("404s with no directory read when the hub is not a page (SEC-7521-D1)", async () => {
235
+ const getMdxSource = jest.fn(async () => null);
236
+
237
+ await expect(
238
+ DetailPage({
239
+ params: Promise.resolve({ locale: "en", slug: "made-up-hub", detail: "acme" }),
240
+ getMdxSource,
241
+ }),
242
+ ).rejects.toThrow("NEXT_NOT_FOUND");
243
+ expect(getMdxSource).toHaveBeenCalledWith("LandingPage", "made-up-hub", "en");
244
+ expect(getMdxSource).toHaveBeenCalledWith("BlogList", "made-up-hub", "en");
245
+ expect(global.fetch).not.toHaveBeenCalled();
246
+ });
247
+ });
@@ -1,32 +1,54 @@
1
- // The Free /meet/<slug> placeholder detail route (#6897 D2). BookCall
2
- // content served at /meet/<detail> by Next.js static-segment precedence:
3
- // wherever apps/web/app/[locale]/meet/ exists (the scheduling module), that
4
- // directory resolves every /meet/* URL first and this route never runs. On a
5
- // keyless scaffold, this route serves the four Free placeholder detail pages.
1
+ // Two-segment URLs: the Free /meet/<slug> placeholder detail pages (#6897
2
+ // D2), and a listicle directory's entry pages (#7521).
6
3
  //
7
- // `slug` must equal MEET_PLACEHOLDER_HUB ("meet") — any other value 404s.
8
- // This route deliberately serves ONLY the one meet hub, not a generic
9
- // two-segment content route: BookCall's slug pattern forbids "/", so a
10
- // generic join would also be reachable at a one-segment URL through the
11
- // `[slug]` route — a duplicate-content defect. A second hub can generalize
12
- // this route when one exists.
4
+ // The meet hub: BookCall content served at /meet/<detail> by Next.js
5
+ // static-segment precedence: wherever apps/web/app/[locale]/meet/ exists (the
6
+ // scheduling module), that directory resolves every /meet/* URL first and this
7
+ // route never runs. On a keyless scaffold, this route serves the four Free
8
+ // placeholder detail pages. Unknown detail slug → notFound() (renderPage's own
9
+ // detail-route branch: no translationsNamespace is passed, so a null source
10
+ // 404s rather than rendering an empty-state placeholder — the same contract
11
+ // `[slug]/page.tsx` documents).
13
12
  //
14
- // Unknown detail slug → notFound() (renderPage's own detail-route branch: no
15
- // translationsNamespace is passed, so a null source 404s rather than
16
- // rendering an empty-state placeholder — the same contract `[slug]/page.tsx`
17
- // documents).
13
+ // Every other hub: `/<landingPage>/<entry>` for a listicle whose landing page
14
+ // is a generic one-segment page (`/tools` → `[slug]/page.tsx`). The listing
15
+ // extension (`lib/listings.tsx`) renders the accepted entry, or the route
16
+ // 404s — with no key or no paid package it makes no read at all. The first
17
+ // segment is the visitor's to choose, so the extension reads the directory
18
+ // only once `/<slug>` is proved a page the `[slug]` route serves (a
19
+ // LandingPage or BlogList source); a made-up hub costs no API read
20
+ // (SEC-7521-D1). No MDX is read at `/<slug>/<detail>`, so no generic
21
+ // two-segment content route exists: BookCall's slug pattern forbids "/", and
22
+ // a generic join would also be reachable at a one-segment URL through the
23
+ // `[slug]` route — a duplicate-content defect.
18
24
  //
19
25
  // Deliberately NO generateStaticParams, for the same reason as `[slug]/page.tsx`:
20
26
  // the locale layout reads cookies()/headers(), so this segment always renders
21
27
  // on request.
22
28
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
23
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
29
+ import {
30
+ getMdxSource as loadMdxSource,
31
+ renderPage,
32
+ renderPageMetadata,
33
+ } from "@working-theory/page-renderer";
24
34
  import type { Metadata } from "next";
25
35
  import { notFound } from "next/navigation";
26
36
  import type { ReactElement } from "react";
27
37
 
38
+ import { listingExtension, type HubExists } from "../../../../lib/listings";
28
39
  import { MEET_PLACEHOLDER_HUB } from "../../../../lib/meet-placeholder";
29
40
 
41
+ /** Whether `/<slug>` is a page the one-segment `[slug]` route would serve. */
42
+ function hubPage(
43
+ slug: string,
44
+ locale: string,
45
+ getMdxSource: typeof defaultGetMdxSource = loadMdxSource,
46
+ ): HubExists {
47
+ return async () =>
48
+ (await getMdxSource("LandingPage", slug, locale)) !== null ||
49
+ (await getMdxSource("BlogList", slug, locale)) !== null;
50
+ }
51
+
30
52
  interface Props {
31
53
  params: Promise<{ locale: string; slug: string; detail: string }>;
32
54
  /** Test-only DI override for getMdxSource. Production callers leave undefined. */
@@ -38,9 +60,18 @@ export async function generateMetadata({
38
60
  }: Pick<Props, "params">): Promise<Metadata> {
39
61
  const { locale, slug, detail } = await params;
40
62
  // Mirrors renderPageMetadata's own missing-frontmatter → {} contract: a
41
- // hub mismatch is the same "nothing to title" case, not a thrown 404 —
42
- // generateMetadata cannot itself change the response status.
43
- if (slug !== MEET_PLACEHOLDER_HUB) return {};
63
+ // hub with no listing entry is the same "nothing to title" case, not a
64
+ // thrown 404 — generateMetadata cannot itself change the response status.
65
+ if (slug !== MEET_PLACEHOLDER_HUB) {
66
+ return (
67
+ (await listingExtension.entryMetadataAt(
68
+ `/${slug}/${detail}`,
69
+ detail,
70
+ locale,
71
+ hubPage(slug, locale),
72
+ )) ?? {}
73
+ );
74
+ }
44
75
  return renderPageMetadata({ type: "BookCall", slug: detail, locale });
45
76
  }
46
77
 
@@ -49,7 +80,16 @@ export default async function Page({
49
80
  getMdxSource,
50
81
  }: Props): Promise<ReactElement> {
51
82
  const { locale, slug, detail } = await params;
52
- if (slug !== MEET_PLACEHOLDER_HUB) notFound();
83
+ if (slug !== MEET_PLACEHOLDER_HUB) {
84
+ const entry = await listingExtension.entryAt(
85
+ `/${slug}/${detail}`,
86
+ detail,
87
+ locale,
88
+ hubPage(slug, locale, getMdxSource),
89
+ );
90
+ if (entry === null) notFound();
91
+ return entry;
92
+ }
53
93
  return renderPage({
54
94
  type: "BookCall",
55
95
  slug: detail,
@@ -1,13 +1,9 @@
1
1
  // JobPosting detail page. Reads the JobPosting MDX for the given slug, renders via
2
- // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs trigger 404.
2
+ // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
3
  // Careers list at /careers links here; each job posting lives at /careers/<slug>.
4
- import {
5
- getMdxSource as defaultGetMdxSource,
6
- renderPage,
7
- renderPageMetadata,
8
- } from "@working-theory/page-renderer";
4
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
5
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
9
6
  import type { Metadata } from "next";
10
- import { notFound } from "next/navigation";
11
7
  import type { ReactElement } from "react";
12
8
 
13
9
  interface Props {
@@ -24,21 +20,14 @@ export async function generateMetadata({
24
20
 
25
21
  export default async function Page({
26
22
  params,
27
- getMdxSource = defaultGetMdxSource,
23
+ getMdxSource,
28
24
  }: Props): Promise<ReactElement> {
29
25
  const { locale, slug } = await params;
30
- const source = await getMdxSource("JobPosting", slug, locale);
31
-
32
- if (!source) {
33
- notFound();
34
- }
35
-
36
26
  return renderPage({
37
27
  type: "JobPosting",
38
28
  slug,
39
29
  locale,
40
- translationsNamespace: "careers",
41
30
  bodyMode: "mdx-body",
42
- getMdxSource: async () => source,
31
+ ...(getMdxSource !== undefined && { getMdxSource }),
43
32
  });
44
33
  }
@@ -36,6 +36,7 @@ jest.mock("next-mdx-remote/rsc", () => ({
36
36
  // ── Import page components ─────────────────────────────────────────────────────
37
37
  import CareersPage from "../page";
38
38
  import CareersSlugPage from "../[slug]/page";
39
+ import * as pageRenderer from "@working-theory/page-renderer";
39
40
 
40
41
  // ── Shared stubs ───────────────────────────────────────────────────────────────
41
42
  const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
@@ -101,6 +102,7 @@ describe("careers/[slug]/page — snapshot regression", () => {
101
102
  });
102
103
 
103
104
  it("null source triggers notFound()", async () => {
105
+ const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
104
106
  const { notFound } = jest.requireMock("next/navigation") as {
105
107
  notFound: jest.MockedFunction<() => never>;
106
108
  };
@@ -112,6 +114,12 @@ describe("careers/[slug]/page — snapshot regression", () => {
112
114
  getMdxSource: getMdxSourceEmpty,
113
115
  }),
114
116
  ).rejects.toThrow("NEXT_NOT_FOUND");
117
+ // The 404 is renderPage's detail branch, not a route pre-check (#7537).
118
+ expect(renderPageSpy).toHaveBeenCalledTimes(1);
119
+ expect(
120
+ renderPageSpy.mock.calls[0]![0].translationsNamespace,
121
+ ).toBeUndefined();
122
+ renderPageSpy.mockRestore();
115
123
  expect(notFound).toHaveBeenCalled();
116
124
  });
117
125
  });
@@ -1,12 +1,8 @@
1
1
  // Case Study detail page. Reads the CaseStudy MDX for the given slug, renders via
2
- // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
- import {
4
- getMdxSource as defaultGetMdxSource,
5
- renderPage,
6
- renderPageMetadata,
7
- } from "@working-theory/page-renderer";
2
+ // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
8
5
  import type { Metadata } from "next";
9
- import { notFound } from "next/navigation";
10
6
  import type { ReactElement } from "react";
11
7
 
12
8
  interface Props {
@@ -23,21 +19,14 @@ export async function generateMetadata({
23
19
 
24
20
  export default async function Page({
25
21
  params,
26
- getMdxSource = defaultGetMdxSource,
22
+ getMdxSource,
27
23
  }: Props): Promise<ReactElement> {
28
24
  const { locale, slug } = await params;
29
- const source = await getMdxSource("CaseStudy", slug, locale);
30
-
31
- if (!source) {
32
- notFound();
33
- }
34
-
35
25
  return renderPage({
36
26
  type: "CaseStudy",
37
27
  slug,
38
28
  locale,
39
- translationsNamespace: "case-studies",
40
29
  bodyMode: "mdx-body",
41
- getMdxSource: async () => source,
30
+ ...(getMdxSource !== undefined && { getMdxSource }),
42
31
  });
43
32
  }
@@ -32,6 +32,7 @@ jest.mock("next-mdx-remote/rsc", () => ({
32
32
  // ── Import page components ─────────────────────────────────────────────────────
33
33
  import CaseStudiesPage from "../page";
34
34
  import CaseStudySlugPage from "../[slug]/page";
35
+ import * as pageRenderer from "@working-theory/page-renderer";
35
36
 
36
37
  // ── Shared stubs ───────────────────────────────────────────────────────────────
37
38
  const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
@@ -97,6 +98,7 @@ describe("case-studies/[slug]/page — snapshot regression", () => {
97
98
  });
98
99
 
99
100
  it("null source triggers notFound()", async () => {
101
+ const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
100
102
  const { notFound } = jest.requireMock("next/navigation") as {
101
103
  notFound: jest.MockedFunction<() => never>;
102
104
  };
@@ -108,6 +110,12 @@ describe("case-studies/[slug]/page — snapshot regression", () => {
108
110
  getMdxSource: getMdxSourceEmpty,
109
111
  }),
110
112
  ).rejects.toThrow("NEXT_NOT_FOUND");
113
+ // The 404 is renderPage's detail branch, not a route pre-check (#7537).
114
+ expect(renderPageSpy).toHaveBeenCalledTimes(1);
115
+ expect(
116
+ renderPageSpy.mock.calls[0]![0].translationsNamespace,
117
+ ).toBeUndefined();
118
+ renderPageSpy.mockRestore();
111
119
  expect(notFound).toHaveBeenCalled();
112
120
  });
113
121
  });
@@ -1,12 +1,8 @@
1
1
  // Event detail page. Reads the Event MDX for the given slug, renders via
2
- // renderPage, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
- import {
4
- renderPage,
5
- renderPageMetadata,
6
- getMdxSource as defaultGetMdxSource,
7
- } from "@working-theory/page-renderer";
2
+ // renderPage, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
8
5
  import type { Metadata } from "next";
9
- import { notFound } from "next/navigation";
10
6
  import type { ReactElement } from "react";
11
7
 
12
8
  interface Props {
@@ -23,24 +19,14 @@ export async function generateMetadata({
23
19
 
24
20
  export default async function Page({
25
21
  params,
26
- getMdxSource = defaultGetMdxSource,
22
+ getMdxSource,
27
23
  }: Props): Promise<ReactElement> {
28
24
  const { locale, slug } = await params;
29
-
30
- // Pre-check: detail pages call notFound() rather than rendering an empty-state.
31
- // Inject the resolved source so renderPage does not call getMdxSource a second time.
32
- const source = await getMdxSource("Event", slug, locale);
33
-
34
- if (!source) {
35
- notFound();
36
- }
37
-
38
25
  return renderPage({
39
26
  type: "Event",
40
27
  slug,
41
28
  locale,
42
- translationsNamespace: "events",
43
29
  bodyMode: "mdx-body",
44
- getMdxSource: async () => source,
30
+ ...(getMdxSource !== undefined && { getMdxSource }),
45
31
  });
46
32
  }
@@ -49,6 +49,7 @@ jest.mock("@working-theory/ui", () => ({
49
49
 
50
50
  import EventsPage from "../page";
51
51
  import EventSlugPage from "../[slug]/page";
52
+ import * as pageRenderer from "@working-theory/page-renderer";
52
53
  import { notFound } from "next/navigation";
53
54
 
54
55
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
@@ -128,12 +129,19 @@ describe("EventSlugPage — snapshot regression", () => {
128
129
  });
129
130
 
130
131
  it("null source calls notFound()", async () => {
132
+ const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
131
133
  await expect(
132
134
  EventSlugPage({
133
135
  params: Promise.resolve({ locale: "en", slug: "unknown-event" }),
134
136
  getMdxSource: getMdxSourceEmpty,
135
137
  }),
136
138
  ).rejects.toThrow("NEXT_NOT_FOUND");
139
+ // The 404 is renderPage's detail branch, not a route pre-check (#7537).
140
+ expect(renderPageSpy).toHaveBeenCalledTimes(1);
141
+ expect(
142
+ renderPageSpy.mock.calls[0]![0].translationsNamespace,
143
+ ).toBeUndefined();
144
+ renderPageSpy.mockRestore();
137
145
  expect(mockNotFound).toHaveBeenCalled();
138
146
  });
139
147
  });
@@ -1,12 +1,8 @@
1
1
  // Integration detail page. Reads the Integration MDX for the given slug, renders
2
- // via next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
- import {
4
- renderPage,
5
- renderPageMetadata,
6
- getMdxSource as defaultGetMdxSource,
7
- } from "@working-theory/page-renderer";
2
+ // via next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
+ import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
8
5
  import type { Metadata } from "next";
9
- import { notFound } from "next/navigation";
10
6
  import type { ReactElement } from "react";
11
7
 
12
8
  interface Props {
@@ -23,20 +19,15 @@ export async function generateMetadata({
23
19
 
24
20
  export default async function Page({
25
21
  params,
26
- getMdxSource = defaultGetMdxSource,
22
+ getMdxSource,
27
23
  }: Props): Promise<ReactElement> {
28
24
  const { locale, slug } = await params;
29
- const source = await getMdxSource("Integration", slug, locale);
30
- if (!source) {
31
- notFound();
32
- }
33
25
  return renderPage({
34
26
  type: "Integration",
35
27
  slug,
36
28
  locale,
37
- translationsNamespace: "integrations",
38
29
  bodyMode: "mdx-body",
39
30
  populatedDescriptionTestId: "integration-description",
40
- getMdxSource: async () => source,
31
+ ...(getMdxSource !== undefined && { getMdxSource }),
41
32
  });
42
33
  }
@@ -29,6 +29,7 @@ jest.mock("next/navigation", () => ({
29
29
  import { notFound } from "next/navigation";
30
30
  import IntegrationsPage from "../page";
31
31
  import IntegrationSlugPage from "../[slug]/page";
32
+ import * as pageRenderer from "@working-theory/page-renderer";
32
33
 
33
34
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
34
35
 
@@ -117,6 +118,7 @@ describe("IntegrationSlugPage — snapshot regression", () => {
117
118
  });
118
119
 
119
120
  it("null source calls notFound()", async () => {
121
+ const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
120
122
  const getMdxSourceNull = jest.fn().mockResolvedValue(null);
121
123
  await expect(
122
124
  IntegrationSlugPage({
@@ -124,6 +126,12 @@ describe("IntegrationSlugPage — snapshot regression", () => {
124
126
  getMdxSource: getMdxSourceNull,
125
127
  }),
126
128
  ).rejects.toThrow("NEXT_NOT_FOUND");
129
+ // The 404 is renderPage's detail branch, not a route pre-check (#7537).
130
+ expect(renderPageSpy).toHaveBeenCalledTimes(1);
131
+ expect(
132
+ renderPageSpy.mock.calls[0]![0].translationsNamespace,
133
+ ).toBeUndefined();
134
+ renderPageSpy.mockRestore();
127
135
  expect(mockNotFound).toHaveBeenCalled();
128
136
  });
129
137
  });