create-ailk 0.11.5 → 0.12.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/component-catalog.json +35 -2
- package/component-catalog.md +9 -6
- package/dist/add-module.js +13 -0
- package/dist/module-architecture.d.ts +8 -0
- package/dist/module-architecture.js +33 -0
- package/dist/paid-sections.d.ts +23 -0
- package/dist/paid-sections.js +119 -0
- package/dist/programmatic.js +8 -0
- package/package.json +3 -3
- package/templates/.env.example +69 -3
- package/templates/apps/api/.env.example +22 -0
- package/templates/apps/api/CLAUDE.md +19 -7
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/bin/spokes.ts +137 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
- package/templates/apps/api/src/config/index.ts +97 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
- package/templates/apps/api/src/lib/spoke-store.ts +131 -0
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
- package/templates/apps/api/src/routes/leads/index.ts +33 -3
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
- package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
- package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
- package/templates/apps/api/src/server.ts +48 -0
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
- package/templates/apps/api/src/services/default-tenant.ts +51 -28
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
- package/templates/apps/api/src/services/lead-routing.ts +13 -1
- package/templates/apps/api/src/services/project-listings.ts +209 -18
- package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
- package/templates/apps/api/src/services/slack-notify.ts +54 -3
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/layout.tsx +4 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/jest.config.cjs +1 -1
- package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
- package/templates/apps/web/lib/listings.tsx +472 -0
- package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
- package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
- package/templates/apps/web/messages/en.json +10 -0
- package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
- package/templates/database/CHANGELOG.md +24 -0
- package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
- package/templates/database/auth/schema.prisma +31 -0
- package/templates/database/config/schema.prisma +26 -0
- package/templates/database/inbox/schema.prisma +21 -0
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
- package/templates/database/package.json +1 -1
- 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
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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 {
|
|
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
|
|
42
|
-
// generateMetadata cannot itself change the response status.
|
|
43
|
-
if (slug !== MEET_PLACEHOLDER_HUB)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
|
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
|
|
30
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
42
31
|
});
|
|
43
32
|
}
|
package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx
CHANGED
|
@@ -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
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
|
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
|
|
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
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
|
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
|
|
31
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
41
32
|
});
|
|
42
33
|
}
|
package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx
CHANGED
|
@@ -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
|
});
|