minutework 0.1.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 (203) hide show
  1. package/EXTERNAL_ALPHA.md +74 -0
  2. package/README.md +57 -0
  3. package/assets/claude-local/CLAUDE.md.template +45 -0
  4. package/assets/claude-local/bundle.json +22 -0
  5. package/assets/claude-local/skills/README.md +6 -0
  6. package/assets/claude-local/skills/app-pack-authoring.md +8 -0
  7. package/assets/claude-local/skills/event-bus.md +8 -0
  8. package/assets/claude-local/skills/ontology-mapping.md +8 -0
  9. package/assets/claude-local/skills/openclaw-skill-importer.md +7 -0
  10. package/assets/claude-local/skills/schema-engine.md +8 -0
  11. package/assets/claude-local/skills/secrets-runtime-bridge.md +9 -0
  12. package/assets/claude-local/skills/sidecar-generation.md +9 -0
  13. package/assets/templates/fastapi-sidecar/.env.example +8 -0
  14. package/assets/templates/fastapi-sidecar/README.md +77 -0
  15. package/assets/templates/fastapi-sidecar/poetry.lock +757 -0
  16. package/assets/templates/fastapi-sidecar/pyproject.toml +42 -0
  17. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/__init__.py +3 -0
  18. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/auth.py +70 -0
  19. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/__init__.py +3 -0
  20. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/bridge/client.py +71 -0
  21. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/logging_utils.py +25 -0
  22. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/main.py +85 -0
  23. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/receipts.py +24 -0
  24. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/settings.py +41 -0
  25. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/template_validation.py +26 -0
  26. package/assets/templates/fastapi-sidecar/src/fastapi_sidecar/worker.py +33 -0
  27. package/assets/templates/fastapi-sidecar/template.json +43 -0
  28. package/assets/templates/fastapi-sidecar/template.schema.json +160 -0
  29. package/assets/templates/fastapi-sidecar/tests/conftest.py +36 -0
  30. package/assets/templates/fastapi-sidecar/tests/test_app.py +39 -0
  31. package/assets/templates/fastapi-sidecar/tests/test_auth.py +32 -0
  32. package/assets/templates/fastapi-sidecar/tests/test_bridge_client.py +31 -0
  33. package/assets/templates/fastapi-sidecar/tests/test_materialization.py +55 -0
  34. package/assets/templates/fastapi-sidecar/tests/test_template_contract.py +49 -0
  35. package/assets/templates/fastapi-sidecar/tests/test_worker.py +7 -0
  36. package/assets/templates/fastapi-sidecar/tools/template/validate_template.py +20 -0
  37. package/assets/templates/next-tenant-app/.env.example +8 -0
  38. package/assets/templates/next-tenant-app/.storybook/main.ts +19 -0
  39. package/assets/templates/next-tenant-app/.storybook/preview.tsx +38 -0
  40. package/assets/templates/next-tenant-app/README.md +115 -0
  41. package/assets/templates/next-tenant-app/components.json +21 -0
  42. package/assets/templates/next-tenant-app/eslint.config.mjs +41 -0
  43. package/assets/templates/next-tenant-app/next-env.d.ts +6 -0
  44. package/assets/templates/next-tenant-app/next.config.ts +8 -0
  45. package/assets/templates/next-tenant-app/package-lock.json +9682 -0
  46. package/assets/templates/next-tenant-app/package.json +59 -0
  47. package/assets/templates/next-tenant-app/pnpm-lock.yaml +6062 -0
  48. package/assets/templates/next-tenant-app/postcss.config.mjs +8 -0
  49. package/assets/templates/next-tenant-app/src/app/api/auth/context/route.test.ts +90 -0
  50. package/assets/templates/next-tenant-app/src/app/api/auth/context/route.ts +78 -0
  51. package/assets/templates/next-tenant-app/src/app/api/auth/login/route.ts +31 -0
  52. package/assets/templates/next-tenant-app/src/app/api/auth/logout/route.ts +16 -0
  53. package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.test.ts +79 -0
  54. package/assets/templates/next-tenant-app/src/app/api/auth/password-change/route.ts +40 -0
  55. package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.test.ts +42 -0
  56. package/assets/templates/next-tenant-app/src/app/api/auth/password-status/route.ts +29 -0
  57. package/assets/templates/next-tenant-app/src/app/api/auth/session/route.ts +26 -0
  58. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.test.ts +40 -0
  59. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/[runId]/route.ts +47 -0
  60. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.test.ts +43 -0
  61. package/assets/templates/next-tenant-app/src/app/api/gateway/commands/route.ts +45 -0
  62. package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.test.ts +83 -0
  63. package/assets/templates/next-tenant-app/src/app/app/examples/runtime-commands/page.tsx +30 -0
  64. package/assets/templates/next-tenant-app/src/app/app/layout.tsx +20 -0
  65. package/assets/templates/next-tenant-app/src/app/app/page.test.ts +62 -0
  66. package/assets/templates/next-tenant-app/src/app/app/page.tsx +24 -0
  67. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.test.ts +70 -0
  68. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.tsx +57 -0
  69. package/assets/templates/next-tenant-app/src/app/blog/page.test.ts +42 -0
  70. package/assets/templates/next-tenant-app/src/app/blog/page.tsx +37 -0
  71. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.test.ts +70 -0
  72. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.tsx +55 -0
  73. package/assets/templates/next-tenant-app/src/app/docs/page.test.ts +42 -0
  74. package/assets/templates/next-tenant-app/src/app/docs/page.tsx +37 -0
  75. package/assets/templates/next-tenant-app/src/app/globals.css +70 -0
  76. package/assets/templates/next-tenant-app/src/app/layout.tsx +69 -0
  77. package/assets/templates/next-tenant-app/src/app/login/page.test.ts +55 -0
  78. package/assets/templates/next-tenant-app/src/app/login/page.tsx +33 -0
  79. package/assets/templates/next-tenant-app/src/app/page.test.ts +56 -0
  80. package/assets/templates/next-tenant-app/src/app/page.tsx +35 -0
  81. package/assets/templates/next-tenant-app/src/app/pricing/page.test.ts +55 -0
  82. package/assets/templates/next-tenant-app/src/app/pricing/page.tsx +35 -0
  83. package/assets/templates/next-tenant-app/src/app/providers.tsx +25 -0
  84. package/assets/templates/next-tenant-app/src/app/robots.test.ts +20 -0
  85. package/assets/templates/next-tenant-app/src/app/robots.ts +18 -0
  86. package/assets/templates/next-tenant-app/src/app/sitemap.test.ts +49 -0
  87. package/assets/templates/next-tenant-app/src/app/sitemap.ts +54 -0
  88. package/assets/templates/next-tenant-app/src/components/ui/button.tsx +59 -0
  89. package/assets/templates/next-tenant-app/src/components/ui/input.tsx +21 -0
  90. package/assets/templates/next-tenant-app/src/design-system/docs/governance.mdx +26 -0
  91. package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.stories.tsx +48 -0
  92. package/assets/templates/next-tenant-app/src/design-system/patterns/panel-frame.tsx +26 -0
  93. package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.stories.tsx +26 -0
  94. package/assets/templates/next-tenant-app/src/design-system/patterns/status-badge.tsx +35 -0
  95. package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
  96. package/assets/templates/next-tenant-app/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
  97. package/assets/templates/next-tenant-app/src/design-system/primitives/button.stories.tsx +37 -0
  98. package/assets/templates/next-tenant-app/src/design-system/primitives/button.ts +1 -0
  99. package/assets/templates/next-tenant-app/src/design-system/primitives/input.stories.tsx +26 -0
  100. package/assets/templates/next-tenant-app/src/design-system/primitives/input.ts +1 -0
  101. package/assets/templates/next-tenant-app/src/design-system/recipes/chrome.ts +28 -0
  102. package/assets/templates/next-tenant-app/src/design-system/tokens/foundation.css +31 -0
  103. package/assets/templates/next-tenant-app/src/design-system/tokens/index.css +3 -0
  104. package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.json +85 -0
  105. package/assets/templates/next-tenant-app/src/design-system/tokens/manifest.ts +87 -0
  106. package/assets/templates/next-tenant-app/src/design-system/tokens/semantic.css +105 -0
  107. package/assets/templates/next-tenant-app/src/design-system/tokens/theme.css +59 -0
  108. package/assets/templates/next-tenant-app/src/design-system/tokens/tokens.stories.tsx +71 -0
  109. package/assets/templates/next-tenant-app/src/features/auth/components/login-screen.tsx +198 -0
  110. package/assets/templates/next-tenant-app/src/features/dashboard/components/tenant-dashboard.tsx +153 -0
  111. package/assets/templates/next-tenant-app/src/features/examples/runtime-command-demo/components/runtime-command-demo.tsx +342 -0
  112. package/assets/templates/next-tenant-app/src/features/public-shell/components/content-article.tsx +66 -0
  113. package/assets/templates/next-tenant-app/src/features/public-shell/components/content-collection.tsx +108 -0
  114. package/assets/templates/next-tenant-app/src/features/public-shell/components/marketing-page-canvas.tsx +111 -0
  115. package/assets/templates/next-tenant-app/src/features/public-shell/components/public-site-shell.tsx +111 -0
  116. package/assets/templates/next-tenant-app/src/features/shell/components/private-app-shell.tsx +624 -0
  117. package/assets/templates/next-tenant-app/src/lib/app-routes.test.ts +20 -0
  118. package/assets/templates/next-tenant-app/src/lib/app-routes.ts +59 -0
  119. package/assets/templates/next-tenant-app/src/lib/content/__fixtures__/public-site-snapshot.ts +189 -0
  120. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +318 -0
  121. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +232 -0
  122. package/assets/templates/next-tenant-app/src/lib/content/contracts.ts +339 -0
  123. package/assets/templates/next-tenant-app/src/lib/content/custom-adapter.ts +5 -0
  124. package/assets/templates/next-tenant-app/src/lib/content/empty-state.ts +96 -0
  125. package/assets/templates/next-tenant-app/src/lib/platform/auth.server.test.ts +75 -0
  126. package/assets/templates/next-tenant-app/src/lib/platform/auth.server.ts +25 -0
  127. package/assets/templates/next-tenant-app/src/lib/platform/client.server.test.ts +170 -0
  128. package/assets/templates/next-tenant-app/src/lib/platform/client.server.ts +661 -0
  129. package/assets/templates/next-tenant-app/src/lib/platform/contracts.ts +131 -0
  130. package/assets/templates/next-tenant-app/src/lib/platform/endpoints.server.ts +34 -0
  131. package/assets/templates/next-tenant-app/src/lib/platform/env.server.test.ts +102 -0
  132. package/assets/templates/next-tenant-app/src/lib/platform/env.server.ts +87 -0
  133. package/assets/templates/next-tenant-app/src/lib/platform/route-response.ts +33 -0
  134. package/assets/templates/next-tenant-app/src/lib/platform/session.server.ts +108 -0
  135. package/assets/templates/next-tenant-app/src/lib/public-site.test.ts +20 -0
  136. package/assets/templates/next-tenant-app/src/lib/public-site.ts +49 -0
  137. package/assets/templates/next-tenant-app/src/lib/theme-config.ts +10 -0
  138. package/assets/templates/next-tenant-app/src/lib/theme.tsx +159 -0
  139. package/assets/templates/next-tenant-app/src/lib/utils.ts +6 -0
  140. package/assets/templates/next-tenant-app/template.json +27 -0
  141. package/assets/templates/next-tenant-app/template.schema.json +160 -0
  142. package/assets/templates/next-tenant-app/test/server-only-stub.ts +1 -0
  143. package/assets/templates/next-tenant-app/tools/design-system/build-token-manifest.mjs +3 -0
  144. package/assets/templates/next-tenant-app/tools/design-system/check-imports.mjs +9 -0
  145. package/assets/templates/next-tenant-app/tools/design-system/check-stories.mjs +9 -0
  146. package/assets/templates/next-tenant-app/tools/design-system/check-values.mjs +9 -0
  147. package/assets/templates/next-tenant-app/tools/design-system/checks.mjs +238 -0
  148. package/assets/templates/next-tenant-app/tools/design-system/eslint-plugin-design-system.mjs +184 -0
  149. package/assets/templates/next-tenant-app/tools/design-system/playwright.config.mjs +34 -0
  150. package/assets/templates/next-tenant-app/tools/design-system/run-checks.mjs +22 -0
  151. package/assets/templates/next-tenant-app/tools/design-system/shared.mjs +166 -0
  152. package/assets/templates/next-tenant-app/tools/design-system/visual.spec.ts +41 -0
  153. package/assets/templates/next-tenant-app/tools/template/validate-route-contract.mjs +39 -0
  154. package/assets/templates/next-tenant-app/tools/template/validate-template.mjs +45 -0
  155. package/assets/templates/next-tenant-app/tsconfig.json +42 -0
  156. package/assets/templates/next-tenant-app/vitest.config.ts +25 -0
  157. package/bin/minutework.js +40 -0
  158. package/dist/auth.d.ts +59 -0
  159. package/dist/auth.js +338 -0
  160. package/dist/auth.js.map +1 -0
  161. package/dist/browser.d.ts +1 -0
  162. package/dist/browser.js +26 -0
  163. package/dist/browser.js.map +1 -0
  164. package/dist/cli.d.ts +2 -0
  165. package/dist/cli.js +5 -0
  166. package/dist/cli.js.map +1 -0
  167. package/dist/compile.d.ts +20 -0
  168. package/dist/compile.js +121 -0
  169. package/dist/compile.js.map +1 -0
  170. package/dist/config.d.ts +25 -0
  171. package/dist/config.js +102 -0
  172. package/dist/config.js.map +1 -0
  173. package/dist/deploy-state.d.ts +35 -0
  174. package/dist/deploy-state.js +30 -0
  175. package/dist/deploy-state.js.map +1 -0
  176. package/dist/deploy.d.ts +22 -0
  177. package/dist/deploy.js +308 -0
  178. package/dist/deploy.js.map +1 -0
  179. package/dist/developer-client.d.ts +88 -0
  180. package/dist/developer-client.js +78 -0
  181. package/dist/developer-client.js.map +1 -0
  182. package/dist/index.d.ts +27 -0
  183. package/dist/index.js +290 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/init.d.ts +22 -0
  186. package/dist/init.js +421 -0
  187. package/dist/init.js.map +1 -0
  188. package/dist/launcher.d.ts +1 -0
  189. package/dist/launcher.js +50 -0
  190. package/dist/launcher.js.map +1 -0
  191. package/dist/paths.d.ts +12 -0
  192. package/dist/paths.js +33 -0
  193. package/dist/paths.js.map +1 -0
  194. package/dist/sandbox.d.ts +30 -0
  195. package/dist/sandbox.js +852 -0
  196. package/dist/sandbox.js.map +1 -0
  197. package/dist/state.d.ts +46 -0
  198. package/dist/state.js +82 -0
  199. package/dist/state.js.map +1 -0
  200. package/dist/tokens.d.ts +14 -0
  201. package/dist/tokens.js +293 -0
  202. package/dist/tokens.js.map +1 -0
  203. package/package.json +43 -0
