create-ailk 0.11.4 → 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.
Files changed (81) hide show
  1. package/component-catalog.json +36 -3
  2. package/component-catalog.md +10 -7
  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/__tests__/content-write-production-boot.test.ts +104 -0
  15. package/templates/apps/api/src/__tests__/root-modules.test.ts +175 -0
  16. package/templates/apps/api/src/bin/spokes.ts +137 -0
  17. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
  18. package/templates/apps/api/src/config/index.ts +97 -0
  19. package/templates/apps/api/src/config/modules.ts +38 -0
  20. package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
  21. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
  22. package/templates/apps/api/src/lib/spoke-store.ts +131 -0
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
  24. package/templates/apps/api/src/routes/leads/index.ts +33 -3
  25. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
  26. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
  27. package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
  28. package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
  29. package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
  30. package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
  31. package/templates/apps/api/src/server.ts +56 -1
  32. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
  33. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +19 -0
  34. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
  35. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
  36. package/templates/apps/api/src/services/default-tenant.ts +51 -28
  37. package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
  38. package/templates/apps/api/src/services/lead-routing.ts +13 -1
  39. package/templates/apps/api/src/services/project-listings.ts +209 -18
  40. package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
  41. package/templates/apps/api/src/services/revalidation.ts +8 -1
  42. package/templates/apps/api/src/services/slack-notify.ts +54 -3
  43. package/templates/apps/web/.env.example +14 -0
  44. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
  45. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
  46. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
  47. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
  48. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
  50. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
  51. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
  52. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
  53. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
  54. package/templates/apps/web/app/[locale]/layout.tsx +4 -0
  55. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
  56. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
  57. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
  58. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
  59. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
  60. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
  61. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
  62. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
  63. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
  65. package/templates/apps/web/jest.config.cjs +1 -1
  66. package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
  67. package/templates/apps/web/lib/listings.tsx +472 -0
  68. package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
  69. package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
  70. package/templates/apps/web/messages/en.json +49 -15
  71. package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
  72. package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
  73. package/templates/database/CHANGELOG.md +31 -0
  74. package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
  75. package/templates/database/auth/schema.prisma +31 -0
  76. package/templates/database/config/schema.prisma +26 -0
  77. package/templates/database/inbox/schema.prisma +21 -0
  78. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
  79. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
  80. package/templates/database/package.json +1 -1
  81. package/versions.json +34 -34
