create-ailk 0.2.0 → 0.3.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 (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. package/templates/apps/web/source.config.ts +0 -78
@@ -1,398 +0,0 @@
1
- /**
2
- * @file playbook-compile.ts
3
- * @description FEAT-074 U4a (#4715) — compiles a scored `targeting-maturity`
4
- * signup into a person-scoped Playbook and persists it append-only.
5
- *
6
- * `compilePlaybook` is a PURE function (`rules/execution-substrate.md` — the
7
- * compile is a payload, never a per-respondent model call): given a scored
8
- * `WaitlistScore` row's facts (`{id, tier, ruleTrace}`), the play library,
9
- * and the lead id, it returns `{source, markdown}` — no Prisma, no network,
10
- * no clock, no filesystem read of its own. `source` validates against
11
- * `vendor/playbook-method/playbook-source.schema.json` (U1) and passes
12
- * `vendor/playbook-method/check-playbook.py` — see this file's own test
13
- * suite, which runs the vendored checker against every golden.
14
- *
15
- * `compileAndStorePlaybook` is the thin, fail-soft Prisma-touching wrapper —
16
- * the sibling to A2's `waitlist-scoring.ts` glue. It loads the vendored
17
- * play library LAZILY, memoized, on first use inside its own try block (a
18
- * `readFileSync`, not a static `import … from "*.json"` — `apps/api`'s
19
- * `tsconfig.json` sets `rootDir: "./src"` + `composite: true`, and the
20
- * vendored library lives at the repo root, outside that boundary;
21
- * `apps-paid/design-reference-gallery/lib/corpus.ts` is this repo's own
22
- * precedent for reading vendored repo-root content via `readFileSync`
23
- * rather than a cross-boundary static import). Deliberately NOT at module
24
- * scope (security-review finding M1, PR #4727): a missing/corrupt vendored
25
- * file must degrade like any other compile failure (D4), never throw at
26
- * import time where no catch in this file can reach it. `compilePlaybook`
27
- * itself never touches the filesystem.
28
- *
29
- * The path is resolved via `process.cwd()`-relative candidate probing
30
- * (`apps/api/src/lib/locales.ts`'s own `resolveProjectYamlPath` pattern),
31
- * NOT `import.meta.url` — Jest's ts-jest preset here compiles to CommonJS
32
- * (`jest.config.js`), which cannot execute `import.meta` (Node throws
33
- * `SyntaxError: Cannot use 'import.meta' outside a module`); this repo's own
34
- * `moduleNameMapper` comment on `@working-theory/database` names the exact
35
- * same v7 prisma-client `import.meta.url` incompatibility and mocks around
36
- * it. `process.cwd()` has no such split — it resolves correctly whether the
37
- * process was started from `apps/api/` (dev/build) or from this package's
38
- * own test run.
39
- *
40
- * The unlock map (`unlocks_next`) is LIBRARY-DECLARED, resolved from
41
- * `levels[level].unlocks_next` — NEVER computed as `level + 1` (the
42
- * method's map is not monotonic: Level 5 unlocks Level 7, Level 8 unlocks
43
- * Level 7 — spec §2.1, U3 Clarifications).
44
- */
45
-
46
- import { readFileSync } from "node:fs";
47
- import { join } from "node:path";
48
-
49
- import type { Prisma } from "@working-theory/database";
50
- import { logger } from "@working-theory/observability";
51
-
52
- import { prisma } from "../lib/prisma.js";
53
-
54
- // ─── Play-library types (the vendored targeting-maturity.plays.json shape) ──
55
-
56
- export interface PlaybookPlay {
57
- id: string;
58
- title: string;
59
- text: string;
60
- }
61
-
62
- export interface PlaybookLevelEntry {
63
- level: number;
64
- name?: string;
65
- use_if?: string;
66
- cautions?: string[];
67
- plays: PlaybookPlay[];
68
- unlocks_next: number | null;
69
- }
70
-
71
- export interface PlayLibrary {
72
- version: string;
73
- method: string;
74
- /** A bare pointer object at level 0 in the authored shape (D4) — the Gate's
75
- * own plays live in `levels[]` at `level: 0`; consulted only as a fallback
76
- * when `levels[]` carries no level-0 entry (mirrors
77
- * `check-playbook.py`'s `_library_entry_for_level`). */
78
- gate?: { level: number; plays?: PlaybookPlay[]; unlocks_next?: number | null };
79
- levels: PlaybookLevelEntry[];
80
- }
81
-
82
- // ─── The vendored play library — lazy-loaded, memoized (see header) ─────────
83
-
84
- const VENDORED_LIBRARY_RELATIVE = "vendor/playbook-method/targeting-maturity.plays.json";
85
-
86
- /** Probes `[cwd, cwd/.., cwd/../..]` for the vendored library — apps run
87
- * from their own dir in dev/build (`apps/api/`) but this package's own
88
- * tests may run from either `apps/api/` or the repo root (mirrors
89
- * `apps/api/src/lib/locales.ts`'s `resolveProjectYamlPath`). Falls back to
90
- * the cwd-relative path so a genuine miss's error names the expected
91
- * location. */
92
- export function resolveVendoredLibraryPath(cwd = process.cwd()): string {
93
- const candidates = [
94
- join(cwd, VENDORED_LIBRARY_RELATIVE),
95
- join(cwd, "..", VENDORED_LIBRARY_RELATIVE),
96
- join(cwd, "..", "..", VENDORED_LIBRARY_RELATIVE),
97
- ];
98
- for (const candidate of candidates) {
99
- try {
100
- readFileSync(candidate, "utf8");
101
- return candidate;
102
- } catch {
103
- // continue
104
- }
105
- }
106
- return candidates[0]!;
107
- }
108
-
109
- export function loadVendoredPlayLibrary(): PlayLibrary {
110
- return JSON.parse(readFileSync(resolveVendoredLibraryPath(), "utf8")) as PlayLibrary;
111
- }
112
-
113
- // LAZY + memoized (security-review finding M1, PR #4727): loading the
114
- // library at MODULE SCOPE would let a missing/corrupt vendored file throw
115
- // at import time — outside every try/catch this file adds, and outside the
116
- // fail-soft contract this whole unit exists to uphold. On a bundler that
117
- // cannot statically resolve the `process.cwd()`-relative repo-root path
118
- // (e.g. a serverless target with no `functions.includeFiles` entry for
119
- // `vendor/**`), that import-time throw would 500 EVERY request through this
120
- // module's static import chain (`waitlist-scoring.ts` → `flow-engine.ts` →
121
- // the route layer → the server entrypoint) — not merely fail to compile a
122
- // Playbook. Deferring the read to first use inside `compileAndStorePlaybook`'s
123
- // own try block (below) means a missing/corrupt file degrades exactly like
124
- // any other compile failure (D4: logged, the score is unaffected) instead of
125
- // taking the process down. Memoized so the read still happens at most once.
126
- let vendoredPlayLibraryCache: PlayLibrary | undefined;
127
- function vendoredPlayLibrary(): PlayLibrary {
128
- if (!vendoredPlayLibraryCache) {
129
- vendoredPlayLibraryCache = loadVendoredPlayLibrary();
130
- }
131
- return vendoredPlayLibraryCache;
132
- }
133
-
134
- /** The one experiment this compile hooks (D4) — every other experiment's
135
- * `scoreCompletedSignup` completion never reaches this module. */
136
- export const TARGETING_MATURITY_EXPERIMENT_KEY = "targeting-maturity";
137
-
138
- // ─── compilePlaybook — the pure payload (D1) ────────────────────────────────
139
-
140
- export interface PlaybookSource {
141
- artifact_type: "playbook";
142
- scope: "person";
143
- scope_ref: string;
144
- method: string;
145
- level: number;
146
- plays: string[];
147
- unlocks_next?: string[];
148
- compiled_from: string;
149
- }
150
-
151
- /** The WaitlistScore facts `compilePlaybook` reads — a subset of the Prisma
152
- * row, never the row itself (the pure function takes no Prisma type). */
153
- export interface CompilePlaybookScoreInput {
154
- /** `WaitlistScore.id` — becomes `source.compiled_from` (D2). */
155
- id: string;
156
- /** `WaitlistScore.tier` — a `level_N` string. */
157
- tier: string;
158
- /** `WaitlistScore.ruleTrace` — opaque at this boundary, mirroring
159
- * `packages/validation`'s own `ruleTrace: z.unknown()` convention; parsed
160
- * defensively for the Level-1 loop-back framing only (see
161
- * `decidingRefNames`). */
162
- ruleTrace: unknown;
163
- }
164
-
165
- export interface CompilePlaybookInput {
166
- score: CompilePlaybookScoreInput;
167
- plays: PlayLibrary;
168
- leadId: string;
169
- }
170
-
171
- export interface CompilePlaybookResult {
172
- source: PlaybookSource;
173
- markdown: string;
174
- }
175
-
176
- /** A `compilePlaybook` input that cannot be compiled — the library declares
177
- * no Level for the score's tier, or that Level has no plays (spec §5.3 — the
178
- * play-library owner's defect, never a compile branch). */
179
- export class PlaybookCompileError extends Error {}
180
-
181
- const TIER_LEVEL_RE = /^level_(\d+)$/;
182
-
183
- /** The library entry for `level` — `levels[]` first, the `gate` pointer as a
184
- * level-0 fallback only (mirrors `check-playbook.py`'s `_library_entry_for_level`,
185
- * the #8496 amendment). */
186
- function levelEntryFor(library: PlayLibrary, level: number): PlaybookLevelEntry | undefined {
187
- const fromLevels = library.levels.find((entry) => entry.level === level);
188
- if (fromLevels) return fromLevels;
189
- if (level === 0 && library.gate?.plays && library.gate.plays.length > 0) {
190
- return {
191
- level: 0,
192
- plays: library.gate.plays,
193
- unlocks_next: library.gate.unlocks_next ?? null,
194
- };
195
- }
196
- return undefined;
197
- }
198
-
199
- /**
200
- * The `ref(<name>)` def names that DECIDED the matched tier — every
201
- * `kind: "predicate"` trace entry whose `tier` equals the matched tier AND
202
- * whose `result` is `true`, with every `ref(...)` segment in its `path`
203
- * extracted (`RuleTraceEntry.path` embeds each traversed def as
204
- * `ref(<name>)`, per `evaluatePredicateNode`'s own path-building — there is
205
- * no separate `def` field to read instead). Duck-typed, not
206
- * `ruleTraceEntrySchema`-parsed: `ruleTrace` is `unknown` at every boundary
207
- * this function is reached from (Prisma `Json`, the dashboard's own
208
- * `z.unknown()`), and this compile never needs the full shape — only
209
- * whether a specific ref fired true for this tier.
210
- */
211
- function decidingRefNames(ruleTrace: unknown, tier: string): Set<string> {
212
- const names = new Set<string>();
213
- if (!Array.isArray(ruleTrace)) return names;
214
- for (const entry of ruleTrace) {
215
- if (typeof entry !== "object" || entry === null) continue;
216
- const e = entry as Record<string, unknown>;
217
- if (e["kind"] !== "predicate" || e["tier"] !== tier || e["result"] !== true) continue;
218
- const path = e["path"];
219
- if (typeof path !== "string") continue;
220
- for (const m of path.matchAll(/ref\(([^)]+)\)/g)) {
221
- names.add(m[1]!);
222
- }
223
- }
224
- return names;
225
- }
226
-
227
- function renderPlaybookMarkdown(args: {
228
- source: PlaybookSource;
229
- entry: PlaybookLevelEntry;
230
- unlockEntry: PlaybookLevelEntry | undefined;
231
- isLoopBackCohort: boolean;
232
- }): string {
233
- const { source, entry, unlockEntry, isLoopBackCohort } = args;
234
- const heading = entry.name ? `Level ${source.level} — ${entry.name}` : `Level ${source.level}`;
235
- const lines: string[] = [];
236
-
237
- lines.push(`# Playbook — ${heading}`, "");
238
-
239
- if (isLoopBackCohort) {
240
- lines.push(
241
- "> You reached Level 1 via the Level-6 loop-back — a confirmed-Fit " +
242
- "cohort (not paying customers) put you here. Once this Level has " +
243
- "given you a validated ICP filter, revisit at Level 7 or 8.",
244
- "",
245
- );
246
- }
247
-
248
- lines.push(
249
- "## §1. Identity",
250
- "",
251
- `**artifact_type:** \`${source.artifact_type}\``,
252
- `**scope:** \`${source.scope}\``,
253
- `**scope_ref:** \`${source.scope_ref}\``,
254
- `**method:** \`${source.method}\``,
255
- `**level:** \`${source.level}\``,
256
- `**compiled_from:** \`${source.compiled_from}\``,
257
- "",
258
- "<!-- machine-region -->",
259
- `<!-- playbook-source:\n${JSON.stringify(source)}\n-->`,
260
- "<!-- /machine-region -->",
261
- "",
262
- "---",
263
- "",
264
- "## §2. Plays",
265
- "",
266
- `### Level ${source.level}`,
267
- "",
268
- );
269
- for (const play of entry.plays) {
270
- lines.push(`- \`${play.id}\` — ${play.title}`);
271
- }
272
-
273
- if (unlockEntry && source.unlocks_next) {
274
- lines.push(
275
- "",
276
- "---",
277
- "",
278
- "## §3. Unlocks next",
279
- "",
280
- `### Level ${unlockEntry.level}`,
281
- "",
282
- );
283
- for (const play of unlockEntry.plays) {
284
- lines.push(`- \`${play.id}\` — ${play.title}`);
285
- }
286
- }
287
-
288
- return lines.join("\n") + "\n";
289
- }
290
-
291
- /**
292
- * Compile a scored `targeting-maturity` signup into a person-scoped
293
- * Playbook — pure, deterministic, no I/O (D1). Throws `PlaybookCompileError`
294
- * when the library declares no Level for the score's tier, or that Level
295
- * has no plays — the caller (`compileAndStorePlaybook`) is fail-soft over
296
- * this throw (D4, spec §5.3).
297
- */
298
- export function compilePlaybook(input: CompilePlaybookInput): CompilePlaybookResult {
299
- const { score, plays, leadId } = input;
300
-
301
- const match = TIER_LEVEL_RE.exec(score.tier);
302
- if (!match) {
303
- throw new PlaybookCompileError(
304
- `cannot compile a Playbook for tier "${score.tier}" — not a level_N tier`,
305
- );
306
- }
307
- const level = Number(match[1]);
308
-
309
- const entry = levelEntryFor(plays, level);
310
- if (!entry || entry.plays.length === 0) {
311
- throw new PlaybookCompileError(
312
- `the play library declares no plays for Level ${level} — a library defect, not a compile branch (spec §5.3)`,
313
- );
314
- }
315
-
316
- let unlockEntry: PlaybookLevelEntry | undefined;
317
- let unlockIds: string[] | undefined;
318
- if (entry.unlocks_next !== null && entry.unlocks_next !== undefined) {
319
- unlockEntry = levelEntryFor(plays, entry.unlocks_next);
320
- if (unlockEntry && unlockEntry.plays.length > 0) {
321
- unlockIds = unlockEntry.plays.map((play) => play.id);
322
- }
323
- }
324
-
325
- const source: PlaybookSource = {
326
- artifact_type: "playbook",
327
- scope: "person",
328
- scope_ref: leadId,
329
- method: plays.method,
330
- level,
331
- plays: entry.plays.map((play) => play.id),
332
- ...(unlockIds ? { unlocks_next: unlockIds } : {}),
333
- compiled_from: score.id,
334
- };
335
-
336
- const isLoopBackCohort =
337
- level === 1 && decidingRefNames(score.ruleTrace, score.tier).has("has_confirmed_fit_cohort");
338
-
339
- const markdown = renderPlaybookMarkdown({ source, entry, unlockEntry, isLoopBackCohort });
340
-
341
- return { source, markdown };
342
- }
343
-
344
- // ─── compileAndStorePlaybook — the fail-soft Prisma-touching wrapper (D4) ───
345
-
346
- export interface CompileAndStorePlaybookInput {
347
- siteId: string;
348
- leadId: string;
349
- experimentKey: string;
350
- scoreId: string;
351
- tier: string;
352
- ruleTrace: unknown;
353
- }
354
-
355
- /**
356
- * Compile + append a `PlaybookInstance` row. Fail-soft over BOTH the pure
357
- * compile (`PlaybookCompileError` or any other throw) and the Prisma write —
358
- * a compile/store failure is logged and never fails the signup or the score
359
- * (D4). The caller (`waitlist-scoring.ts`) awaits this unconditionally; it
360
- * never throws.
361
- */
362
- export async function compileAndStorePlaybook(
363
- input: CompileAndStorePlaybookInput,
364
- ): Promise<void> {
365
- const { siteId, leadId, experimentKey, scoreId, tier, ruleTrace } = input;
366
- try {
367
- const { source, markdown } = compilePlaybook({
368
- score: { id: scoreId, tier, ruleTrace },
369
- plays: vendoredPlayLibrary(),
370
- leadId,
371
- });
372
-
373
- await prisma.playbookInstance.create({
374
- data: {
375
- siteId,
376
- leadId,
377
- experimentKey,
378
- scoreId,
379
- tier,
380
- level: source.level,
381
- ruleTrace: (ruleTrace ?? null) as unknown as Prisma.InputJsonValue,
382
- source: source as unknown as Prisma.InputJsonValue,
383
- markdown,
384
- },
385
- });
386
- } catch (err) {
387
- logger.error(
388
- {
389
- siteId,
390
- experimentKey,
391
- leadId,
392
- kind: "playbook_compile_failed",
393
- errKind: err instanceof Error ? err.constructor.name : typeof err,
394
- },
395
- "[playbook-compile] compile/store failed — the score is unaffected",
396
- );
397
- }
398
- }
@@ -1,263 +0,0 @@
1
- /**
2
- * @file playbook-render.ts
3
- * @description FEAT-074 U4b (#4718) — renders a stored `PlaybookInstance` to
4
- * a customer-facing PDF, on request.
5
- *
6
- * `renderPlaybookPdf({source, markdown, locale})` is a pure-TypeScript
7
- * renderer (D2, spec §3): a small typed template lays out the Level/Gate
8
- * heading, the Level's bound plays (title + body, resolved from `source`'s
9
- * `plays`/`unlocks_next` id lists against the LIVE vendored play library —
10
- * D1's "a template or renderer fix improves every already-issued Playbook"
11
- * intent, so this deliberately reads the library fresh at render time
12
- * rather than trusting anything frozen into the stored `markdown` snapshot),
13
- * and the unlocks-next block. `markdown` is accepted per the spec's literal
14
- * `{source, markdown, locale}` signature (matching `compilePlaybook`'s own
15
- * `{source, markdown}` output pair, the exact shape `PlaybookInstance`
16
- * persists) but is NOT parsed for content in this unit — `source`'s id
17
- * lists plus a live library lookup already carry everything §2.1 requires
18
- * (the Level label, the bound plays, the unlocks-next block); reserved for
19
- * a future direct-render use.
20
- *
21
- * Renderer choice (D2, recorded per the dispatch's own requirement): PDF.js
22
- * (mozilla's) sibling project `pdf-lib` (v1.17.1, MIT) — pure TypeScript,
23
- * zero native dependencies (its own runtime deps —
24
- * `@pdf-lib/standard-fonts`, `@pdf-lib/upng`, `pako`, `tslib` — are all pure
25
- * JS/TS too), no headless browser, no postinstall download. It embeds the
26
- * 14 PDF standard fonts (Helvetica here) with no external font files, and
27
- * exposes `font.widthOfTextAtSize` for the manual word-wrap this file
28
- * implements (pdf-lib has no built-in text-flow/wrap primitive — a small
29
- * typed template has no need for one).
30
- *
31
- * A play id from `source.plays`/`source.unlocks_next` that the CURRENT
32
- * vendored library no longer declares is a library defect, never a render
33
- * branch — mirrors `playbook-compile.ts`'s own `PlaybookCompileError`
34
- * posture ("a library defect, not a compile branch") applied symmetrically
35
- * to render.
36
- *
37
- * Every CHROME label (the Level/Gate heading text, the "Plays"/"Unlocks
38
- * next" section titles) is a `LocalizedString` resolved via
39
- * `resolveLocalizedString` (`@working-theory/validation`, the ONE shared
40
- * resolution rule — flow-i18n.ts's own header) against the request
41
- * `locale`. Play TITLES/BODIES are read straight from the vendored library,
42
- * which carries no `LocalizedString` shape of its own (English-only at the
43
- * source, the same convention `compilePlaybook`'s stored `markdown` already
44
- * uses) — the i18n requirement (spec §2.1) scopes to the renderer's own
45
- * chrome, not to library content. `renderPlaybookPdf`'s fixed
46
- * `{source, markdown, locale}` signature carries no separate flow
47
- * `defaultLocale` field, so this file's label table declares `en` as its
48
- * sole `defaultLocale` (a module constant, `LABEL_DEFAULT_LOCALE` below) —
49
- * every label below currently declares only `en`, so this is presently a
50
- * no-op fallback, kept for structural correctness against a future
51
- * multi-locale label table.
52
- *
53
- * The Gate (Level 0) is labelled "Gate," never "Level 0" (spec §2.1's
54
- * never-say-Level-0 rule — mirrors the operator dashboard's own
55
- * `waitlistDashboard.level.gate` key, U4a D5, and the AC-1 Gate filename
56
- * convention `playbook-gate.pdf`).
57
- */
58
-
59
- import { resolveLocalizedString } from "@working-theory/validation";
60
- import { PDFDocument, PageSizes, StandardFonts, type PDFFont, type PDFPage } from "pdf-lib";
61
-
62
- import { loadVendoredPlayLibrary, type PlaybookPlay, type PlaybookSource, type PlayLibrary } from "./playbook-compile.js";
63
-
64
- // ─── Chrome labels — the CUSTOMER-FACING structural text (spec §2.1) ──────
65
-
66
- const LABEL_DEFAULT_LOCALE = "en";
67
-
68
- const LABELS = {
69
- documentTitle: { en: "Your Playbook" },
70
- gate: { en: "Gate" },
71
- /** `{n}` is a literal placeholder — substituted after resolution. */
72
- levelLabel: { en: "Level {n}" },
73
- plays: { en: "Plays" },
74
- unlocksNext: { en: "Unlocks next" },
75
- } as const satisfies Record<string, Record<string, string>>;
76
-
77
- function label(key: keyof typeof LABELS, locale: string): string {
78
- return resolveLocalizedString(LABELS[key], locale, LABEL_DEFAULT_LOCALE);
79
- }
80
-
81
- /** Level 0 is "Gate," never "Level 0" (spec §2.1 — the never-say-Level-0 rule). */
82
- function levelHeading(level: number, locale: string): string {
83
- if (level === 0) return label("gate", locale);
84
- return label("levelLabel", locale).replace("{n}", String(level));
85
- }
86
-
87
- // ─── The vendored play library — a per-play id index ───────────────────────
88
-
89
- interface IndexedPlay {
90
- play: PlaybookPlay;
91
- level: number;
92
- levelName: string | undefined;
93
- }
94
-
95
- /** A `source.plays`/`source.unlocks_next` id the CURRENT vendored library no
96
- * longer declares — a library defect, never a render branch (mirrors
97
- * `PlaybookCompileError`'s identical posture in `playbook-compile.ts`). */
98
- export class PlaybookRenderError extends Error {}
99
-
100
- /** Flattens every Level's `plays[]` into one id-keyed index, carrying each
101
- * play's owning Level number + name — so an unlocks-next play id resolves
102
- * both its own title/text AND which Level it belongs to, with no need to
103
- * parse a `tm-<level>.<n>` id-format convention the schema does not
104
- * guarantee. */
105
- function buildPlayIndex(library: PlayLibrary): Map<string, IndexedPlay> {
106
- const index = new Map<string, IndexedPlay>();
107
- for (const entry of library.levels) {
108
- for (const play of entry.plays) {
109
- index.set(play.id, { play, level: entry.level, levelName: entry.name });
110
- }
111
- }
112
- return index;
113
- }
114
-
115
- function resolvePlays(index: Map<string, IndexedPlay>, ids: string[]): IndexedPlay[] {
116
- return ids.map((id) => {
117
- const entry = index.get(id);
118
- if (!entry) {
119
- throw new PlaybookRenderError(
120
- `the vendored play library declares no play "${id}" — a library defect, never a render branch (mirrors playbook-compile.ts's PlaybookCompileError posture)`,
121
- );
122
- }
123
- return entry;
124
- });
125
- }
126
-
127
- // ─── Word wrap + pagination (pdf-lib has no built-in text-flow primitive) ──
128
-
129
- const PAGE_SIZE = PageSizes.Letter; // [612, 792]
130
- const MARGIN = 54; // 0.75in
131
- const CONTENT_WIDTH = PAGE_SIZE[0] - MARGIN * 2;
132
- const TOP_Y = PAGE_SIZE[1] - MARGIN;
133
- const BOTTOM_Y = MARGIN;
134
-
135
- /** Greedy word-wrap against a font's measured width — pdf-lib exposes
136
- * `font.widthOfTextAtSize` but no wrap primitive of its own. */
137
- function wrapLine(text: string, font: PDFFont, size: number, maxWidth: number): string[] {
138
- const words = text.split(/\s+/).filter((w) => w.length > 0);
139
- const lines: string[] = [];
140
- let current = "";
141
- for (const word of words) {
142
- const candidate = current.length > 0 ? `${current} ${word}` : word;
143
- if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
144
- current = candidate;
145
- } else {
146
- if (current.length > 0) lines.push(current);
147
- current = word;
148
- }
149
- }
150
- if (current.length > 0) lines.push(current);
151
- return lines;
152
- }
153
-
154
- interface PdfWriter {
155
- heading(text: string, size: number): void;
156
- subheading(text: string, size: number): void;
157
- paragraph(text: string, size: number): void;
158
- spacer(height: number): void;
159
- save(): Promise<Uint8Array>;
160
- }
161
-
162
- /** A closure-scoped writer (`@working-theory/design-system/no-undisciplined-class`
163
- * forbids `this`-bearing stateful classes) — the mutable page/y cursor lives
164
- * in this function's own local variables, never on an instance. */
165
- async function createPdfWriter(): Promise<PdfWriter> {
166
- const doc = await PDFDocument.create();
167
- const regular = await doc.embedFont(StandardFonts.Helvetica);
168
- const bold = await doc.embedFont(StandardFonts.HelveticaBold);
169
-
170
- let page: PDFPage = doc.addPage(PAGE_SIZE);
171
- let y = TOP_Y;
172
-
173
- function ensureSpace(height: number): void {
174
- if (y - height < BOTTOM_Y) {
175
- page = doc.addPage(PAGE_SIZE);
176
- y = TOP_Y;
177
- }
178
- }
179
-
180
- function drawLines(lines: string[], font: PDFFont, size: number, lineHeight: number): void {
181
- for (const line of lines) {
182
- ensureSpace(lineHeight);
183
- page.drawText(line, { x: MARGIN, y: y - size, size, font });
184
- y -= lineHeight;
185
- }
186
- }
187
-
188
- return {
189
- heading(text, size) {
190
- ensureSpace(size * 1.6 + 8);
191
- drawLines(wrapLine(text, bold, size, CONTENT_WIDTH), bold, size, size * 1.4);
192
- y -= 8;
193
- },
194
- subheading(text, size) {
195
- ensureSpace(size * 1.6 + 6);
196
- drawLines(wrapLine(text, bold, size, CONTENT_WIDTH), bold, size, size * 1.35);
197
- y -= 6;
198
- },
199
- paragraph(text, size) {
200
- drawLines(wrapLine(text, regular, size, CONTENT_WIDTH), regular, size, size * 1.4);
201
- y -= 6;
202
- },
203
- spacer(height) {
204
- y -= height;
205
- },
206
- async save() {
207
- return doc.save();
208
- },
209
- };
210
- }
211
-
212
- // ─── renderPlaybookPdf ───────────────────────────────────────────────────────
213
-
214
- export interface RenderPlaybookPdfInput {
215
- source: PlaybookSource;
216
- /** Accepted per the spec's literal signature; not parsed for content in
217
- * this unit — see the file header. */
218
- markdown: string;
219
- locale: string;
220
- }
221
-
222
- /**
223
- * Renders a stored `PlaybookInstance` to a PDF: the Level/Gate heading, the
224
- * Level's bound plays (title + body from the LIVE vendored library), and
225
- * the unlocks-next block (spec §2.1). Throws `PlaybookRenderError` when the
226
- * current library no longer declares a play `source` names — a library
227
- * defect, never a render branch.
228
- */
229
- export async function renderPlaybookPdf(input: RenderPlaybookPdfInput): Promise<Uint8Array> {
230
- const { source, locale } = input;
231
-
232
- const library = loadVendoredPlayLibrary();
233
- const index = buildPlayIndex(library);
234
-
235
- const currentPlays = resolvePlays(index, source.plays);
236
- const unlockPlays = source.unlocks_next ? resolvePlays(index, source.unlocks_next) : [];
237
-
238
- const writer = await createPdfWriter();
239
-
240
- writer.heading(label("documentTitle", locale), 20);
241
- writer.subheading(levelHeading(source.level, locale), 15);
242
- writer.spacer(4);
243
-
244
- writer.subheading(label("plays", locale), 13);
245
- renderPlays(writer, currentPlays);
246
-
247
- if (unlockPlays.length > 0) {
248
- writer.spacer(10);
249
- const unlockLevel = unlockPlays[0]!.level;
250
- writer.subheading(`${label("unlocksNext", locale)} — ${levelHeading(unlockLevel, locale)}`, 13);
251
- renderPlays(writer, unlockPlays);
252
- }
253
-
254
- return writer.save();
255
- }
256
-
257
- function renderPlays(writer: PdfWriter, plays: IndexedPlay[]): void {
258
- for (const { play } of plays) {
259
- writer.paragraph(`• ${play.title}`, 11);
260
- writer.paragraph(play.text, 10);
261
- writer.spacer(4);
262
- }
263
- }