minutework 0.1.26 → 0.1.28

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 (30) hide show
  1. package/assets/claude-local/CLAUDE.md.template +4 -2
  2. package/assets/claude-local/skills/published-web-and-mw-core-site/SKILL.md +3 -1
  3. package/assets/templates/next-tenant-app/.env.example +4 -3
  4. package/assets/templates/next-tenant-app/README.md +9 -4
  5. package/assets/templates/next-tenant-app/src/app/(cms)/[...path]/page.tsx +17 -1
  6. package/assets/templates/next-tenant-app/src/app/app/private-content-source.test.ts +88 -0
  7. package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.tsx +17 -1
  8. package/assets/templates/next-tenant-app/src/app/blog/page.test.ts +4 -0
  9. package/assets/templates/next-tenant-app/src/app/blog/page.tsx +15 -1
  10. package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.tsx +17 -1
  11. package/assets/templates/next-tenant-app/src/app/docs/page.test.ts +4 -0
  12. package/assets/templates/next-tenant-app/src/app/docs/page.tsx +15 -1
  13. package/assets/templates/next-tenant-app/src/app/layout.tsx +4 -2
  14. package/assets/templates/next-tenant-app/src/app/page.test.ts +4 -0
  15. package/assets/templates/next-tenant-app/src/app/page.tsx +15 -1
  16. package/assets/templates/next-tenant-app/src/app/pricing/page.test.ts +4 -0
  17. package/assets/templates/next-tenant-app/src/app/pricing/page.tsx +15 -1
  18. package/assets/templates/next-tenant-app/src/app/robots.test.ts +24 -4
  19. package/assets/templates/next-tenant-app/src/app/robots.ts +15 -1
  20. package/assets/templates/next-tenant-app/src/app/sitemap.test.ts +16 -2
  21. package/assets/templates/next-tenant-app/src/app/sitemap.ts +21 -7
  22. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +102 -0
  23. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +140 -35
  24. package/assets/templates/next-tenant-app/src/lib/platform/env.server.test.ts +109 -0
  25. package/assets/templates/next-tenant-app/src/lib/platform/env.server.ts +84 -20
  26. package/assets/templates/next-tenant-app/src/lib/public-site.test.ts +1 -1
  27. package/assets/templates/next-tenant-app/src/lib/public-site.ts +30 -6
  28. package/assets/templates/next-tenant-app/tools/template/with-public-site-fixture.mjs +1 -0
  29. package/assets/templates/next-tenant-app/vitest.config.ts +2 -0
  30. package/package.json +1 -1
@@ -2,9 +2,23 @@ import type { MetadataRoute } from "next";
2
2
 
3
3
  import { listEntries } from "@/lib/content/adapter.server";
4
4
  import { appRoutes } from "@/lib/app-routes";
5
- import { resolvePublicSiteUrl } from "@/lib/public-site";
5
+ import { isPublicContentDisabled, resolvePublicSiteUrl } from "@/lib/public-site";
6
+
7
+ function buildSitemapUrl(pathname: string) {
8
+ const url = resolvePublicSiteUrl(pathname);
9
+
10
+ if (!url) {
11
+ throw new Error("MW_PUBLIC_BASE_URL is required to build a public sitemap.");
12
+ }
13
+
14
+ return url.toString();
15
+ }
6
16
 