@@ -0,0 +1,472 @@
1
+ /**
2
+ * The listicle directory, mounted on whichever page a live listicle names
3
+ * (#7521 D6).
4
+ *
5
+ * A listicle is a magnet whose accepted applications are listed. Its owner
6
+ * picks a landing page in the wizard (`landingPage: "/partners"`); this module
7
+ * puts that listicle's accepted entries on that page as shelves, below the
8
+ * page's own sections, and gives each entry a page at `/<landingPage>/<slug>`.
9
+ * Nothing here knows `/projects` — `/partners`, `/tools` and `/projects` all
10
+ * work with no per-site route.
11
+ *
12
+ * It is a page-renderer EXTENSION (`setPageExtension`, D5), registered at
13
+ * module load exactly as `./site-identity.ts` registers the site identity:
14
+ * `app/[locale]/layout.tsx` imports this module, so every `renderPage` call
15
+ * sees it with no per-route prop. `appendix` renders the shelves on a found
16
+ * page; `detailFallback` turns a detail route's 404 into an entry page;
17
+ * `detailMetadata` titles it. `[slug]/[detail]/page.tsx` calls `entryAt`
18
+ * directly, because that route 404s before it reaches `renderPage`.
19
+ *
20
+ * BASE TREE, and inert without two things (D7). It ships in every scaffold,
21
+ * Free included, and is never slice-owned — the layout imports it, so owning
22
+ * it would strip every page from a keyless scaffold. Each member answers
23
+ * `null` with NO read when `AILK_LISTING_PUBLIC_KEY` is unset or the paid
24
+ * `@working-theory/ui-signal-collectors` package is absent. The package is
25
+ * reached the way `src/lib/commerce-sections.ts` reaches its own: through
26
+ * `loadUiSignalCollectors()` in the generated paid-sections registry (#7536),
27
+ * a bundled `import()` where the site carries the package and a stub that
28
+ * answers `null` where it does not, so a keyless build resolves no paid
29
+ * package.
30
+ *
31
+ * BOTH TENANCY SHAPES, no branch: the API resolves the public key through a
32
+ * `SiteListingConfig` row (multi-tenant) or its own env key (single-tenant).
33
+ *
34
+ * FAIL-SOFT: a read that fails, answers non-2xx, fails its schema or takes
35
+ * longer than 2000 ms renders the page as today, with one warning that never
36
+ * carries the key. Reads are bounded twice: React `cache()` shares one read
37
+ * between a request's metadata and its page, and a 30 s process memo keeps
38
+ * the API at one read per landing page per server process (SEC-7521-D1).
39
+ *
40
+ * PATH TRUST (SEC-7521-C1): `x-pathname` is the middleware's, except on a
41
+ * dotted path it does not match — the one shape a client could forge. So
42
+ * every segment must be `^[a-z0-9][a-z0-9-]*$` (no dots) and the last must
43
+ * equal the slug the route rendered, or the member answers `null`.
44
+ */
45
+ import { projectListings } from "@working-theory/api-client";
46
+ import {
47
+ JsonLdScript,
48
+ setPageExtension,
49
+ type PageExtension,
50
+ type PageExtensionContext,
51
+ } from "@working-theory/page-renderer";
52
+ import { localeHref } from "@working-theory/ui";
53
+ import {
54
+ listingDirectoryResponseSchema,
55
+ type ListingDirectory,
56
+ type ListingEntryPublic,
57
+ } from "@working-theory/validation";
58
+ import { readLocales } from "@working-theory/web-i18n";
59
+ import type { Metadata } from "next";
60
+ import { headers } from "next/headers";
61
+ import { unstable_rethrow } from "next/navigation";
62
+ import { getTranslations } from "next-intl/server";
63
+ import { cache, type ComponentType, type ReactElement, type ReactNode } from "react";
64
+
65
+ import { resolveBaseUrl } from "../src/lib/routes";
66
+
67
+ import { loadUiSignalCollectors } from "./paid-sections.generated";
68
+
69
+ // ── The paid sections, through the generated registry ──────────────────────
70
+
71
+ /** The props this module passes `ListingShelves` — a structural copy, never an `import type`. */
72
+ export interface ListingShelvesSlotProps {
73
+ entries: ListingEntryPublic[];
74
+ hrefFor: (entry: ListingEntryPublic) => string;
75
+ activeCopy: { title: string; empty: string };
76
+ closedCopy: { title: string; empty: string };
77
+ featuredLabel: string;
78
+ waitlistCountLabel: (n: number) => string;
79
+ }
80
+
81
+ /** The props this module passes `ListingDetail`. */
82
+ export interface ListingDetailSlotProps {
83
+ entry: ListingEntryPublic;
84
+ websiteLabel: string;
85
+ profileHeading: string;
86
+ }
87
+
88
+ /** The two exports this module needs from the paid package. */
89
+ export interface ListingUiModule {
90
+ ListingShelves: ComponentType<ListingShelvesSlotProps>;
91
+ ListingDetail: ComponentType<ListingDetailSlotProps>;
92
+ }
93
+
94
+ let uiModule: Promise<ListingUiModule | null> | undefined;
95
+
96
+ /**
97
+ * The paid sections, or `null` when this site does not carry the package.
98
+ * Once per process. An older package without the directory surfaces also
99
+ * answers `null`, never an undefined component that would fail the render
100
+ * past the extension's try/catch.
101
+ */
102
+ function loadListingUi(): Promise<ListingUiModule | null> {
103
+ uiModule ??= loadUiSignalCollectors().then((mod) => {
104
+ const ui = mod as Partial<ListingUiModule> | null;
105
+ return typeof ui?.ListingShelves === "function" && typeof ui.ListingDetail === "function"
106
+ ? (ui as ListingUiModule)
107
+ : null;
108
+ });
109
+ return uiModule;
110
+ }
111
+
112
+ // ── Dependencies ────────────────────────────────────────────────────────────
113
+
114
+ /** The `listings.*` message keys this module reads (`messages/en.json`). */
115
+ type ListingMessageKey =
116
+ | "activeTitle"
117
+ | "activeEmpty"
118
+ | "closedTitle"
119
+ | "closedEmpty"
120
+ | "featured"
121
+ | "waitlistCount"
122
+ | "website"
123
+ | "profileHeading";
124
+
125
+ /** A `listings.*` message lookup for one locale. */
126
+ export type ListingTranslator = (key: ListingMessageKey, values?: Record<string, number>) => string;
127
+
128
+ /** What a directory read answers — the api-client's result envelope, structurally. */
129
+ export type DirectoryReadResult = { ok: true; body: unknown } | { ok: false; status: number };
130
+
131
+ /** Every outside input, injectable so the suite runs with no network and no request. */
132
+ export interface ListingExtensionDeps {
133
+ siteKey?: () => string | undefined;
134
+ pathname?: () => Promise<string | null>;
135
+ locales?: () => readonly string[];
136
+ fetchDirectory?: (
137
+ siteKey: string,
138
+ landingPage: string,
139
+ locale: string,
140
+ ) => Promise<DirectoryReadResult>;
141
+ loadUi?: () => Promise<ListingUiModule | null>;
142
+ translate?: (locale: string) => Promise<ListingTranslator>;
143
+ baseUrl?: () => string;
144
+ /** The memo's clock, in ms. */
145
+ now?: () => number;
146
+ }
147
+
148
+ /**
149
+ * Whether the landing page an explicit path names is itself a page. The
150
+ * `[slug]/[detail]` route's first segment is the visitor's to choose, so the
151
+ * route proves `/<slug>` exists before any directory read (SEC-7521-D1).
152
+ */
153
+ export type HubExists = () => Promise<boolean>;
154
+
155
+ /** The registered extension, plus the explicit-path entry the `[slug]/[detail]` route calls. */
156
+ export interface ListingExtension extends PageExtension {
157
+ entryAt(
158
+ path: string,
159
+ slug: string,
160
+ locale: string,
161
+ hubExists: HubExists,
162
+ ): Promise<ReactElement | null>;
163
+ entryMetadataAt(
164
+ path: string,
165
+ slug: string,
166
+ locale: string,
167
+ hubExists: HubExists,
168
+ ): Promise<Metadata | null>;
169
+ }
170
+
171
+ const READ_TIMEOUT_MS = 2000;
172
+ const MEMO_TTL_MS = 30_000;
173
+ /** A failed read is retried sooner, so one transient error never hides a directory for the full TTL. */
174
+ const MEMO_FAILURE_TTL_MS = 5_000;
175
+ const MEMO_MAX_KEYS = 500;
176
+ const SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
177
+
178
+ // ── Pure helpers ────────────────────────────────────────────────────────────
179
+
180
+ /** A trusted path: its landing page, its segments after the locale, and whether it was prefixed. */
181
+ interface ListingPath {
182
+ landingPage: string;
183
+ segments: string[];
184
+ prefixed: boolean;
185
+ }
186
+
187
+ /**
188
+ * Parse a request path into `segmentCount` trusted segments after an
189
+ * enabled-locale prefix (`auth-routes.ts`'s prefix rule), the last equal to
190
+ * `slug`. Any other shape is `null`.
191
+ */
192
+ function parseListingPath(
193
+ pathname: string | null,
194
+ locales: readonly string[],
195
+ segmentCount: 1 | 2,
196
+ slug: string,
197
+ ): ListingPath | null {
198
+ if (!pathname) return null;
199
+ const raw = (pathname.split(/[?#]/)[0] ?? "").split("/").slice(1);
200
+ const prefixed = raw.length > 0 && locales.includes(raw[0]!);
201
+ const segments = prefixed ? raw.slice(1) : raw;
202
+ if (segments.length !== segmentCount) return null;
203
+ if (!segments.every((segment) => SEGMENT.test(segment))) return null;
204
+ if (segments[segments.length - 1] !== slug) return null;
205
+ return { landingPage: `/${segments[0]}`, segments, prefixed };
206
+ }
207
+
208
+ /**
209
+ * An entry's site-relative URL. `localeHref`'s as-needed rule, except that a
210
+ * request that arrived prefixed keeps its prefix — a page's links point
211
+ * where the page itself was served.
212
+ */
213
+ function entryHref(locale: string, path: ListingPath, slug: string): string {
214
+ const target = `${path.landingPage}/${slug}`;
215
+ return path.prefixed ? `/${locale}${target}` : localeHref(locale, target);
216
+ }
217
+
218
+ function absolute(baseUrl: string, path: string): string {
219
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
220
+ }
221
+
222
+ /** The landing page's `ItemList` — one `ListItem` per entry, in the directory's order. */
223
+ export function directoryItemListJsonLd(
224
+ entries: readonly ListingEntryPublic[],
225
+ urlFor: (entry: ListingEntryPublic) => string,
226
+ ) {
227
+ return {
228
+ "@context": "https://schema.org" as const,
229
+ "@type": "ItemList",
230
+ itemListElement: entries.map((entry, i) => ({
231
+ "@type": "ListItem",
232
+ position: i + 1,
233
+ url: urlFor(entry),
234
+ name: entry.name,
235
+ })),
236
+ };
237
+ }
238
+
239
+ /** An entry page's own node, typed by its listicle's `entryType`. */
240
+ export function entryJsonLd(entry: ListingEntryPublic, entryType: string, url: string) {
241
+ return {
242
+ "@context": "https://schema.org" as const,
243
+ "@type": entryType,
244
+ "@id": url,
245
+ name: entry.name,
246
+ ...(entry.summary !== null && { description: entry.summary }),
247
+ ...(entry.url !== null && { url: entry.url }),
248
+ ...(entry.logoMarkUrl !== null && { image: entry.logoMarkUrl }),
249
+ };
250
+ }
251
+
252
+ // ── The extension ───────────────────────────────────────────────────────────
253
+
254
+ export function createListingExtension(deps: ListingExtensionDeps = {}): ListingExtension {
255
+ const siteKey = deps.siteKey ?? (() => process.env["AILK_LISTING_PUBLIC_KEY"]);
256
+ const pathname =
257
+ deps.pathname ?? (async () => (await headers()).get("x-pathname"));
258
+ const locales = deps.locales ?? (() => readLocales());
259
+ // Resolved per call, never at module load: the layout imports this module,
260
+ // so it must load even where the api-client is partially stubbed.
261
+ const fetchDirectory =
262
+ deps.fetchDirectory ??
263
+ ((key: string, landingPage: string, locale: string) =>
264
+ projectListings.directory(key, landingPage, locale));
265
+ const loadUi = deps.loadUi ?? loadListingUi;
266
+ const translate =
267
+ deps.translate ??
268
+ (async (locale: string): Promise<ListingTranslator> => {
269
+ const t = await getTranslations({ namespace: "listings", locale });
270
+ return (key, values) => t(key, values);
271
+ });
272
+ const baseUrl = deps.baseUrl ?? resolveBaseUrl;
273
+ const now = deps.now ?? Date.now;
274
+
275
+ type ReadOutcome = { directory: ListingDirectory | null; failed: boolean };
276
+ const memo = new Map<string, { at: number; value: Promise<ReadOutcome> }>();
277
+
278
+ async function readOnce(key: string, landingPage: string, locale: string): Promise<ReadOutcome> {
279
+ let timer: ReturnType<typeof setTimeout> | undefined;
280
+ const timeout = new Promise<"timeout">((resolve) => {
281
+ timer = setTimeout(() => resolve("timeout"), READ_TIMEOUT_MS);
282
+ });
283
+ const fail = (reason: string): ReadOutcome => {
284
+ console.warn("[listings] directory read failed; rendering the page without it", {
285
+ landingPage,
286
+ reason,
287
+ });
288
+ return { directory: null, failed: true };
289
+ };
290
+ try {
291
+ const res = await Promise.race([fetchDirectory(key, landingPage, locale), timeout]);
292
+ if (res === "timeout") return fail("timeout");
293
+ if (!res.ok) return fail(`status ${res.status}`);
294
+ const parsed = listingDirectoryResponseSchema.safeParse(res.body);
295
+ if (!parsed.success) return fail("schema");
296
+ return { directory: parsed.data, failed: false };
297
+ } catch (err) {
298
+ unstable_rethrow(err);
299
+ return fail("error");
300
+ } finally {
301
+ clearTimeout(timer);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * At most one read per (key, landing page, locale) per 30 s in this
307
+ * process — 5 s after a failed read.
308
+ */
309
+ async function memoRead(key: string, landingPage: string, locale: string) {
310
+ const memoKey = `${key}\u0000${landingPage}\u0000${locale}`;
311
+ const hit = memo.get(memoKey);
312
+ if (hit) {
313
+ const outcome = await hit.value;
314
+ const ttl = outcome.failed ? MEMO_FAILURE_TTL_MS : MEMO_TTL_MS;
315
+ if (now() - hit.at < ttl) return outcome.directory;
316
+ // A concurrent caller may already have replaced the stale entry.
317
+ if (memo.get(memoKey) === hit) memo.delete(memoKey);
318
+ else return memoRead(key, landingPage, locale);
319
+ }
320
+ if (memo.size >= MEMO_MAX_KEYS) memo.delete(memo.keys().next().value!);
321
+ const value = readOnce(key, landingPage, locale);
322
+ memo.set(memoKey, { at: now(), value });
323
+ return (await value).directory;
324
+ }
325
+ /** One read per request, shared by `generateMetadata` and the page. */
326
+ const read = cache(memoRead);
327
+
328
+ /**
329
+ * The directory for a path — `null` with NO read without a key, for an
330
+ * untrusted path, for a hub that is not a page, or without the paid
331
+ * package. The key is checked first, so a keyless site reads no header, no
332
+ * locale list and no hub either.
333
+ */
334
+ async function directoryFor(
335
+ pathValue: () => Promise<string | null>,
336
+ segmentCount: 1 | 2,
337
+ slug: string,
338
+ locale: string,
339
+ hubExists?: HubExists,
340
+ ): Promise<{ directory: ListingDirectory; ui: ListingUiModule; path: ListingPath } | null> {
341
+ const key = siteKey();
342
+ if (!key) return null;
343
+ // A dotted path skips the middleware, so its `x-pathname` is the client's;
344
+ // its dot can only sit in the locale segment, since any other dotted
345
+ // segment fails `SEGMENT` or the slug match. So a locale this site does
346
+ // not enable answers null, never a read (SEC-7521-C1).
347
+ if (!locales().includes(locale)) return null;
348
+ let path: ListingPath | null;
349
+ try {
350
+ path = parseListingPath(await pathValue(), locales(), segmentCount, slug);
351
+ } catch (err) {
352
+ // `headers()` signals dynamic rendering by throwing; that is Next's to see.
353
+ unstable_rethrow(err);
354
+ return null;
355
+ }
356
+ if (path === null) return null;
357
+ if (hubExists !== undefined && !(await hubExists())) return null;
358
+ const ui = await loadUi();
359
+ if (ui === null) return null;
360
+ const directory = await read(key, path.landingPage, locale);
361
+ return directory === null ? null : { directory, ui, path };
362
+ }
363
+
364
+ async function entryFor(
365
+ pathValue: () => Promise<string | null>,
366
+ slug: string,
367
+ locale: string,
368
+ hubExists?: HubExists,
369
+ ) {
370
+ const found = await directoryFor(pathValue, 2, slug, locale, hubExists);
371
+ const entry = found?.directory.entries.find((e) => e.slug === slug);
372
+ return found && entry ? { ...found, entry } : null;
373
+ }
374
+
375
+ type FoundEntry = Awaited<ReturnType<typeof entryFor>>;
376
+
377
+ async function renderEntry(
378
+ found: FoundEntry,
379
+ slug: string,
380
+ locale: string,
381
+ ): Promise<ReactElement | null> {
382
+ if (found === null) return null;
383
+ const { ui, directory, entry, path } = found;
384
+ const t = await translate(locale);
385
+ const url = absolute(baseUrl(), entryHref(locale, path, slug));
386
+ return (
387
+ <>
388
+ <JsonLdScript data={entryJsonLd(entry, directory.entryType, url)} />
389
+ <main id="main">
390
+ <ui.ListingDetail
391
+ entry={entry}
392
+ websiteLabel={t("website")}
393
+ profileHeading={t("profileHeading")}
394
+ />
395
+ </main>
396
+ </>
397
+ );
398
+ }
399
+
400
+ function entryMetadata(found: FoundEntry): Metadata | null {
401
+ if (found === null) return null;
402
+ return {
403
+ title: found.entry.name,
404
+ ...(found.entry.summary !== null && { description: found.entry.summary }),
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Called by the route directly, not through `consultPageExtension`, so it
410
+ * carries that wrapper's fail-soft itself: Next's signals propagate, any
411
+ * other failure answers `null` and the route 404s as it would without it.
412
+ */
413
+ async function failSoft<T>(work: () => Promise<T | null>): Promise<T | null> {
414
+ try {
415
+ return await work();
416
+ } catch (err) {
417
+ unstable_rethrow(err);
418
+ console.warn("[listings] entry render failed; answering as if absent");
419
+ return null;
420
+ }
421
+ }
422
+
423
+ function entryAt(path: string, slug: string, locale: string, hubExists: HubExists) {
424
+ return failSoft(async () =>
425
+ renderEntry(await entryFor(async () => path, slug, locale, hubExists), slug, locale),
426
+ );
427
+ }
428
+
429
+ function entryMetadataAt(path: string, slug: string, locale: string, hubExists: HubExists) {
430
+ return failSoft(async () =>
431
+ entryMetadata(await entryFor(async () => path, slug, locale, hubExists)),
432
+ );
433
+ }
434
+
435
+ return {
436
+ async appendix({ slug, locale }: PageExtensionContext): Promise<ReactNode | null> {
437
+ const found = await directoryFor(pathname, 1, slug, locale);
438
+ if (found === null || found.directory.entries.length === 0) return null;
439
+ const { ui, directory, path } = found;
440
+ const t = await translate(locale);
441
+ const hrefFor = (entry: ListingEntryPublic) => entryHref(locale, path, entry.slug);
442
+ const origin = baseUrl();
443
+ return (
444
+ <>
445
+ <ui.ListingShelves
446
+ entries={directory.entries}
447
+ hrefFor={hrefFor}
448
+ activeCopy={{ title: t("activeTitle"), empty: t("activeEmpty") }}
449
+ closedCopy={{ title: t("closedTitle"), empty: t("closedEmpty") }}
450
+ featuredLabel={t("featured")}
451
+ waitlistCountLabel={(count) => t("waitlistCount", { count })}
452
+ />
453
+ <JsonLdScript
454
+ data={directoryItemListJsonLd(directory.entries, (e) => absolute(origin, hrefFor(e)))}
455
+ />
456
+ </>
457
+ );
458
+ },
459
+ async detailFallback({ slug, locale }: PageExtensionContext) {
460
+ return renderEntry(await entryFor(pathname, slug, locale), slug, locale);
461
+ },
462
+ async detailMetadata({ slug, locale }: PageExtensionContext) {
463
+ return entryMetadata(await entryFor(pathname, slug, locale));
464
+ },
465
+ entryAt,
466
+ entryMetadataAt,
467
+ };
468
+ }
469
+
470
+ /** The app's one extension, registered for every `renderPage` under the locale layout. */
471
+ export const listingExtension = createListingExtension();
472
+ setPageExtension(listingExtension);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The structural contracts base code loads paid section packages through
3
+ * (#7536) — what `apps/web/lib/paid-sections.generated.ts` types each loader
4
+ * with, so a keyless scaffold can stub a package it does not carry.
5
+ *
6
+ * Hand-authored and in the base tree: it names ONLY the members base code
7
+ * uses and imports no paid package. In the monorepo, `tsc` checks each present
8
+ * `import()` against its contract, so a contract that drifts from the real
9
+ * package is a type error here.
10
+ */
11
+
12
+ /** `@working-theory/ui-commerce`: the one export `commerce-sections.ts` calls. */
13
+ export interface UiCommerceContract {
14
+ registerCommerceSections(): void;
15
+ }
16
+
17
+ /**
18
+ * `@working-theory/ui-signal-collectors`: no member is consumed yet — the
19
+ * directory surface (#7521) widens this to name `ListingShelves` and
20
+ * `ListingDetail`.
21
+ */
22
+ export type UiSignalCollectorsContract = Record<never, never>;
@@ -0,0 +1,16 @@
1
+ // paid-sections.generated.ts — the paid section packages this site carries.
2
+ //
3
+ // GENERATED by create-ailk from MODULE_SLICES (`paidSectionsPackage`) and
4
+ // this app's package.json dependencies (#7536). Do not edit: every scaffold
5
+ // and `create-ailk add-module` rewrites it. A carried package is a bundled
6
+ // dynamic import; one this site does not carry is a stub that answers null.
7
+ import type {
8
+ UiCommerceContract,
9
+ UiSignalCollectorsContract,
10
+ } from "./paid-section-contracts";
11
+
12
+ export const loadUiSignalCollectors =
13
+ async (): Promise<UiSignalCollectorsContract | null> => null;
14
+
15
+ export const loadUiCommerce = async (): Promise<UiCommerceContract | null> =>
16
+ null;
@@ -262,6 +262,16 @@
262
262
  "heading": "Projects",
263
263
  "emptyState": "Projects listing coming soon."
264
264
  },
265
+ "listings": {
266
+ "activeTitle": "Open now",
267
+ "activeEmpty": "Nothing listed yet.",
268
+ "closedTitle": "Past entries",
269
+ "closedEmpty": "Nothing here yet.",
270
+ "featured": "Featured",
271
+ "waitlistCount": "{count, plural, one {# person waiting} other {# people waiting}}",
272
+ "website": "Visit website",
273
+ "profileHeading": "Details"
274
+ },
265
275
  "purchase": {
266
276
  "success": {
267
277
  "title": "Payment successful",
@@ -768,39 +778,63 @@
768
778
  "notFoundHeadline": "Page not found",
769
779
  "notFoundDescription": "There is no page at this address. It may have been renamed or removed.",
770
780
  "editor": {
771
- "tabForm": "Details",
772
- "tabBody": "Body",
773
781
  "tabPreview": "Preview",
774
- "tabCode": "Code",
782
+ "toolChanges": "Show unsaved changes",
783
+ "backLabel": "Pages",
775
784
  "statusDraft": "Draft",
776
785
  "statusNotYetLive": "Not yet live",
777
786
  "statusPublished": "Published",
778
- "frontmatterHeading": "Page details",
787
+ "publishedSwitch": "Published",
788
+ "publishBlockedHint": "Fix the items under For you before publishing.",
789
+ "unsaved": "Unsaved",
790
+ "unsavedChanges": "Unsaved changes",
791
+ "discardButton": "Discard",
792
+ "loading": "Loading…",
793
+ "loadError": "Could not load this page. Try again.",
794
+ "retryButton": "Try again",
779
795
  "frontmatterTitle": "Title",
780
796
  "frontmatterSlug": "Slug",
781
797
  "frontmatterDescription": "Description",
782
- "issuesHeading": "Fix these before publishing",
783
- "saveButton": "Save draft",
798
+ "titlePlaceholder": "Page title",
799
+ "fieldPageType": "Page type",
800
+ "toolbarLabel": "Formatting",
801
+ "formatMenu": "Page actions",
802
+ "closeSheet": "Close",
803
+ "toolH1": "Heading 1",
804
+ "toolH2": "Heading 2",
805
+ "toolH3": "Heading 3",
806
+ "toolBold": "Bold",
807
+ "toolItalic": "Italic",
808
+ "toolBulletList": "Bulleted list",
809
+ "toolNumberedList": "Numbered list",
810
+ "toolLink": "Link",
811
+ "toolImage": "Insert image",
812
+ "issueRequired": "{field} is required.",
813
+ "issueInvalid": "{field} isn't a valid choice.",
814
+ "forYou": {
815
+ "typeFix": "Blocks publish",
816
+ "typeSuggestion": "Suggestion",
817
+ "fixWhy": "The page can't go live until this is fixed.",
818
+ "fixOutcome": "Once it is fixed, the Published switch turns on.",
819
+ "descriptionTitle": "Add a search description",
820
+ "descriptionWhy": "Search engines and AI answers show this line under the page's title. Without it they write their own.",
821
+ "descriptionOutcome": "A sentence you chose appears wherever this page is listed.",
822
+ "altTitle": "{count, plural, one {Describe # image} other {Describe # images}}",
823
+ "altWhy": "Screen readers and search engines can't see an image without alt text.",
824
+ "altOutcome": "Every reader gets the image's meaning, and the page is easier to find."
825
+ },
826
+ "saveButton": "Save",
784
827
  "saving": "Saving…",
785
828
  "saved": "Saved",
786
829
  "saveError": "Could not save. Try again.",
787
- "publishButton": "Publish",
788
- "publishing": "Publishing…",
789
- "published": "Published",
790
830
  "publishError": "Could not publish. Try again.",
791
- "unpublishButton": "Unpublish",
792
- "unpublishing": "Unpublishing…",
793
- "unpublished": "Unpublished",
794
831
  "unpublishError": "Could not unpublish. Try again.",
795
832
  "conflictHeading": "Someone else saved this page",
796
833
  "conflictMessage": "This page changed since you opened it. Reload to take their version, or overwrite it with yours.",
797
834
  "conflictReload": "Reload their version",
798
835
  "conflictOverwrite": "Overwrite with mine",
799
- "imageInsertLabel": "Insert an image",
800
- "imageInsertHint": "The image is uploaded and its markdown inserted at the cursor.",
801
836
  "imageAlt": "Image",
802
837
  "imageInsertError": "Could not upload that image. Try again.",
803
- "codeHeading": "Composed MDX",
804
838
  "diffLegendAdded": "Added",
805
839
  "diffLegendRemoved": "Removed",
806
840
  "diffLegendUnchanged": "Unchanged"
@@ -0,0 +1,57 @@
1
+ // oss: false
2
+ /**
3
+ * #7536 AC-4 — `ensureCommerceSectionsRegistered()` loads the paid package
4
+ * through the generated registry and fails loud: absence (a stub's `null`)
5
+ * answers `false`, a present package whose import rejects propagates, and one
6
+ * process registers at most once. Each case isolates the module so its
7
+ * memoised `registration` starts fresh.
8
+ */
9
+ const REGISTRY = "../../../lib/paid-sections.generated";
10
+
11
+ type Ensure = () => Promise<boolean>;
12
+
13
+ function load(loadUiCommerce: jest.Mock): Ensure {
14
+ let ensure: Ensure | undefined;
15
+ jest.isolateModules(() => {
16
+ jest.doMock(REGISTRY, () => ({ loadUiCommerce }));
17
+ ensure = (
18
+ require("../commerce-sections") as { ensureCommerceSectionsRegistered: Ensure }
19
+ ).ensureCommerceSectionsRegistered;
20
+ });
21
+ if (!ensure) throw new Error("commerce-sections did not load");
22
+ return ensure;
23
+ }
24
+
25
+ afterEach(() => {
26
+ jest.dontMock(REGISTRY);
27
+ });
28
+
29
+ describe("ensureCommerceSectionsRegistered", () => {
30
+ it("registers once and answers true when the registry loads the package", async () => {
31
+ const spy = jest.fn();
32
+ const loader = jest.fn().mockResolvedValue({ registerCommerceSections: spy });
33
+ const ensure = load(loader);
34
+
35
+ await expect(ensure()).resolves.toBe(true);
36
+ await expect(ensure()).resolves.toBe(true);
37
+ expect(spy).toHaveBeenCalledTimes(1);
38
+ expect(loader).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ it("answers false and registers nothing when the registry answers null (package not carried)", async () => {
42
+ const loader = jest.fn().mockResolvedValue(null);
43
+ const ensure = load(loader);
44
+
45
+ await expect(ensure()).resolves.toBe(false);
46
+ expect(loader).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it("lets a rejected import reject — never degrades to false — and stays memoised", async () => {
50
+ const loader = jest.fn().mockRejectedValue(new Error("chunk"));
51
+ const ensure = load(loader);
52
+
53
+ await expect(ensure()).rejects.toThrow("chunk");
54
+ await expect(ensure()).rejects.toThrow("chunk");
55
+ expect(loader).toHaveBeenCalledTimes(1);
56
+ });
57
+ });