@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,528 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/apex-landing. Do not edit. -->
2
+
3
+ # apex-landing
4
+
5
+ Apex landing page composed from other features — hero copy from template-resolver, prices and caps from tier-engine — rendered to one static HTML string.
6
+
7
+ ## README
8
+
9
+ ## Screenshot renderer
10
+
11
+ `bun run screenshot` renders the sample page and shoots a full-page PNG via
12
+ Playwright (`page.setContent` — no server, because the renderer is pure). The
13
+ spec also paints a product-board mock (`hero-app.png`), uses it in the hero
14
+ `.shot-frame`, then writes `lightbox.png` with the overlay open. Docs embed
15
+ `screenshots/landing.png` and `screenshots/apex/lightbox.png`.
16
+
17
+ ## GEO/AEO: schema.org JSON-LD
18
+
19
+ `buildLandingPage`'s `head.schemaJson` combines `organizationSchema` +
20
+ `webPageSchema` (from the `seo` feature) into one `@graph` block — Organization
21
+ + WebPage nodes in a single `<script type="application/ld+json">`, which
22
+ `renderApexPage` already knows how to emit. This is the seam an app extends
23
+ with `faqPageSchema` for an FAQ section, or its own custom schema.org type.
24
+
25
+ ## Site discovery: sitemap.xml / llms.txt
26
+
27
+ The recipe itself has no server (`renderApexPage` is a pure function — nothing
28
+ to boot), but `src/__tests__/seo-routes.integration.test.ts` shows how an app
29
+ mounts `createSeoFeature` alongside the thin `GET /` route that serves
30
+ `renderLanding(...)`, and exercises `/sitemap.xml` + `/llms.txt` as real HTTP
31
+ requests via `setupTestStack`.
32
+
33
+ ## Run
34
+
35
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
36
+
37
+ ```bash
38
+ bun test # pure-function seams + the seo-mounted integration test
39
+ bun run screenshot # → screenshots/landing.png + lightbox.png
40
+ ```
41
+
42
+ ## Source files
43
+
44
+ ### src/__tests__/landing.test.ts
45
+
46
+ ```ts
47
+ import { describe, expect, test } from "bun:test";
48
+ import { buildLandingPage, renderLanding, SAMPLE_PLANS } from "../feature";
49
+
50
+ describe("apex-landing: template-resolver seam", () => {
51
+ test("uses the seeded block body when present", () => {
52
+ const html = renderLanding({
53
+ blocks: new Map([["index:hero.title", "Custom seeded headline"]]),
54
+ plans: SAMPLE_PLANS,
55
+ });
56
+ expect(html).toContain("Custom seeded headline");
57
+ expect(html).not.toContain("Ship your roadmap, not your spreadsheet");
58
+ });
59
+
60
+ test("falls back to the baked-in default when the block is missing", () => {
61
+ const page = buildLandingPage({ plans: SAMPLE_PLANS });
62
+ const hero = page.sections.find((s) => s.kind === "hero");
63
+ if (hero?.kind !== "hero") throw new Error("no hero section");
64
+ expect(hero.title).toBe("Ship your roadmap, not your spreadsheet");
65
+ });
66
+ });
67
+
68
+ describe("apex-landing: tier-engine seam", () => {
69
+ test("formats the paid price and per-month suffix from the plan config", () => {
70
+ const html = renderLanding({ plans: SAMPLE_PLANS });
71
+ expect(html).toContain("12.00 €");
72
+ expect(html).toContain("/month");
73
+ });
74
+
75
+ test("renders the plan cap, and Infinity as unlimited", () => {
76
+ const html = renderLanding({ plans: SAMPLE_PLANS });
77
+ expect(html).toContain("3 projects");
78
+ expect(html).toContain("50 projects");
79
+ expect(html).toContain("Unlimited projects");
80
+ });
81
+
82
+ test("free and enterprise plans show no /month suffix", () => {
83
+ const page = buildLandingPage({ plans: SAMPLE_PLANS });
84
+ const pricing = page.sections.find((s) => s.kind === "pricing-grid");
85
+ if (pricing?.kind !== "pricing-grid") throw new Error("no pricing section");
86
+ const free = pricing.tiers.find((t) => t.name === "Free");
87
+ const ent = pricing.tiers.find((t) => t.name === "Enterprise");
88
+ expect(free?.priceSuffix).toBeUndefined();
89
+ expect(ent?.priceSuffix).toBeUndefined();
90
+ expect(free?.amount).toBe("0 €");
91
+ expect(ent?.amount).toBe("Let's talk");
92
+ });
93
+
94
+ test("marks the featured plan with a badge", () => {
95
+ const page = buildLandingPage({ plans: SAMPLE_PLANS });
96
+ const pricing = page.sections.find((s) => s.kind === "pricing-grid");
97
+ if (pricing?.kind !== "pricing-grid") throw new Error("no pricing section");
98
+ const pro = pricing.tiers.find((t) => t.name === "Pro");
99
+ expect(pro?.featured).toBe(true);
100
+ expect(pro?.badge).toBe("Popular");
101
+ });
102
+ });
103
+
104
+ describe("apex-landing: GEO/AEO schema.org JSON-LD", () => {
105
+ test("renders an @graph with Organization + WebPage nodes", () => {
106
+ const html = renderLanding({ plans: SAMPLE_PLANS });
107
+ expect(html).toContain('<script type="application/ld+json">');
108
+ expect(html).toContain('"@type":"Organization"');
109
+ expect(html).toContain('"@type":"WebPage"');
110
+ expect(html).toContain('"name":"Tasklane"');
111
+ });
112
+
113
+ test("WebPage node picks up the seeded meta.title block", () => {
114
+ const html = renderLanding({
115
+ blocks: new Map([["index:meta.title", "Custom SEO Title"]]),
116
+ plans: SAMPLE_PLANS,
117
+ });
118
+ expect(html).toContain('"name":"Custom SEO Title"');
119
+ });
120
+ });
121
+
122
+ describe("apex-landing: hero screenshot + lightbox", () => {
123
+ test("hero includes shot-frame and apex lightbox chrome", () => {
124
+ const html = renderLanding({ plans: SAMPLE_PLANS });
125
+ expect(html).toContain('class="shot-frame"');
126
+ expect(html).toContain("/screenshots/hero-app.png");
127
+ expect(html).toContain('<dialog id="apex-lightbox"');
128
+ });
129
+ });
130
+ ```
131
+
132
+ ### src/__tests__/seo-routes.integration.test.ts
133
+
134
+ ```ts
135
+ // Shows the seo feature mounted alongside a real (albeit toy) apex route —
136
+ // the landing recipe itself stays a pure function (renderApexPage in,
137
+ // HTML string out, nothing to boot); this is the thin httpRoute an app
138
+ // would add to actually serve it, wired to a live setupTestStack so
139
+ // /sitemap.xml and /llms.txt are exercised as real HTTP requests, not just
140
+ // asserted against the ApexPage data shape.
141
+
142
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
143
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
144
+ import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
145
+ import { defineFeature, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
146
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
147
+ import { renderLanding, SAMPLE_PLANS } from "../feature";
148
+
149
+ const LANDING_URL = "https://tasklane.example/";
150
+
151
+ const landingFeature = defineFeature("landing", (r) => {
152
+ r.describe("Serves the recipe's renderLanding() output at GET /.");
153
+ r.httpRoute({
154
+ method: "GET",
155
+ path: "/",
156
+ anonymous: true,
157
+ handler: (c) => c.html(renderLanding({ plans: SAMPLE_PLANS })),
158
+ });
159
+ return {};
160
+ });
161
+
162
+ const seo = createSeoFeature({
163
+ sitemapEntries: () => [{ loc: LANDING_URL, changefreq: "weekly" }],
164
+ });
165
+
166
+ let stack: TestStack;
167
+
168
+ beforeAll(async () => {
169
+ stack = await setupTestStack({
170
+ features: [createConfigFeature(), landingFeature, seo],
171
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
172
+ });
173
+ });
174
+
175
+ afterAll(async () => {
176
+ await stack.cleanup();
177
+ });
178
+
179
+ describe("apex-landing + seo :: mounted together on one stack", () => {
180
+ test("GET / serves the landing page, including the schema.org JSON-LD", async () => {
181
+ const res = await stack.app.request("/");
182
+ expect(res.status).toBe(200);
183
+ const html = await res.text();
184
+ expect(html).toContain('<script type="application/ld+json">');
185
+ expect(html).toContain('"@type":"Organization"');
186
+ expect(html).toContain('"@type":"WebPage"');
187
+ });
188
+
189
+ test("GET /sitemap.xml lists the landing page URL", async () => {
190
+ const res = await stack.app.request("/sitemap.xml");
191
+ expect(res.status).toBe(200);
192
+ expect(res.headers.get("content-type")).toContain("application/xml");
193
+ const xml = await res.text();
194
+ expect(xml).toContain(`<loc>${LANDING_URL}</loc>`);
195
+ });
196
+
197
+ test("GET /llms.txt lists the landing page URL", async () => {
198
+ const res = await stack.app.request("/llms.txt");
199
+ expect(res.status).toBe(200);
200
+ const text = await res.text();
201
+ expect(text).toContain(LANDING_URL);
202
+ });
203
+ });
204
+ ```
205
+
206
+ ### src/constants.ts
207
+
208
+ ```ts
209
+ /** Product UI mock in the hero `.shot-frame` — not the full-page docs shot. */
210
+ export const HERO_SCREENSHOT = {
211
+ src: "/screenshots/hero-app.png",
212
+ alt: "Tasklane planning board — click to enlarge",
213
+ width: 720,
214
+ height: 480,
215
+ } as const;
216
+ ```
217
+
218
+ ### src/feature.ts
219
+
220
+ ```ts
221
+ // Apex landing page — composed from OTHER features' data.
222
+ //
223
+ // `renderApexPage(page)` is a pure function: a typed `ApexPage` in, one HTML
224
+ // string out. The interesting part is where that page's CONTENT comes from. An
225
+ // app does not hard-code its landing copy or prices — it pulls them from the
226
+ // features it already runs, so the marketing page can never drift from the
227
+ // product:
228
+ //
229
+ // • hero headline / tagline ← template-resolver (kind `text-block`, keyed by slug)
230
+ // • prices and plan caps ← tier-engine (the app's plan config)
231
+ //
232
+ // `buildLandingPage` below shows both seams. It takes the data those features
233
+ // expose and assembles an `ApexPage`; the app serves `renderApexPage(page)` as
234
+ // one static, cacheable response.
235
+
236
+ import { organizationSchema, webPageSchema } from "@cosmicdrift/kumiko-bundled-features/seo";
237
+ import {
238
+ type ApexPage,
239
+ type ApexPricingTier,
240
+ renderApexPage,
241
+ } from "@cosmicdrift/kumiko-headless/apex";
242
+ import { HERO_SCREENSHOT } from "./constants";
243
+
244
+ // --- Inputs: shapes the surrounding features hand you -----------------------
245
+
246
+ /** Editable copy as the `template-resolver` feature projects it: a content string
247
+ * per stable slug. A real app fills this Map from the template-resolver read model. */
248
+ export type ContentBlocks = ReadonlyMap<string, string>;
249
+
250
+ /** One plan as the app's `tier-engine` config exposes it. */
251
+ export type PlanInfo = {
252
+ readonly key: string;
253
+ readonly name: string;
254
+ readonly tagline: string;
255
+ /** `null` = free or on-request; the renderer just shows the `amount` text. */
256
+ readonly monthlyEur: number | null;
257
+ /** Usage cap from the tier config; `Infinity` = unlimited. */
258
+ readonly maxProjects: number;
259
+ readonly benefits: readonly string[];
260
+ readonly featured?: boolean;
261
+ };
262
+
263
+ export type LandingInput = {
264
+ /** From `template-resolver`. Omit a slug and the baked-in fallback is used, so the
265
+ * page renders fully even before anything is seeded. */
266
+ readonly blocks?: ContentBlocks;
267
+ /** From `tier-engine`. */
268
+ readonly plans: readonly PlanInfo[];
269
+ /** Override hero `.shot-frame` src — screenshot runner passes a data: URL. */
270
+ readonly heroScreenshot?: typeof HERO_SCREENSHOT;
271
+ };
272
+
273
+ // --- The two feature seams --------------------------------------------------
274
+
275
+ /** template-resolver seam: block content if seeded, else the fallback baked in here. */
276
+ function block(blocks: ContentBlocks | undefined, slug: string, fallback: string): string {
277
+ return blocks?.get(slug) ?? fallback;
278
+ }
279
+
280
+ function formatEuro(n: number): string {
281
+ return `${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
282
+ }
283
+
284
+ function planAmount(plan: PlanInfo): string {
285
+ if (plan.monthlyEur === null) return plan.key === "enterprise" ? "Let's talk" : "0 €";
286
+ return formatEuro(plan.monthlyEur);
287
+ }
288
+
289
+ function planCap(plan: PlanInfo): string {
290
+ return Number.isFinite(plan.maxProjects)
291
+ ? `${plan.maxProjects.toLocaleString("en-US")} projects`
292
+ : "Unlimited projects";
293
+ }
294
+
295
+ /** tier-engine seam: one plan from the config → one Apex pricing card. */
296
+ function toPricingTier(plan: PlanInfo): ApexPricingTier {
297
+ const paid = plan.monthlyEur !== null;
298
+ return {
299
+ name: plan.name,
300
+ tagline: plan.tagline,
301
+ amount: planAmount(plan),
302
+ priceSuffix: paid ? "/month" : undefined,
303
+ featured: plan.featured,
304
+ badge: plan.featured ? "Popular" : undefined,
305
+ capLine: planCap(plan),
306
+ benefits: plan.benefits,
307
+ cta: {
308
+ label: plan.key === "enterprise" ? "Contact us" : `Choose ${plan.name}`,
309
+ href: plan.key === "enterprise" ? "/contact" : "/signup",
310
+ variant: plan.featured ? "primary" : "secondary",
311
+ },
312
+ };
313
+ }
314
+
315
+ // --- Assembly ---------------------------------------------------------------
316
+
317
+ const FEATURE_ICON = {
318
+ bolt: '<path d="M13 2 4 14h6l-1 8 9-12h-6z"/>',
319
+ shield: '<path d="M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z"/><path d="m9 12 2 2 4-4"/>',
320
+ layers: '<path d="m12 3 9 5-9 5-9-5z"/><path d="m3 13 9 5 9-5"/>',
321
+ } as const;
322
+
323
+ const BRAND_TOKENS = `:root{
324
+ --bg:#ffffff; --bg-card:#ffffff; --bg-muted:#f6f7f9;
325
+ --border:#e6e8ec; --fg:#0f1729; --fg-muted:#475067; --fg-subtle:#6b7280;
326
+ --primary:#4f46e5; --primary-hover:#4338ca; --primary-fg:#ffffff;
327
+ --accent:#4f46e5; --accent-fg:#ffffff; --accent-hover:#6366f1;
328
+ --status-ok:#16a34a; --shadow:0 12px 30px -12px rgba(15,23,42,.25);
329
+ --footer-cols:3;
330
+ --font-body:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
331
+ --font-mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;
332
+ }`;
333
+
334
+ export function buildLandingPage(input: LandingInput): ApexPage {
335
+ return {
336
+ theme: "light",
337
+ brand: { tokensCss: BRAND_TOKENS },
338
+ head: {
339
+ lang: "en",
340
+ title: block(input.blocks, "index:meta.title", "Tasklane — ship your roadmap"),
341
+ description: block(
342
+ input.blocks,
343
+ "index:meta.description",
344
+ "Plan, track and ship product work in one place. Free to start, no credit card.",
345
+ ),
346
+ canonicalUrl: "https://tasklane.example/",
347
+ // GEO/AEO seam: schema.org JSON-LD via the seo feature's pure builders
348
+ // (organizationSchema/webPageSchema), fed straight into ApexHead —
349
+ // renderApexPage already emits the <script type="application/ld+json">
350
+ // block, this recipe only supplies the data. Two node types share one
351
+ // block via @graph (a single schemaJson can't hold two top-level @type
352
+ // values). Site-discovery (sitemap.xml/llms.txt) for an app that also
353
+ // mounts managed-pages/legal-pages is demonstrated separately in
354
+ // samples/apps/use-all-bundled's createSeoFeature wiring — this recipe
355
+ // has no server/feature of its own to attach routes to.
356
+ schemaJson: {
357
+ "@context": "https://schema.org",
358
+ "@graph": [
359
+ organizationSchema({ name: "Tasklane", url: "https://tasklane.example/" }),
360
+ webPageSchema({
361
+ name: block(input.blocks, "index:meta.title", "Tasklane — ship your roadmap"),
362
+ url: "https://tasklane.example/",
363
+ description: block(
364
+ input.blocks,
365
+ "index:meta.description",
366
+ "Plan, track and ship product work in one place. Free to start, no credit card.",
367
+ ),
368
+ }),
369
+ ],
370
+ },
371
+ },
372
+ header: {
373
+ brand: { href: "/", label: "Tasklane" },
374
+ // A dropdown nav entry (kind:"menu") renders an icon/title/desc panel on
375
+ // hover + keyboard focus; plain links sit beside it. One typed shape, no
376
+ // app CSS — the renderer ships the dropdown styling.
377
+ navLinks: [
378
+ {
379
+ kind: "menu",
380
+ label: "Product",
381
+ items: [
382
+ {
383
+ icon: FEATURE_ICON.bolt,
384
+ title: "Live planning",
385
+ desc: "Reorder, estimate and assign as you type.",
386
+ href: "#features",
387
+ },
388
+ {
389
+ icon: FEATURE_ICON.layers,
390
+ title: "Portfolio view",
391
+ desc: "Every project on one screen, at a glance.",
392
+ href: "#features",
393
+ },
394
+ {
395
+ icon: FEATURE_ICON.shield,
396
+ title: "Your data, yours",
397
+ desc: "EU-hosted, no tracking, export any time.",
398
+ href: "#features",
399
+ },
400
+ ],
401
+ footer: { label: "See all features", href: "#features" },
402
+ },
403
+ { label: "Pricing", href: "#pricing" },
404
+ ],
405
+ actions: [{ label: "Sign in", href: "/login", variant: "link" }],
406
+ },
407
+ sections: [
408
+ {
409
+ kind: "hero",
410
+ title: block(input.blocks, "index:hero.title", "Ship your roadmap, not your spreadsheet"),
411
+ tagline: block(
412
+ input.blocks,
413
+ "index:hero.tagline",
414
+ "Plan, track and ship product work in one place — free to start, no credit card.",
415
+ ),
416
+ ctas: [
417
+ { label: "Start free", href: "/signup", variant: "primary" },
418
+ { label: "See pricing", href: "#pricing", variant: "secondary" },
419
+ ],
420
+ metaHtml: "<strong>Free forever plan.</strong> No credit card required.",
421
+ screenshot: input.heroScreenshot ?? HERO_SCREENSHOT,
422
+ },
423
+ {
424
+ kind: "feature-grid",
425
+ id: "features",
426
+ eyebrow: "Features",
427
+ heading: "Everything your team needs to ship",
428
+ sub: "From a single backlog to the whole portfolio — without tab-juggling.",
429
+ items: [
430
+ {
431
+ icon: FEATURE_ICON.bolt,
432
+ title: "Live planning",
433
+ desc: "Reorder, estimate and assign in one board that updates as you type.",
434
+ },
435
+ {
436
+ icon: FEATURE_ICON.layers,
437
+ title: "Portfolio view",
438
+ desc: "Every project on one screen: progress, owners and ship dates at a glance.",
439
+ },
440
+ {
441
+ icon: FEATURE_ICON.shield,
442
+ title: "Your data, yours",
443
+ desc: "EU-hosted, no tracking, export any time. Privacy is the baseline, not a tier.",
444
+ },
445
+ ],
446
+ },
447
+ {
448
+ kind: "pricing-grid",
449
+ id: "pricing",
450
+ eyebrow: "Pricing",
451
+ heading: "Fair prices, clear limits",
452
+ sub: "Start free. Upgrade when your portfolio grows. Cancel any time.",
453
+ tiers: input.plans.map(toPricingTier),
454
+ },
455
+ {
456
+ kind: "final-cta",
457
+ heading: "Your first board in two minutes",
458
+ sub: "Start free — no credit card, no install.",
459
+ cta: { label: "Start free", href: "/signup", variant: "primary" },
460
+ },
461
+ ],
462
+ footer: {
463
+ brand: { label: "Tasklane" },
464
+ tagline: "Ship your roadmap.",
465
+ columns: [
466
+ {
467
+ heading: "Product",
468
+ links: [
469
+ { label: "Features", href: "#features" },
470
+ { label: "Pricing", href: "#pricing" },
471
+ ],
472
+ },
473
+ {
474
+ heading: "Company",
475
+ links: [
476
+ { label: "About", href: "/about" },
477
+ { label: "Contact", href: "/contact" },
478
+ ],
479
+ },
480
+ {
481
+ heading: "Legal",
482
+ links: [
483
+ { label: "Privacy", href: "/legal/privacy" },
484
+ { label: "Imprint", href: "/legal/imprint" },
485
+ ],
486
+ },
487
+ ],
488
+ bottomLeft: "© 2026 Tasklane",
489
+ bottomRight: "Made with Kumiko",
490
+ },
491
+ };
492
+ }
493
+
494
+ /** Convenience: input → final HTML string an app serves as its landing page. */
495
+ export function renderLanding(input: LandingInput): string {
496
+ return renderApexPage(buildLandingPage(input));
497
+ }
498
+
499
+ /** Sample plan config, as a `tier-engine`-backed app would expose it.
500
+ * Used by the test and the screenshot runner. */
501
+ export const SAMPLE_PLANS: readonly PlanInfo[] = [
502
+ {
503
+ key: "free",
504
+ name: "Free",
505
+ tagline: "For your first project",
506
+ monthlyEur: null,
507
+ maxProjects: 3,
508
+ benefits: ["Live planning board", "Up to 5 collaborators", "CSV export"],
509
+ },
510
+ {
511
+ key: "pro",
512
+ name: "Pro",
513
+ tagline: "For a growing team",
514
+ monthlyEur: 12,
515
+ maxProjects: 50,
516
+ benefits: ["Everything in Free", "Portfolio view", "Custom fields", "Priority support"],
517
+ featured: true,
518
+ },
519
+ {
520
+ key: "enterprise",
521
+ name: "Enterprise",
522
+ tagline: "For the whole org",
523
+ monthlyEur: null,
524
+ maxProjects: Number.POSITIVE_INFINITY,
525
+ benefits: ["Everything in Pro", "SSO & SCIM", "Dedicated instance", "SLA"],
526
+ },
527
+ ];
528
+ ```