7
17
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
18
+ if (isPublicContentDisabled()) {
19
+ return [];
20
+ }
21
+
8
22
  const [docsEntries, blogEntries] = await Promise.all([
9
23
  listEntries("docs"),
10
24
  listEntries("blog"),
@@ -12,22 +26,22 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
12
26
 
13
27
  const entries: MetadataRoute.Sitemap = [
14
28
  {
15
- url: resolvePublicSiteUrl(appRoutes.publicHome).toString(),
29
+ url: buildSitemapUrl(appRoutes.publicHome),
16
30
  changeFrequency: "weekly",
17
31
  priority: 1,
18
32
  },
19
33
  {
20
- url: resolvePublicSiteUrl(appRoutes.pricing).toString(),
34
+ url: buildSitemapUrl(appRoutes.pricing),
21
35
  changeFrequency: "monthly",
22
36
  priority: 0.8,
23
37
  },
24
38
  {
25
- url: resolvePublicSiteUrl(appRoutes.docsIndex).toString(),
39
+ url: buildSitemapUrl(appRoutes.docsIndex),
26
40
  changeFrequency: "weekly",
27
41
  priority: 0.8,
28
42
  },
29
43
  {
30
- url: resolvePublicSiteUrl(appRoutes.blogIndex).toString(),
44
+ url: buildSitemapUrl(appRoutes.blogIndex),
31
45
  changeFrequency: "weekly",
32
46
  priority: 0.8,
33
47
  },
@@ -35,7 +49,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
35
49
 
36
50
  entries.push(
37
51
  ...docsEntries.map((entry) => ({
38
- url: resolvePublicSiteUrl(appRoutes.docsPage(entry.slug)).toString(),
52
+ url: buildSitemapUrl(appRoutes.docsPage(entry.slug)),
39
53
  changeFrequency: "weekly" as const,
40
54
  priority: 0.7,
41
55
  })),
@@ -43,7 +57,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
43
57
 
44
58
  entries.push(
45
59
  ...blogEntries.map((entry) => ({
46
- url: resolvePublicSiteUrl(appRoutes.blogPost(entry.slug)).toString(),
60
+ url: buildSitemapUrl(appRoutes.blogPost(entry.slug)),
47
61
  changeFrequency: "monthly" as const,
48
62
  priority: 0.7,
49
63
  ...(entry.publishedAt ? { lastModified: new Date(entry.publishedAt) } : {}),
@@ -1,3 +1,7 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
1
5
  import { afterEach, describe, expect, it, vi } from "vitest";
2
6
 
3
7
  import type {
@@ -128,6 +132,7 @@ function restoreProcessEnv() {
128
132
  function applyServerEnv(overrides: Record<string, string | undefined>) {
129
133
  restoreProcessEnv();
130
134
  process.env.MW_PLATFORM_BASE_URL = "http://127.0.0.1:8000";
135
+ process.env.MW_PUBLIC_CONTENT_SOURCE = "minutework_cms";
131
136
  process.env.MW_CONTENT_API_TOKEN = "test-content-token";
132
137
  process.env.MW_PUBLIC_BASE_URL = "http://127.0.0.1:3000";
133
138
  process.env.MW_PUBLIC_SITE_PROPERTY_KEY = "main-site";
@@ -276,6 +281,11 @@ describe("public content adapter", () => {
276
281
  });
277
282
 
278
283
  it("accepts valid custom adapter output through the shared contract wrapper", async () => {
284
+ applyServerEnv({
285
+ MW_PUBLIC_CONTENT_SOURCE: "custom",
286
+ MW_CONTENT_API_TOKEN: undefined,
287
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
288
+ });
279
289
  vi.doMock("@/lib/content/custom-adapter", () => ({
280
290
  resolveCustomPublicContentAdapter: () => createCustomAdapter(),
281
291
  }));
@@ -290,7 +300,94 @@ describe("public content adapter", () => {
290
300
  );
291
301
  });
292
302
 
303
+ it("requires an explicit adapter when MW_PUBLIC_CONTENT_SOURCE=custom", async () => {
304
+ applyServerEnv({
305
+ MW_PUBLIC_CONTENT_SOURCE: "custom",
306
+ MW_CONTENT_API_TOKEN: undefined,
307
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
308
+ });
309
+ vi.doMock("@/lib/content/custom-adapter", () => ({
310
+ resolveCustomPublicContentAdapter: () => null,
311
+ }));
312
+
313
+ const adapterModule = await import("./adapter.server");
314
+
315
+ expect(() => adapterModule.getPublicContentAdapter()).toThrow(
316
+ "MW_PUBLIC_CONTENT_SOURCE=custom requires resolveCustomPublicContentAdapter()",
317
+ );
318
+ });
319
+
320
+ it("loads valid static JSON public content", async () => {
321
+ const tempRoot = await mkdtemp(join(tmpdir(), "mw-public-content-"));
322
+ const contentPath = join(tempRoot, "public-site.json");
323
+
324
+ try {
325
+ await writeFile(
326
+ contentPath,
327
+ JSON.stringify(publicSiteFixtureSnapshot),
328
+ "utf8",
329
+ );
330
+ applyServerEnv({
331
+ MW_PUBLIC_CONTENT_SOURCE: "static_json",
332
+ MW_CONTENT_API_TOKEN: undefined,
333
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
334
+ MW_STATIC_PUBLIC_CONTENT_PATH: contentPath,
335
+ });
336
+
337
+ const adapterModule = await import("./adapter.server");
338
+
339
+ await expect(adapterModule.getMarketingPage("home")).resolves.toEqual(
340
+ getFixtureMarketingPage("home"),
341
+ );
342
+ } finally {
343
+ await rm(tempRoot, { force: true, recursive: true });
344
+ }
345
+ });
346
+
347
+ it("rejects invalid static JSON public content", async () => {
348
+ const tempRoot = await mkdtemp(join(tmpdir(), "mw-public-content-"));
349
+ const contentPath = join(tempRoot, "public-site.json");
350
+
351
+ try {
352
+ await writeFile(contentPath, JSON.stringify({ site: {} }), "utf8");
353
+ applyServerEnv({
354
+ MW_PUBLIC_CONTENT_SOURCE: "static_json",
355
+ MW_CONTENT_API_TOKEN: undefined,
356
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
357
+ MW_STATIC_PUBLIC_CONTENT_PATH: contentPath,
358
+ });
359
+
360
+ const adapterModule = await import("./adapter.server");
361
+
362
+ await expect(adapterModule.getSiteConfig()).rejects.toThrow(
363
+ "Static public content is invalid",
364
+ );
365
+ } finally {
366
+ await rm(tempRoot, { force: true, recursive: true });
367
+ }
368
+ });
369
+
370
+ it("keeps public content disabled for private-only mode", async () => {
371
+ applyServerEnv({
372
+ MW_PUBLIC_CONTENT_SOURCE: "none",
373
+ MW_CONTENT_API_TOKEN: undefined,
374
+ MW_PUBLIC_BASE_URL: undefined,
375
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
376
+ });
377
+
378
+ const adapterModule = await import("./adapter.server");
379
+
380
+ await expect(adapterModule.getSiteConfig()).rejects.toThrow(
381
+ "Public content is disabled",
382
+ );
383
+ });
384
+
293
385
  it("rejects a custom adapter when a marketing page violates the canonical route contract", async () => {
386
+ applyServerEnv({
387
+ MW_PUBLIC_CONTENT_SOURCE: "custom",
388
+ MW_CONTENT_API_TOKEN: undefined,
389
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
390
+ });
294
391
  vi.doMock("@/lib/content/custom-adapter", () => ({
295
392
  resolveCustomPublicContentAdapter: () =>
296
393
  createCustomAdapter({
@@ -317,6 +414,11 @@ describe("public content adapter", () => {
317
414
  });
318
415
 
319
416
  it("rejects a custom adapter when blog summaries use multi-segment slugs", async () => {
417
+ applyServerEnv({
418
+ MW_PUBLIC_CONTENT_SOURCE: "custom",
419
+ MW_CONTENT_API_TOKEN: undefined,
420
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
421
+ });
320
422
  vi.doMock("@/lib/content/custom-adapter", () => ({
321
423
  resolveCustomPublicContentAdapter: () =>
322
424
  createCustomAdapter({
@@ -9,6 +9,7 @@ import type {
9
9
  MarketingPageKey,
10
10
  PublicContentAdapter,
11
11
  PublicContentKind,
12
+ PublicContentSnapshot,
12
13
  PublicEntry,
13
14
  PublicEntrySummary,
14
15
  PublicSiteSnapshotEnvelope,
@@ -19,6 +20,7 @@ import {
19
20
  marketingPageResultSchema,
20
21
  publicEntryResultSchema,
21
22
  publicEntrySummaryListSchema,
23
+ publicContentSnapshotSchema,
22
24
  publicSiteSnapshotEnvelopeSchema,
23
25
  siteConfigSchema,
24
26
  } from "@/lib/content/contracts";
@@ -71,6 +73,14 @@ function validateAdapterResult<T>(
71
73
  return parsedValue.data;
72
74
  }
73
75
 
76
+ function requirePublicContentEnv(key: string, value: string | undefined) {
77
+ if (!value) {
78
+ throw new Error(`${key} is required for MW_PUBLIC_CONTENT_SOURCE=minutework_cms.`);
79
+ }
80
+
81
+ return value;
82
+ }
83
+
74
84
  export class ValidatedPublicContentAdapter implements PublicContentAdapter {
75
85
  constructor(
76
86
  private readonly adapter: PublicContentAdapter,
@@ -134,6 +144,49 @@ export class ValidatedPublicContentAdapter implements PublicContentAdapter {
134
144
  }
135
145
  }
136
146
 
147
+ export class PublicSiteSnapshotContentAdapter implements PublicContentAdapter {
148
+ constructor(private readonly loadSnapshot: () => Promise<PublicContentSnapshot>) {}
149
+
150
+ async getSiteConfig(): Promise<SiteConfig> {
151
+ return (await this.loadSnapshot()).site;
152
+ }
153
+
154
+ async getMarketingPage(pageKey: MarketingPageKey): Promise<MarketingPage | null> {
155
+ return (
156
+ (await this.loadSnapshot()).marketingPages.find((page) => page.pageKey === pageKey) ??
157
+ null
158
+ );
159
+ }
160
+
161
+ async listEntries(kind: PublicContentKind): Promise<PublicEntrySummary[]> {
162
+ return sortEntriesByDate((await this.loadSnapshot())[kind]);
163
+ }
164
+
165
+ async getEntry(kind: PublicContentKind, slugParts: string[]): Promise<PublicEntry | null> {
166
+ return (
167
+ (await this.loadSnapshot())[kind].find((entry) =>
168
+ compareSlugParts(entry.slug, slugParts),
169
+ ) ?? null
170
+ );
171
+ }
172
+
173
+ async getPageByPath(path: string): Promise<ContentStructurePage | null> {
174
+ const snapshot = await this.loadSnapshot();
175
+ const pages = snapshot.pages ?? [];
176
+ const normalizedPath = path.replace(/\/+$/, "") || "/";
177
+ return pages.find((p) => p.path === normalizedPath) ?? null;
178
+ }
179
+
180
+ async listPages(status?: string): Promise<ContentStructurePage[]> {
181
+ const snapshot = await this.loadSnapshot();
182
+ const pages = snapshot.pages ?? [];
183
+ if (status) {
184
+ return pages.filter((p) => p.status === status);
185
+ }
186
+ return pages;
187
+ }
188
+ }
189
+
137
190
  export async function fetchPublicSiteSnapshotEnvelope(input: {
138
191
  contentApiToken: string;
139
192
  environment: "preview" | "live";
@@ -181,59 +234,92 @@ export async function fetchPublicSiteSnapshotEnvelope(input: {
181
234
 
182
235
  const loadPublicSiteSnapshotEnvelope = cache(async () =>
183
236
  fetchPublicSiteSnapshotEnvelope({
184
- contentApiToken: env.MW_CONTENT_API_TOKEN,
237
+ contentApiToken: requirePublicContentEnv(
238
+ "MW_CONTENT_API_TOKEN",
239
+ env.MW_CONTENT_API_TOKEN,
240
+ ),
185
241
  environment: env.MW_PUBLIC_SITE_ENV,
186
242
  platformBaseUrl: env.MW_PLATFORM_BASE_URL,
187
- propertyKey: env.MW_PUBLIC_SITE_PROPERTY_KEY,
243
+ propertyKey: requirePublicContentEnv(
244
+ "MW_PUBLIC_SITE_PROPERTY_KEY",
245
+ env.MW_PUBLIC_SITE_PROPERTY_KEY,
246
+ ),
188
247
  }),
189
248
  );
190
249
 
191
- export class MinuteWorkPublicSiteContentAdapter implements PublicContentAdapter {
250
+ export class MinuteWorkPublicSiteContentAdapter extends PublicSiteSnapshotContentAdapter {
192
251
  constructor(
193
- private readonly loadEnvelope: () => Promise<PublicSiteSnapshotEnvelope> = loadPublicSiteSnapshotEnvelope,
194
- ) {}
252
+ loadEnvelope: () => Promise<PublicSiteSnapshotEnvelope> = loadPublicSiteSnapshotEnvelope,
253
+ ) {
254
+ super(async () => (await loadEnvelope()).snapshot);
255
+ }
256
+ }
257
+
258
+ const loadStaticPublicContentSnapshot = cache(async () => {
259
+ const [{ readFile }, { resolve }] = await Promise.all([
260
+ import("node:fs/promises"),
261
+ import("node:path"),
262
+ ]);
263
+ const contentPath = resolve(
264
+ /* turbopackIgnore: true */ process.cwd(),
265
+ env.MW_STATIC_PUBLIC_CONTENT_PATH,
266
+ );
267
+ let payload: unknown;
195
268
 
196
- private async loadSnapshot() {
197
- return (await this.loadEnvelope()).snapshot;
269
+ try {
270
+ payload = JSON.parse(await readFile(contentPath, "utf8"));
271
+ } catch (error) {
272
+ throw new Error(
273
+ `Unable to load static public content from "${env.MW_STATIC_PUBLIC_CONTENT_PATH}": ${error instanceof Error ? error.message : String(error)}`,
274
+ );
275
+ }
276
+
277
+ const parsedSnapshot = publicContentSnapshotSchema.safeParse(payload);
278
+
279
+ if (!parsedSnapshot.success) {
280
+ throw new Error(
281
+ `Static public content is invalid: ${formatZodIssues(parsedSnapshot.error.issues)}`,
282
+ );
283
+ }
284
+
285
+ return parsedSnapshot.data;
286
+ });
287
+
288
+ export class StaticJsonPublicContentAdapter extends PublicSiteSnapshotContentAdapter {
289
+ constructor(
290
+ loadSnapshot: () => Promise<PublicContentSnapshot> = loadStaticPublicContentSnapshot,
291
+ ) {
292
+ super(loadSnapshot);
293
+ }
294
+ }
295
+
296
+ class DisabledPublicContentAdapter implements PublicContentAdapter {
297
+ private throwDisabled(): never {
298
+ throw new Error("Public content is disabled by MW_PUBLIC_CONTENT_SOURCE=none.");
198
299
  }
199
300
 
200
301
  async getSiteConfig(): Promise<SiteConfig> {
201
- return (await this.loadSnapshot()).site;
302
+ this.throwDisabled();
202
303
  }
203
304
 
204
- async getMarketingPage(pageKey: MarketingPageKey): Promise<MarketingPage | null> {
205
- return (
206
- (await this.loadSnapshot()).marketingPages.find((page) => page.pageKey === pageKey) ??
207
- null
208
- );
305
+ async getMarketingPage(): Promise<MarketingPage | null> {
306
+ this.throwDisabled();
209
307
  }
210
308
 
211
- async listEntries(kind: PublicContentKind): Promise<PublicEntrySummary[]> {
212
- return sortEntriesByDate((await this.loadSnapshot())[kind]);
309
+ async listEntries(): Promise<PublicEntrySummary[]> {
310
+ this.throwDisabled();
213
311
  }
214
312
 
215
- async getEntry(kind: PublicContentKind, slugParts: string[]): Promise<PublicEntry | null> {
216
- return (
217
- (await this.loadSnapshot())[kind].find((entry) =>
218
- compareSlugParts(entry.slug, slugParts),
219
- ) ?? null
220
- );
313
+ async getEntry(): Promise<PublicEntry | null> {
314
+ this.throwDisabled();
221
315
  }
222
316
 
223
- async getPageByPath(path: string): Promise<ContentStructurePage | null> {
224
- const snapshot = await this.loadSnapshot();
225
- const pages = snapshot.pages ?? [];
226
- const normalizedPath = path.replace(/\/+$/, "") || "/";
227
- return pages.find((p) => p.path === normalizedPath) ?? null;
317
+ async getPageByPath(): Promise<ContentStructurePage | null> {
318
+ this.throwDisabled();
228
319
  }
229
320
 
230
- async listPages(status?: string): Promise<ContentStructurePage[]> {
231
- const snapshot = await this.loadSnapshot();
232
- const pages = snapshot.pages ?? [];
233
- if (status) {
234
- return pages.filter((p) => p.status === status);
235
- }
236
- return pages;
321
+ async listPages(): Promise<ContentStructurePage[]> {
322
+ this.throwDisabled();
237
323
  }
238
324
  }
239
325
 
@@ -241,17 +327,36 @@ const defaultPublicContentAdapter = new ValidatedPublicContentAdapter(
241
327
  new MinuteWorkPublicSiteContentAdapter(),
242
328
  "MinuteWork public-site content adapter",
243
329
  );
330
+ const staticJsonPublicContentAdapter = new ValidatedPublicContentAdapter(
331
+ new StaticJsonPublicContentAdapter(),
332
+ "Static JSON public content adapter",
333
+ );
334
+ const disabledPublicContentAdapter = new DisabledPublicContentAdapter();
244
335
 
245
336
  export const getPublicContentAdapter = cache(() => {
246
- const customAdapter = resolveCustomPublicContentAdapter();
337
+ if (env.MW_PUBLIC_CONTENT_SOURCE === "custom") {
338
+ const customAdapter = resolveCustomPublicContentAdapter();
339
+
340
+ if (!customAdapter) {
341
+ throw new Error(
342
+ "MW_PUBLIC_CONTENT_SOURCE=custom requires resolveCustomPublicContentAdapter() to return a PublicContentAdapter.",
343
+ );
344
+ }
247
345
 
248
- if (customAdapter) {
249
346
  return new ValidatedPublicContentAdapter(
250
347
  customAdapter,
251
348
  "Custom public content adapter",
252
349
  );
253
350
  }
254
351
 
352
+ if (env.MW_PUBLIC_CONTENT_SOURCE === "static_json") {
353
+ return staticJsonPublicContentAdapter;
354
+ }
355
+
356
+ if (env.MW_PUBLIC_CONTENT_SOURCE === "none") {
357
+ return disabledPublicContentAdapter;
358
+ }
359
+
255
360
  return defaultPublicContentAdapter;
256
361
  });
257
362
 
@@ -1,3 +1,5 @@
1
+ import { readFile } from "node:fs/promises";
2
+
1
3
  import { afterEach, describe, expect, it, vi } from "vitest";
2
4
 
3
5
  const originalEnv = { ...process.env };
@@ -15,6 +17,7 @@ function restoreProcessEnv() {
15
17
  function applyServerEnv(overrides: Record<string, string | undefined>) {
16
18
  restoreProcessEnv();
17
19
  process.env.MW_PLATFORM_BASE_URL = "http://127.0.0.1:8000";
20
+ process.env.MW_PUBLIC_CONTENT_SOURCE = "minutework_cms";
18
21
  process.env.MW_CONTENT_API_TOKEN = "test-content-token";
19
22
  process.env.MW_PUBLIC_BASE_URL = "http://127.0.0.1:3000";
20
23
  process.env.MW_PUBLIC_SITE_PROPERTY_KEY = "main-site";
@@ -29,6 +32,23 @@ function applyServerEnv(overrides: Record<string, string | undefined>) {
29
32
  }
30
33
  }
31
34
 
35
+ async function readEnvExample() {
36
+ const contents = await readFile(".env.example", "utf8");
37
+ return Object.fromEntries(
38
+ contents
39
+ .split("\n")
40
+ .map((line) => line.trim())
41
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
42
+ .map((line) => {
43
+ const separatorIndex = line.indexOf("=");
44
+ return [
45
+ line.slice(0, separatorIndex),
46
+ line.slice(separatorIndex + 1),
47
+ ] as const;
48
+ }),
49
+ );
50
+ }
51
+
32
52
  describe("server env", () => {
33
53
  afterEach(() => {
34
54
  restoreProcessEnv();
@@ -45,6 +65,32 @@ describe("server env", () => {
45
65
  expect(env.MW_PUBLIC_SITE_ENV).toBe("preview");
46
66
  });
47
67
 
68
+ it("defaults MW_PUBLIC_CONTENT_SOURCE to private-only mode", async () => {
69
+ applyServerEnv({
70
+ MW_PUBLIC_CONTENT_SOURCE: undefined,
71
+ MW_CONTENT_API_TOKEN: undefined,
72
+ MW_PUBLIC_BASE_URL: undefined,
73
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
74
+ });
75
+
76
+ const { env } = await import("./env.server");
77
+
78
+ expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("none");
79
+ expect(env.MW_CONTENT_API_TOKEN).toBeUndefined();
80
+ expect(env.MW_PUBLIC_BASE_URL).toBeUndefined();
81
+ expect(env.MW_PUBLIC_SITE_PROPERTY_KEY).toBeUndefined();
82
+ });
83
+
84
+ it("defaults MW_STATIC_PUBLIC_CONTENT_PATH to content/public-site.json", async () => {
85
+ applyServerEnv({
86
+ MW_STATIC_PUBLIC_CONTENT_PATH: undefined,
87
+ });
88
+
89
+ const { env } = await import("./env.server");
90
+
91
+ expect(env.MW_STATIC_PUBLIC_CONTENT_PATH).toBe("content/public-site.json");
92
+ });
93
+
48
94
  it("accepts MW_PUBLIC_SITE_ENV=live", async () => {
49
95
  applyServerEnv({
50
96
  MW_PUBLIC_SITE_ENV: "live",
@@ -81,6 +127,69 @@ describe("server env", () => {
81
127
  );
82
128
  });
83
129
 
130
+ it("accepts custom public content without MinuteWork CMS credentials", async () => {
131
+ applyServerEnv({
132
+ MW_PUBLIC_CONTENT_SOURCE: "custom",
133
+ MW_CONTENT_API_TOKEN: undefined,
134
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
135
+ });
136
+
137
+ const { env } = await import("./env.server");
138
+
139
+ expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("custom");
140
+ expect(env.MW_CONTENT_API_TOKEN).toBeUndefined();
141
+ expect(env.MW_PUBLIC_SITE_PROPERTY_KEY).toBeUndefined();
142
+ });
143
+
144
+ it("requires MW_PUBLIC_BASE_URL for custom public content", async () => {
145
+ applyServerEnv({
146
+ MW_PUBLIC_CONTENT_SOURCE: "custom",
147
+ MW_CONTENT_API_TOKEN: undefined,
148
+ MW_PUBLIC_BASE_URL: undefined,
149
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
150
+ });
151
+
152
+ await expect(import("./env.server")).rejects.toThrow("MW_PUBLIC_BASE_URL");
153
+ });
154
+
155
+ it("accepts static JSON public content without MinuteWork CMS credentials", async () => {
156
+ applyServerEnv({
157
+ MW_PUBLIC_CONTENT_SOURCE: "static_json",
158
+ MW_CONTENT_API_TOKEN: undefined,
159
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
160
+ MW_STATIC_PUBLIC_CONTENT_PATH: "content/site.json",
161
+ });
162
+
163
+ const { env } = await import("./env.server");
164
+
165
+ expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("static_json");
166
+ expect(env.MW_STATIC_PUBLIC_CONTENT_PATH).toBe("content/site.json");
167
+ });
168
+
169
+ it("accepts private-only mode without public or CMS environment", async () => {
170
+ applyServerEnv({
171
+ MW_PUBLIC_CONTENT_SOURCE: "none",
172
+ MW_CONTENT_API_TOKEN: undefined,
173
+ MW_PUBLIC_BASE_URL: undefined,
174
+ MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
175
+ });
176
+
177
+ const { env } = await import("./env.server");
178
+
179
+ expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("none");
180
+ expect(env.MW_PUBLIC_BASE_URL).toBeUndefined();
181
+ });
182
+
183
+ it("ships .env.example as private-only so generated apps boot without CMS", async () => {
184
+ await expect(readEnvExample()).resolves.toMatchObject({
185
+ MW_PUBLIC_CONTENT_SOURCE: "none",
186
+ MW_CONTENT_API_TOKEN: "",
187
+ MW_PUBLIC_BASE_URL: "",
188
+ MW_PUBLIC_SITE_PROPERTY_KEY: "",
189
+ MW_STATIC_PUBLIC_CONTENT_PATH: "content/public-site.json",
190
+ });
191
+ });
192
+
84
193
  it("requires MW_PLATFORM_BASE_URL in production", async () => {
85
194
  applyServerEnv({
86
195
  MW_PLATFORM_BASE_URL: undefined,