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,232 @@
1
+ import "server-only";
2
+
3
+ import { cache } from "react";
4
+ import type { ZodType } from "zod";
5
+
6
+ import type {
7
+ MarketingPage,
8
+ MarketingPageKey,
9
+ PublicContentAdapter,
10
+ PublicContentKind,
11
+ PublicEntry,
12
+ PublicEntrySummary,
13
+ PublicSiteSnapshotEnvelope,
14
+ SiteConfig,
15
+ } from "@/lib/content/contracts";
16
+ import {
17
+ marketingPageResultSchema,
18
+ publicEntryResultSchema,
19
+ publicEntrySummaryListSchema,
20
+ publicSiteSnapshotEnvelopeSchema,
21
+ siteConfigSchema,
22
+ } from "@/lib/content/contracts";
23
+ import { resolveCustomPublicContentAdapter } from "@/lib/content/custom-adapter";
24
+ import { env } from "@/lib/platform/env.server";
25
+
26
+ export const PUBLIC_CONTENT_REVALIDATE_SECONDS = 300;
27
+
28
+ function normalizeSlugParts(slugParts: readonly string[]) {
29
+ return slugParts.map((part) => part.trim().toLowerCase()).filter(Boolean).join("/");
30
+ }
31
+
32
+ function compareSlugParts(left: readonly string[], right: readonly string[]) {
33
+ return normalizeSlugParts(left) === normalizeSlugParts(right);
34
+ }
35
+
36
+ function sortEntriesByDate(entries: readonly PublicEntrySummary[]) {
37
+ return [...entries].sort((left, right) => {
38
+ const leftTime = left.publishedAt ? Date.parse(left.publishedAt) : 0;
39
+ const rightTime = right.publishedAt ? Date.parse(right.publishedAt) : 0;
40
+ return rightTime - leftTime;
41
+ });
42
+ }
43
+
44
+ function formatZodIssues(
45
+ issues: readonly { message: string; path: readonly (string | number)[] }[],
46
+ ) {
47
+ return issues
48
+ .map((issue) => {
49
+ const path = issue.path.length > 0 ? issue.path.join(".") : "root";
50
+ return `${path}: ${issue.message}`;
51
+ })
52
+ .join("; ");
53
+ }
54
+
55
+ function validateAdapterResult<T>(
56
+ adapterLabel: string,
57
+ methodLabel: string,
58
+ schema: ZodType<T>,
59
+ value: unknown,
60
+ ) {
61
+ const parsedValue = schema.safeParse(value);
62
+
63
+ if (!parsedValue.success) {
64
+ throw new Error(
65
+ `${adapterLabel} returned invalid ${methodLabel}: ${formatZodIssues(parsedValue.error.issues)}`,
66
+ );
67
+ }
68
+
69
+ return parsedValue.data;
70
+ }
71
+
72
+ export class ValidatedPublicContentAdapter implements PublicContentAdapter {
73
+ constructor(
74
+ private readonly adapter: PublicContentAdapter,
75
+ private readonly adapterLabel: string,
76
+ ) {}
77
+
78
+ async getSiteConfig(): Promise<SiteConfig> {
79
+ return validateAdapterResult(
80
+ this.adapterLabel,
81
+ "site config",
82
+ siteConfigSchema,
83
+ await this.adapter.getSiteConfig(),
84
+ );
85
+ }
86
+
87
+ async getMarketingPage(pageKey: MarketingPageKey): Promise<MarketingPage | null> {
88
+ return validateAdapterResult(
89
+ this.adapterLabel,
90
+ `marketing page for "${pageKey}"`,
91
+ marketingPageResultSchema(pageKey),
92
+ await this.adapter.getMarketingPage(pageKey),
93
+ );
94
+ }
95
+
96
+ async listEntries(kind: PublicContentKind): Promise<PublicEntrySummary[]> {
97
+ return validateAdapterResult(
98
+ this.adapterLabel,
99
+ `${kind} entry summaries`,
100
+ publicEntrySummaryListSchema(kind),
101
+ await this.adapter.listEntries(kind),
102
+ );
103
+ }
104
+
105
+ async getEntry(kind: PublicContentKind, slugParts: string[]): Promise<PublicEntry | null> {
106
+ return validateAdapterResult(
107
+ this.adapterLabel,
108
+ `${kind} entry for "${normalizeSlugParts(slugParts)}"`,
109
+ publicEntryResultSchema(kind, slugParts),
110
+ await this.adapter.getEntry(kind, slugParts),
111
+ );
112
+ }
113
+ }
114
+
115
+ export async function fetchPublicSiteSnapshotEnvelope(input: {
116
+ contentApiToken: string;
117
+ environment: "preview" | "live";
118
+ platformBaseUrl: string;
119
+ propertyKey: string;
120
+ }): Promise<PublicSiteSnapshotEnvelope> {
121
+ const requestUrl = new URL(
122
+ `/api/v1/developer/public-site/snapshots/${encodeURIComponent(input.propertyKey)}/`,
123
+ `${input.platformBaseUrl.replace(/\/$/, "")}/`,
124
+ );
125
+ requestUrl.searchParams.set("environment", input.environment);
126
+
127
+ const response = await fetch(requestUrl.toString(), {
128
+ headers: {
129
+ accept: "application/json",
130
+ authorization: `Bearer ${input.contentApiToken}`,
131
+ },
132
+ next: {
133
+ revalidate: PUBLIC_CONTENT_REVALIDATE_SECONDS,
134
+ tags: [
135
+ "public-site-content",
136
+ `public-site-content:${input.propertyKey}`,
137
+ `public-site-content:${input.environment}`,
138
+ ],
139
+ },
140
+ });
141
+
142
+ if (!response.ok) {
143
+ throw new Error(
144
+ `Unable to load public-site ${input.environment} snapshot for "${input.propertyKey}" (${response.status}).`,
145
+ );
146
+ }
147
+
148
+ const payload = await response.json();
149
+ const parsedEnvelope = publicSiteSnapshotEnvelopeSchema.safeParse(payload);
150
+
151
+ if (!parsedEnvelope.success) {
152
+ throw new Error(
153
+ `Public-site snapshot envelope is invalid: ${formatZodIssues(parsedEnvelope.error.issues)}`,
154
+ );
155
+ }
156
+
157
+ return parsedEnvelope.data;
158
+ }
159
+
160
+ const loadPublicSiteSnapshotEnvelope = cache(async () =>
161
+ fetchPublicSiteSnapshotEnvelope({
162
+ contentApiToken: env.MW_CONTENT_API_TOKEN,
163
+ environment: env.MW_PUBLIC_SITE_ENV,
164
+ platformBaseUrl: env.MW_PLATFORM_BASE_URL,
165
+ propertyKey: env.MW_PUBLIC_SITE_PROPERTY_KEY,
166
+ }),
167
+ );
168
+
169
+ export class MinuteWorkPublicSiteContentAdapter implements PublicContentAdapter {
170
+ constructor(
171
+ private readonly loadEnvelope: () => Promise<PublicSiteSnapshotEnvelope> = loadPublicSiteSnapshotEnvelope,
172
+ ) {}
173
+
174
+ private async loadSnapshot() {
175
+ return (await this.loadEnvelope()).snapshot;
176
+ }
177
+
178
+ async getSiteConfig(): Promise<SiteConfig> {
179
+ return (await this.loadSnapshot()).site;
180
+ }
181
+
182
+ async getMarketingPage(pageKey: MarketingPageKey): Promise<MarketingPage | null> {
183
+ return (
184
+ (await this.loadSnapshot()).marketingPages.find((page) => page.pageKey === pageKey) ??
185
+ null
186
+ );
187
+ }
188
+
189
+ async listEntries(kind: PublicContentKind): Promise<PublicEntrySummary[]> {
190
+ return sortEntriesByDate((await this.loadSnapshot())[kind]);
191
+ }
192
+
193
+ async getEntry(kind: PublicContentKind, slugParts: string[]): Promise<PublicEntry | null> {
194
+ return (
195
+ (await this.loadSnapshot())[kind].find((entry) =>
196
+ compareSlugParts(entry.slug, slugParts),
197
+ ) ?? null
198
+ );
199
+ }
200
+ }
201
+
202
+ const defaultPublicContentAdapter = new ValidatedPublicContentAdapter(
203
+ new MinuteWorkPublicSiteContentAdapter(),
204
+ "MinuteWork public-site content adapter",
205
+ );
206
+
207
+ export const getPublicContentAdapter = cache(() => {
208
+ const customAdapter = resolveCustomPublicContentAdapter();
209
+
210
+ if (customAdapter) {
211
+ return new ValidatedPublicContentAdapter(
212
+ customAdapter,
213
+ "Custom public content adapter",
214
+ );
215
+ }
216
+
217
+ return defaultPublicContentAdapter;
218
+ });
219
+
220
+ export const getSiteConfig = cache(async () => getPublicContentAdapter().getSiteConfig());
221
+
222
+ export const getMarketingPage = cache(async (pageKey: MarketingPageKey) =>
223
+ getPublicContentAdapter().getMarketingPage(pageKey),
224
+ );
225
+
226
+ export const listEntries = cache(async (kind: PublicContentKind) =>
227
+ getPublicContentAdapter().listEntries(kind),
228
+ );
229
+
230
+ export const getEntry = cache(async (kind: PublicContentKind, slugParts: string[]) =>
231
+ getPublicContentAdapter().getEntry(kind, slugParts),
232
+ );
@@ -0,0 +1,339 @@
1
+ import { z } from "zod";
2
+
3
+ export const marketingPageKeys = ["home", "pricing"] as const;
4
+ export const publicContentKinds = ["docs", "blog"] as const;
5
+ export const requiredMarketingPagePaths = {
6
+ home: "/",
7
+ pricing: "/pricing",
8
+ } as const;
9
+
10
+ export type MarketingPageKey = (typeof marketingPageKeys)[number];
11
+ export type PublicContentKind = (typeof publicContentKinds)[number];
12
+
13
+ export type PublicLink = {
14
+ label: string;
15
+ href: string;
16
+ };
17
+
18
+ export type ContentCollectionConfig = {
19
+ eyebrow: string;
20
+ title: string;
21
+ description: string;
22
+ };
23
+
24
+ export type SiteConfig = {
25
+ siteName: string;
26
+ siteDescription: string;
27
+ organizationName: string;
28
+ footerBlurb: string;
29
+ primaryCta: PublicLink;
30
+ secondaryCta: PublicLink;
31
+ primaryNavigation: PublicLink[];
32
+ collections: Record<PublicContentKind, ContentCollectionConfig>;
33
+ };
34
+
35
+ export type SeoMetadata = {
36
+ title: string;
37
+ description: string;
38
+ };
39
+
40
+ export type MarketingSection = {
41
+ eyebrow?: string;
42
+ title: string;
43
+ body: string;
44
+ points?: string[];
45
+ };
46
+
47
+ export type MarketingPage = {
48
+ pageKey: MarketingPageKey;
49
+ path: string;
50
+ heroEyebrow: string;
51
+ heroTitle: string;
52
+ heroBody: string;
53
+ primaryCta: PublicLink;
54
+ secondaryCta?: PublicLink | null;
55
+ sections: MarketingSection[];
56
+ seo: SeoMetadata;
57
+ };
58
+
59
+ type PublicEntrySummaryBase = {
60
+ title: string;
61
+ description: string;
62
+ eyebrow?: string;
63
+ publishedAt?: string | null;
64
+ readingTime?: string | null;
65
+ };
66
+
67
+ export type DocsEntrySummary = PublicEntrySummaryBase & {
68
+ kind: "docs";
69
+ slug: string[];
70
+ };
71
+
72
+ export type BlogEntrySummary = PublicEntrySummaryBase & {
73
+ kind: "blog";
74
+ slug: [string];
75
+ };
76
+
77
+ export type PublicEntrySummary = DocsEntrySummary | BlogEntrySummary;
78
+
79
+ export type PublicEntrySection = {
80
+ heading: string;
81
+ paragraphs: string[];
82
+ };
83
+
84
+ type PublicEntryBody = {
85
+ body: {
86
+ intro: string;
87
+ sections: PublicEntrySection[];
88
+ };
89
+ seo: SeoMetadata;
90
+ };
91
+
92
+ export type DocsEntry = DocsEntrySummary & PublicEntryBody;
93
+ export type BlogEntry = BlogEntrySummary & PublicEntryBody;
94
+ export type PublicEntry = DocsEntry | BlogEntry;
95
+
96
+ export interface PublicContentAdapter {
97
+ getSiteConfig(): Promise<SiteConfig>;
98
+ getMarketingPage(pageKey: MarketingPageKey): Promise<MarketingPage | null>;
99
+ listEntries(kind: PublicContentKind): Promise<PublicEntrySummary[]>;
100
+ getEntry(kind: PublicContentKind, slugParts: string[]): Promise<PublicEntry | null>;
101
+ }
102
+
103
+ export const publicLinkSchema = z.object({
104
+ label: z.string().trim().min(1),
105
+ href: z.string().trim().min(1),
106
+ });
107
+
108
+ export const seoMetadataSchema = z.object({
109
+ title: z.string().trim().min(1),
110
+ description: z.string().trim().min(1),
111
+ });
112
+
113
+ export const contentCollectionConfigSchema = z.object({
114
+ eyebrow: z.string().trim().min(1),
115
+ title: z.string().trim().min(1),
116
+ description: z.string().trim().min(1),
117
+ });
118
+
119
+ export const siteConfigSchema = z.object({
120
+ siteName: z.string().trim().min(1),
121
+ siteDescription: z.string().trim().min(1),
122
+ organizationName: z.string().trim().min(1),
123
+ footerBlurb: z.string().trim().min(1),
124
+ primaryCta: publicLinkSchema,
125
+ secondaryCta: publicLinkSchema,
126
+ primaryNavigation: z.array(publicLinkSchema).min(1),
127
+ collections: z.object({
128
+ docs: contentCollectionConfigSchema,
129
+ blog: contentCollectionConfigSchema,
130
+ }),
131
+ });
132
+
133
+ export const marketingSectionSchema = z.object({
134
+ eyebrow: z.string().trim().min(1).optional(),
135
+ title: z.string().trim().min(1),
136
+ body: z.string().trim().min(1),
137
+ points: z.array(z.string().trim().min(1)).optional(),
138
+ });
139
+
140
+ export const marketingPageSchema = z
141
+ .object({
142
+ pageKey: z.enum(marketingPageKeys),
143
+ path: z.string().trim().min(1),
144
+ heroEyebrow: z.string().trim().min(1),
145
+ heroTitle: z.string().trim().min(1),
146
+ heroBody: z.string().trim().min(1),
147
+ primaryCta: publicLinkSchema,
148
+ secondaryCta: publicLinkSchema.nullish(),
149
+ sections: z.array(marketingSectionSchema).min(1),
150
+ seo: seoMetadataSchema,
151
+ })
152
+ .superRefine((page, context) => {
153
+ const requiredPath = requiredMarketingPagePaths[page.pageKey];
154
+
155
+ if (page.path !== requiredPath) {
156
+ context.addIssue({
157
+ code: z.ZodIssueCode.custom,
158
+ path: ["path"],
159
+ message: `"${page.pageKey}" marketing page must use the "${requiredPath}" route.`,
160
+ });
161
+ }
162
+ });
163
+
164
+ const marketingPagesSchema = z
165
+ .array(marketingPageSchema)
166
+ .superRefine((pages, context) => {
167
+ const counts = new Map<MarketingPageKey, number>();
168
+
169
+ for (const [index, page] of pages.entries()) {
170
+ const nextCount = (counts.get(page.pageKey) ?? 0) + 1;
171
+ counts.set(page.pageKey, nextCount);
172
+
173
+ if (nextCount > 1) {
174
+ context.addIssue({
175
+ code: z.ZodIssueCode.custom,
176
+ path: [index, "pageKey"],
177
+ message: `Duplicate marketing page for "${page.pageKey}".`,
178
+ });
179
+ }
180
+ }
181
+ });
182
+
183
+ const slugSegmentSchema = z.string().trim().min(1);
184
+
185
+ const publicEntrySummaryBaseSchema = z.object({
186
+ title: z.string().trim().min(1),
187
+ description: z.string().trim().min(1),
188
+ eyebrow: z.string().trim().min(1).optional(),
189
+ publishedAt: z.string().trim().min(1).nullable().optional(),
190
+ readingTime: z.string().trim().min(1).nullable().optional(),
191
+ });
192
+
193
+ export const docsEntrySummarySchema = publicEntrySummaryBaseSchema.extend({
194
+ kind: z.literal("docs"),
195
+ slug: z.array(slugSegmentSchema).min(1),
196
+ });
197
+
198
+ export const blogEntrySummarySchema = publicEntrySummaryBaseSchema.extend({
199
+ kind: z.literal("blog"),
200
+ slug: z.tuple([slugSegmentSchema]),
201
+ });
202
+
203
+ export const publicEntrySummarySchema = z.union([
204
+ docsEntrySummarySchema,
205
+ blogEntrySummarySchema,
206
+ ]);
207
+
208
+ function normalizeSlugParts(slugParts: readonly string[]) {
209
+ return slugParts.map((part) => part.trim().toLowerCase()).filter(Boolean).join("/");
210
+ }
211
+
212
+ const publicEntryBodySchema = z.object({
213
+ body: z.object({
214
+ intro: z.string().trim().min(1),
215
+ sections: z
216
+ .array(
217
+ z.object({
218
+ heading: z.string().trim().min(1),
219
+ paragraphs: z.array(z.string().trim().min(1)).min(1),
220
+ }),
221
+ )
222
+ .min(1),
223
+ }),
224
+ seo: seoMetadataSchema,
225
+ });
226
+
227
+ export const docsEntrySchema = docsEntrySummarySchema.extend(
228
+ publicEntryBodySchema.shape,
229
+ );
230
+
231
+ export const blogEntrySchema = blogEntrySummarySchema.extend(
232
+ publicEntryBodySchema.shape,
233
+ );
234
+
235
+ export const publicEntrySchema = z.union([docsEntrySchema, blogEntrySchema]);
236
+
237
+ export const publicContentSnapshotSchema = z.object({
238
+ site: siteConfigSchema,
239
+ marketingPages: marketingPagesSchema,
240
+ docs: z.array(docsEntrySchema),
241
+ blog: z.array(blogEntrySchema),
242
+ });
243
+
244
+ export const publicSiteSnapshotEnvironmentSchema = z.enum(["preview", "live"]);
245
+ export const publicSiteSnapshotSourceBoundarySchema = z.enum([
246
+ "runtime_preview",
247
+ "published_live",
248
+ ]);
249
+
250
+ export const publicSiteSnapshotEnvelopeSchema = z
251
+ .object({
252
+ environment: publicSiteSnapshotEnvironmentSchema,
253
+ source_boundary: publicSiteSnapshotSourceBoundarySchema,
254
+ snapshot: publicContentSnapshotSchema,
255
+ })
256
+ .superRefine((value, context) => {
257
+ if (
258
+ value.environment === "preview" &&
259
+ value.source_boundary !== "runtime_preview"
260
+ ) {
261
+ context.addIssue({
262
+ code: z.ZodIssueCode.custom,
263
+ path: ["source_boundary"],
264
+ message:
265
+ 'Preview public-site snapshots must declare the "runtime_preview" source boundary.',
266
+ });
267
+ }
268
+
269
+ if (
270
+ value.environment === "live" &&
271
+ value.source_boundary !== "published_live"
272
+ ) {
273
+ context.addIssue({
274
+ code: z.ZodIssueCode.custom,
275
+ path: ["source_boundary"],
276
+ message:
277
+ 'Live public-site snapshots must declare the "published_live" source boundary.',
278
+ });
279
+ }
280
+ });
281
+
282
+ export function marketingPageResultSchema(
283
+ pageKey: MarketingPageKey,
284
+ ): z.ZodType<MarketingPage | null> {
285
+ return marketingPageSchema.nullable().superRefine((page, context) => {
286
+ if (!page) {
287
+ return;
288
+ }
289
+
290
+ if (page.pageKey !== pageKey) {
291
+ context.addIssue({
292
+ code: z.ZodIssueCode.custom,
293
+ path: ["pageKey"],
294
+ message: `Expected "${pageKey}" marketing page.`,
295
+ });
296
+ }
297
+ });
298
+ }
299
+
300
+ export function publicEntrySummaryListSchema(
301
+ kind: PublicContentKind,
302
+ ): z.ZodType<PublicEntrySummary[]> {
303
+ return z.array(kind === "docs" ? docsEntrySummarySchema : blogEntrySummarySchema);
304
+ }
305
+
306
+ export function publicEntryResultSchema(
307
+ kind: PublicContentKind,
308
+ slugParts: readonly string[],
309
+ ): z.ZodType<PublicEntry | null> {
310
+ const refineEntryResult = <
311
+ TEntry extends DocsEntry | BlogEntry | null,
312
+ >(
313
+ entry: TEntry,
314
+ context: z.RefinementCtx,
315
+ ) => {
316
+ if (!entry) {
317
+ return;
318
+ }
319
+
320
+ if (normalizeSlugParts(entry.slug) !== normalizeSlugParts(slugParts)) {
321
+ context.addIssue({
322
+ code: z.ZodIssueCode.custom,
323
+ path: ["slug"],
324
+ message: `Expected "${kind}" entry slug to match "${slugParts.join("/")}".`,
325
+ });
326
+ }
327
+ };
328
+
329
+ if (kind === "docs") {
330
+ return docsEntrySchema.nullable().superRefine(refineEntryResult);
331
+ }
332
+
333
+ return blogEntrySchema.nullable().superRefine(refineEntryResult);
334
+ }
335
+
336
+ export type PublicContentSnapshot = z.infer<typeof publicContentSnapshotSchema>;
337
+ export type PublicSiteSnapshotEnvelope = z.infer<
338
+ typeof publicSiteSnapshotEnvelopeSchema
339
+ >;
@@ -0,0 +1,5 @@
1
+ import type { PublicContentAdapter } from "@/lib/content/contracts";
2
+
3
+ export function resolveCustomPublicContentAdapter(): PublicContentAdapter | null {
4
+ return null;
5
+ }
@@ -0,0 +1,96 @@
1
+ import type {
2
+ MarketingPage,
3
+ MarketingPageKey,
4
+ SiteConfig,
5
+ } from "@/lib/content/contracts";
6
+
7
+ const emptyMarketingPageConfig: Record<
8
+ MarketingPageKey,
9
+ Pick<
10
+ MarketingPage,
11
+ | "heroEyebrow"
12
+ | "heroTitle"
13
+ | "heroBody"
14
+ | "path"
15
+ | "sections"
16
+ >
17
+ > = {
18
+ home: {
19
+ heroEyebrow: "Public site",
20
+ heroTitle: "Your public site is ready for published content.",
21
+ heroBody:
22
+ "Connect this starter to MinuteWork public-site content and publish your first marketing page, docs entry, or blog post.",
23
+ path: "/",
24
+ sections: [
25
+ {
26
+ eyebrow: "First run",
27
+ title: "No published home page yet",
28
+ body:
29
+ "This starter now reads public-site content from the MinuteWork gateway during development and build.",
30
+ },
31
+ {
32
+ eyebrow: "Preview",
33
+ title: "Use preview for local authoring",
34
+ body:
35
+ "Set MW_PUBLIC_SITE_ENV=preview to render draft-safe preview content through the same API contract.",
36
+ },
37
+ {
38
+ eyebrow: "Publish",
39
+ title: "Live stays publication-safe",
40
+ body:
41
+ "Switch builds and deploys to MW_PUBLIC_SITE_ENV=live once the property has published content available.",
42
+ },
43
+ ],
44
+ },
45
+ pricing: {
46
+ heroEyebrow: "Pricing",
47
+ heroTitle: "Add your first published pricing page.",
48
+ heroBody:
49
+ "The pricing route is live, but the property has not published pricing content yet. Publish a pricing page to replace this empty-state shell.",
50
+ path: "/pricing",
51
+ sections: [
52
+ {
53
+ eyebrow: "Authoring source",
54
+ title: "Published pages stay externalized",
55
+ body:
56
+ "Marketing content now comes from the MinuteWork public-site snapshot API instead of seeded in-repo copy.",
57
+ },
58
+ {
59
+ eyebrow: "Build behavior",
60
+ title: "Static-first output still works",
61
+ body:
62
+ "Builds succeed with valid empty snapshots, so fresh properties can ship before authored content exists.",
63
+ },
64
+ {
65
+ eyebrow: "Next step",
66
+ title: "Publish pricing content when ready",
67
+ body:
68
+ "As soon as the property publishes a pricing page, this shell is replaced automatically by the fetched snapshot.",
69
+ },
70
+ ],
71
+ },
72
+ };
73
+
74
+ export function buildEmptyMarketingPage(
75
+ pageKey: MarketingPageKey,
76
+ siteConfig: SiteConfig,
77
+ ): MarketingPage {
78
+ const config = emptyMarketingPageConfig[pageKey];
79
+ const titlePrefix =
80
+ pageKey === "home" ? siteConfig.siteName : `${siteConfig.siteName} Pricing`;
81
+
82
+ return {
83
+ pageKey,
84
+ path: config.path,
85
+ heroEyebrow: config.heroEyebrow,
86
+ heroTitle: config.heroTitle,
87
+ heroBody: config.heroBody,
88
+ primaryCta: siteConfig.primaryCta,
89
+ secondaryCta: siteConfig.secondaryCta,
90
+ sections: config.sections,
91
+ seo: {
92
+ title: titlePrefix,
93
+ description: siteConfig.siteDescription,
94
+ },
95
+ };
96
+ }