@@ -0,0 +1,189 @@
1
+ import type { PublicContentSnapshot } from "@/lib/content/contracts";
2
+
3
+ export const publicSiteFixtureSnapshot: PublicContentSnapshot = {
4
+ site: {
5
+ siteName: "MinuteWork Combined Starter",
6
+ siteDescription:
7
+ "SEO-first marketing, docs, blog, and authenticated workspace routes served from one governed Next.js starter.",
8
+ organizationName: "MinuteWork",
9
+ footerBlurb:
10
+ "One codebase for public discovery, operator sign-in, and the private Builder-ready workspace shell.",
11
+ primaryCta: {
12
+ label: "Sign In",
13
+ href: "/login",
14
+ },
15
+ secondaryCta: {
16
+ label: "Docs",
17
+ href: "/docs",
18
+ },
19
+ primaryNavigation: [
20
+ { label: "Home", href: "/" },
21
+ { label: "Pricing", href: "/pricing" },
22
+ { label: "Docs", href: "/docs" },
23
+ { label: "Blog", href: "/blog" },
24
+ ],
25
+ collections: {
26
+ docs: {
27
+ eyebrow: "Docs",
28
+ title: "Starter Docs",
29
+ description: "Route public content through the MinuteWork public-site snapshot seam.",
30
+ },
31
+ blog: {
32
+ eyebrow: "Blog",
33
+ title: "Starter Blog",
34
+ description: "Notes about the combined public and private starter.",
35
+ },
36
+ },
37
+ },
38
+ marketingPages: [
39
+ {
40
+ pageKey: "home",
41
+ path: "/",
42
+ heroEyebrow: "Public Site API",
43
+ heroTitle: "Public routes now read from MinuteWork content snapshots.",
44
+ heroBody:
45
+ "The tenant-app starter loads public-site content from the MinuteWork gateway during preview and build, while private routes stay behind the platform-session BFF.",
46
+ primaryCta: {
47
+ label: "Sign In",
48
+ href: "/login",
49
+ },
50
+ secondaryCta: {
51
+ label: "Read the docs",
52
+ href: "/docs",
53
+ },
54
+ sections: [
55
+ {
56
+ eyebrow: "Authoring",
57
+ title: "Preview reads stay server-only",
58
+ body:
59
+ "The Next server fetches preview or live-safe public-site content with a server-only content token.",
60
+ },
61
+ {
62
+ eyebrow: "Publishing",
63
+ title: "Live reads stay publication-safe",
64
+ body:
65
+ "Anonymous production traffic reads static output generated from a published-live snapshot contract.",
66
+ },
67
+ {
68
+ eyebrow: "Boundaries",
69
+ title: "Private app routes stay on the BFF path",
70
+ body:
71
+ "Public discovery routes and private workspace routes keep separate auth and data boundaries.",
72
+ },
73
+ ],
74
+ seo: {
75
+ title: "MinuteWork Combined Starter",
76
+ description:
77
+ "A combined starter with public-site snapshot reads for anonymous routes and a platform-session BFF for private routes.",
78
+ },
79
+ },
80
+ {
81
+ pageKey: "pricing",
82
+ path: "/pricing",
83
+ heroEyebrow: "Pricing",
84
+ heroTitle: "Static-first public pages with remote content by default.",
85
+ heroBody:
86
+ "Pricing, docs, and blog routes build from the same public-site snapshot contract, so developers do not have to maintain local seed content.",
87
+ primaryCta: {
88
+ label: "Get started",
89
+ href: "/login",
90
+ },
91
+ secondaryCta: {
92
+ label: "See docs",
93
+ href: "/docs",
94
+ },
95
+ sections: [
96
+ {
97
+ eyebrow: "DX",
98
+ title: "No seeded runtime mode",
99
+ body:
100
+ "The generated starter expects real public-site content from MinuteWork during development and build.",
101
+ },
102
+ {
103
+ eyebrow: "Safety",
104
+ title: "Empty snapshots still render",
105
+ body:
106
+ "Fresh properties can ship before authored content exists because built-in empty-state shells handle missing marketing pages.",
107
+ },
108
+ {
109
+ eyebrow: "Ops",
110
+ title: "One API for preview and live",
111
+ body:
112
+ "The same gateway path serves runtime-backed preview reads and published-live snapshots with an explicit source boundary.",
113
+ },
114
+ ],
115
+ seo: {
116
+ title: "MinuteWork Combined Starter Pricing",
117
+ description:
118
+ "MinuteWork public-site snapshots let pricing and landing pages stay static-first without local content seeds.",
119
+ },
120
+ },
121
+ ],
122
+ docs: [
123
+ {
124
+ kind: "docs",
125
+ slug: ["guides", "public-site-content"],
126
+ eyebrow: "Guide",
127
+ title: "Use the public-site snapshot API",
128
+ description:
129
+ "Configure the tenant-app starter to load preview and live-safe public-site content from MinuteWork.",
130
+ publishedAt: "2026-03-26T09:00:00.000Z",
131
+ readingTime: "3 min read",
132
+ body: {
133
+ intro:
134
+ "The public-site adapter now talks to the MinuteWork gateway with a server-only content token and unwraps a public-site snapshot envelope.",
135
+ sections: [
136
+ {
137
+ heading: "Environment contract",
138
+ paragraphs: [
139
+ "Use MW_CONTENT_API_TOKEN for server-only authentication.",
140
+ "Use MW_PUBLIC_SITE_PROPERTY_KEY to select the PublishedWebProperty backing the site.",
141
+ ],
142
+ },
143
+ {
144
+ heading: "Preview and live",
145
+ paragraphs: [
146
+ "MW_PUBLIC_SITE_ENV=preview fetches runtime-backed preview snapshots.",
147
+ "MW_PUBLIC_SITE_ENV=live fetches publication-safe published snapshots only.",
148
+ ],
149
+ },
150
+ ],
151
+ },
152
+ seo: {
153
+ title: "Use the public-site snapshot API",
154
+ description:
155
+ "Configure the MinuteWork tenant-app starter to fetch public-site snapshots during preview and build.",
156
+ },
157
+ },
158
+ ],
159
+ blog: [
160
+ {
161
+ kind: "blog",
162
+ slug: ["public-site-api-default"],
163
+ eyebrow: "Launch note",
164
+ title: "Public-site snapshots are now the starter default",
165
+ description:
166
+ "The generated tenant-app no longer depends on seeded public content for its public route surface.",
167
+ publishedAt: "2026-03-26T10:00:00.000Z",
168
+ readingTime: "2 min read",
169
+ body: {
170
+ intro:
171
+ "MinuteWork tenant-app starters now fetch public-site content from a gateway API using a dedicated server-only content token.",
172
+ sections: [
173
+ {
174
+ heading: "What changed",
175
+ paragraphs: [
176
+ "Seeded local public content is no longer the runtime path for generated starters.",
177
+ "Public routes render from a remote snapshot envelope with explicit preview and live boundaries.",
178
+ ],
179
+ },
180
+ ],
181
+ },
182
+ seo: {
183
+ title: "Public-site snapshots are now the starter default",
184
+ description:
185
+ "The tenant-app starter now defaults to server-only MinuteWork public-site content reads during preview and build.",
186
+ },
187
+ },
188
+ ],
189
+ };
@@ -0,0 +1,318 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type {
4
+ BlogEntrySummary,
5
+ DocsEntrySummary,
6
+ MarketingPageKey,
7
+ PublicContentAdapter,
8
+ PublicContentKind,
9
+ } from "@/lib/content/contracts";
10
+ import { publicSiteFixtureSnapshot } from "@/lib/content/__fixtures__/public-site-snapshot";
11
+ import {
12
+ MinuteWorkPublicSiteContentAdapter,
13
+ PUBLIC_CONTENT_REVALIDATE_SECONDS,
14
+ fetchPublicSiteSnapshotEnvelope,
15
+ } from "@/lib/content/adapter.server";
16
+
17
+ const originalEnv = { ...process.env };
18
+
19
+ function jsonResponse(body: unknown, status = 200) {
20
+ return new Response(JSON.stringify(body), {
21
+ status,
22
+ headers: {
23
+ "content-type": "application/json",
24
+ },
25
+ });
26
+ }
27
+
28
+ function normalizeSlugParts(slugParts: readonly string[]) {
29
+ return slugParts.map((part) => part.trim().toLowerCase()).filter(Boolean).join("/");
30
+ }
31
+
32
+ function getFixtureMarketingPage(pageKey: MarketingPageKey) {
33
+ return (
34
+ publicSiteFixtureSnapshot.marketingPages.find((page) => page.pageKey === pageKey) ??
35
+ null
36
+ );
37
+ }
38
+
39
+ function getFixtureEntry(kind: PublicContentKind, slugParts: readonly string[]) {
40
+ return (
41
+ publicSiteFixtureSnapshot[kind].find(
42
+ (entry) => normalizeSlugParts(entry.slug) === normalizeSlugParts(slugParts),
43
+ ) ?? null
44
+ );
45
+ }
46
+
47
+ function toDocsEntrySummary(
48
+ entry: typeof publicSiteFixtureSnapshot.docs[number],
49
+ ): DocsEntrySummary {
50
+ return {
51
+ description: entry.description,
52
+ eyebrow: entry.eyebrow,
53
+ kind: entry.kind,
54
+ publishedAt: entry.publishedAt,
55
+ readingTime: entry.readingTime,
56
+ slug: entry.slug,
57
+ title: entry.title,
58
+ };
59
+ }
60
+
61
+ function toBlogEntrySummary(
62
+ entry: typeof publicSiteFixtureSnapshot.blog[number],
63
+ ): BlogEntrySummary {
64
+ return {
65
+ description: entry.description,
66
+ eyebrow: entry.eyebrow,
67
+ kind: entry.kind,
68
+ publishedAt: entry.publishedAt,
69
+ readingTime: entry.readingTime,
70
+ slug: entry.slug,
71
+ title: entry.title,
72
+ };
73
+ }
74
+
75
+ function getFixtureEntrySummaries(kind: "docs"): DocsEntrySummary[];
76
+ function getFixtureEntrySummaries(kind: "blog"): BlogEntrySummary[];
77
+ function getFixtureEntrySummaries(kind: PublicContentKind) {
78
+ if (kind === "docs") {
79
+ return publicSiteFixtureSnapshot.docs.map(toDocsEntrySummary);
80
+ }
81
+
82
+ return publicSiteFixtureSnapshot.blog.map(toBlogEntrySummary);
83
+ }
84
+
85
+ function createCustomAdapter(
86
+ overrides: Partial<PublicContentAdapter> = {},
87
+ ): PublicContentAdapter {
88
+ return {
89
+ async getSiteConfig() {
90
+ return publicSiteFixtureSnapshot.site;
91
+ },
92
+ async getMarketingPage(pageKey) {
93
+ return getFixtureMarketingPage(pageKey);
94
+ },
95
+ async listEntries(kind) {
96
+ return kind === "docs"
97
+ ? getFixtureEntrySummaries("docs")
98
+ : getFixtureEntrySummaries("blog");
99
+ },
100
+ async getEntry(kind, slugParts) {
101
+ return getFixtureEntry(kind, slugParts);
102
+ },
103
+ ...overrides,
104
+ };
105
+ }
106
+
107
+ function createEnvelope(
108
+ overrides: Partial<Awaited<ReturnType<typeof fetchPublicSiteSnapshotEnvelope>>> = {},
109
+ ) {
110
+ return {
111
+ environment: "preview" as const,
112
+ source_boundary: "runtime_preview" as const,
113
+ snapshot: publicSiteFixtureSnapshot,
114
+ ...overrides,
115
+ };
116
+ }
117
+
118
+ function restoreProcessEnv() {
119
+ for (const key of Object.keys(process.env)) {
120
+ if (!(key in originalEnv)) {
121
+ delete process.env[key];
122
+ }
123
+ }
124
+
125
+ Object.assign(process.env, originalEnv);
126
+ }
127
+
128
+ function applyServerEnv(overrides: Record<string, string | undefined>) {
129
+ restoreProcessEnv();
130
+ process.env.MW_PLATFORM_BASE_URL = "http://127.0.0.1:8000";
131
+ process.env.MW_CONTENT_API_TOKEN = "test-content-token";
132
+ process.env.MW_PUBLIC_BASE_URL = "http://127.0.0.1:3000";
133
+ process.env.MW_PUBLIC_SITE_PROPERTY_KEY = "main-site";
134
+ process.env.MW_PUBLIC_SITE_ENV = "preview";
135
+ process.env.MW_ENABLE_RUNTIME_COMMAND_EXAMPLE = "false";
136
+
137
+ for (const [key, value] of Object.entries(overrides)) {
138
+ if (value === undefined) {
139
+ delete process.env[key];
140
+ } else {
141
+ process.env[key] = value;
142
+ }
143
+ }
144
+ }
145
+
146
+ describe("public content adapter", () => {
147
+ afterEach(() => {
148
+ restoreProcessEnv();
149
+ vi.unstubAllGlobals();
150
+ vi.resetModules();
151
+ vi.doUnmock("@/lib/content/custom-adapter");
152
+ });
153
+
154
+ it("loads a public-site snapshot envelope through a cacheable bearer-authenticated fetch", async () => {
155
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(createEnvelope()));
156
+ vi.stubGlobal("fetch", fetchMock);
157
+
158
+ const envelope = await fetchPublicSiteSnapshotEnvelope({
159
+ contentApiToken: "content-token",
160
+ environment: "preview",
161
+ platformBaseUrl: "https://platform.example.com",
162
+ propertyKey: "main-site",
163
+ });
164
+
165
+ expect(envelope.snapshot.site.siteName).toBe("MinuteWork Combined Starter");
166
+ expect(fetchMock).toHaveBeenCalledWith(
167
+ "https://platform.example.com/api/v1/developer/public-site/snapshots/main-site/?environment=preview",
168
+ expect.objectContaining({
169
+ headers: {
170
+ accept: "application/json",
171
+ authorization: "Bearer content-token",
172
+ },
173
+ next: {
174
+ revalidate: PUBLIC_CONTENT_REVALIDATE_SECONDS,
175
+ tags: [
176
+ "public-site-content",
177
+ "public-site-content:main-site",
178
+ "public-site-content:preview",
179
+ ],
180
+ },
181
+ }),
182
+ );
183
+ });
184
+
185
+ it("rejects invalid environment and source-boundary combinations", async () => {
186
+ const fetchMock = vi.fn().mockResolvedValue(
187
+ jsonResponse(
188
+ createEnvelope({
189
+ environment: "live",
190
+ source_boundary: "runtime_preview",
191
+ }),
192
+ ),
193
+ );
194
+ vi.stubGlobal("fetch", fetchMock);
195
+
196
+ await expect(
197
+ fetchPublicSiteSnapshotEnvelope({
198
+ contentApiToken: "content-token",
199
+ environment: "live",
200
+ platformBaseUrl: "https://platform.example.com",
201
+ propertyKey: "main-site",
202
+ }),
203
+ ).rejects.toThrow("published_live");
204
+ });
205
+
206
+ it("maps MinuteWork public-site snapshots through the adapter interface", async () => {
207
+ const adapter = new MinuteWorkPublicSiteContentAdapter(async () =>
208
+ createEnvelope({
209
+ environment: "live",
210
+ source_boundary: "published_live",
211
+ }),
212
+ );
213
+
214
+ await expect(adapter.getMarketingPage("home")).resolves.toEqual(
215
+ publicSiteFixtureSnapshot.marketingPages[0],
216
+ );
217
+ await expect(adapter.listEntries("blog")).resolves.toEqual(
218
+ publicSiteFixtureSnapshot.blog,
219
+ );
220
+ await expect(
221
+ adapter.getEntry("blog", ["public-site-api-default"]),
222
+ ).resolves.toEqual(publicSiteFixtureSnapshot.blog[0]);
223
+ });
224
+
225
+ it("allows empty marketing-page snapshots from the default gateway adapter", async () => {
226
+ const adapter = new MinuteWorkPublicSiteContentAdapter(async () =>
227
+ createEnvelope({
228
+ snapshot: {
229
+ ...publicSiteFixtureSnapshot,
230
+ marketingPages: [],
231
+ },
232
+ }),
233
+ );
234
+
235
+ await expect(adapter.getMarketingPage("pricing")).resolves.toBeNull();
236
+ });
237
+
238
+ it("loads the default MinuteWork public-site path when no custom adapter is provided", async () => {
239
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(createEnvelope()));
240
+ vi.stubGlobal("fetch", fetchMock);
241
+ applyServerEnv({});
242
+ vi.doMock("@/lib/content/custom-adapter", () => ({
243
+ resolveCustomPublicContentAdapter: () => null,
244
+ }));
245
+
246
+ const adapterModule = await import("./adapter.server");
247
+
248
+ await expect(adapterModule.getSiteConfig()).resolves.toEqual(
249
+ publicSiteFixtureSnapshot.site,
250
+ );
251
+ expect(fetchMock).toHaveBeenCalledTimes(1);
252
+ });
253
+
254
+ it("accepts valid custom adapter output through the shared contract wrapper", async () => {
255
+ vi.doMock("@/lib/content/custom-adapter", () => ({
256
+ resolveCustomPublicContentAdapter: () => createCustomAdapter(),
257
+ }));
258
+
259
+ const adapterModule = await import("./adapter.server");
260
+
261
+ await expect(adapterModule.getMarketingPage("pricing")).resolves.toEqual(
262
+ getFixtureMarketingPage("pricing"),
263
+ );
264
+ await expect(adapterModule.listEntries("blog")).resolves.toEqual(
265
+ getFixtureEntrySummaries("blog"),
266
+ );
267
+ });
268
+
269
+ it("rejects a custom adapter when a marketing page violates the canonical route contract", async () => {
270
+ vi.doMock("@/lib/content/custom-adapter", () => ({
271
+ resolveCustomPublicContentAdapter: () =>
272
+ createCustomAdapter({
273
+ async getMarketingPage(pageKey) {
274
+ const page = getFixtureMarketingPage(pageKey);
275
+
276
+ if (!page || pageKey !== "pricing") {
277
+ return page;
278
+ }
279
+
280
+ return {
281
+ ...page,
282
+ path: "/plans",
283
+ };
284
+ },
285
+ }),
286
+ }));
287
+
288
+ const adapterModule = await import("./adapter.server");
289
+
290
+ await expect(adapterModule.getMarketingPage("pricing")).rejects.toThrow(
291
+ '"pricing" marketing page must use the "/pricing" route.',
292
+ );
293
+ });
294
+
295
+ it("rejects a custom adapter when blog summaries use multi-segment slugs", async () => {
296
+ vi.doMock("@/lib/content/custom-adapter", () => ({
297
+ resolveCustomPublicContentAdapter: () =>
298
+ createCustomAdapter({
299
+ async listEntries(kind) {
300
+ if (kind !== "blog") {
301
+ return getFixtureEntrySummaries("docs");
302
+ }
303
+
304
+ return publicSiteFixtureSnapshot.blog.map((entry) => ({
305
+ ...entry,
306
+ slug: ["release", "v1"],
307
+ }));
308
+ },
309
+ }),
310
+ }));
311
+
312
+ const adapterModule = await import("./adapter.server");
313
+
314
+ await expect(adapterModule.listEntries("blog")).rejects.toThrow(
315
+ "Array must contain at most 1 element(s)",
316
+ );
317
+ });
318
+ });