minutework 0.1.26 → 0.1.27
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.
- package/assets/claude-local/CLAUDE.md.template +4 -2
- package/assets/claude-local/skills/published-web-and-mw-core-site/SKILL.md +3 -1
- package/assets/templates/next-tenant-app/.env.example +4 -3
- package/assets/templates/next-tenant-app/README.md +9 -4
- package/assets/templates/next-tenant-app/src/app/(cms)/[...path]/page.tsx +17 -1
- package/assets/templates/next-tenant-app/src/app/app/private-content-source.test.ts +88 -0
- package/assets/templates/next-tenant-app/src/app/blog/[slug]/page.tsx +17 -1
- package/assets/templates/next-tenant-app/src/app/blog/page.test.ts +4 -0
- package/assets/templates/next-tenant-app/src/app/blog/page.tsx +15 -1
- package/assets/templates/next-tenant-app/src/app/docs/[...slug]/page.tsx +17 -1
- package/assets/templates/next-tenant-app/src/app/docs/page.test.ts +4 -0
- package/assets/templates/next-tenant-app/src/app/docs/page.tsx +15 -1
- package/assets/templates/next-tenant-app/src/app/layout.tsx +4 -2
- package/assets/templates/next-tenant-app/src/app/page.test.ts +4 -0
- package/assets/templates/next-tenant-app/src/app/page.tsx +15 -1
- package/assets/templates/next-tenant-app/src/app/pricing/page.test.ts +4 -0
- package/assets/templates/next-tenant-app/src/app/pricing/page.tsx +15 -1
- package/assets/templates/next-tenant-app/src/app/robots.test.ts +24 -4
- package/assets/templates/next-tenant-app/src/app/robots.ts +15 -1
- package/assets/templates/next-tenant-app/src/app/sitemap.test.ts +16 -2
- package/assets/templates/next-tenant-app/src/app/sitemap.ts +21 -7
- package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +102 -0
- package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +140 -35
- package/assets/templates/next-tenant-app/src/lib/platform/env.server.test.ts +103 -0
- package/assets/templates/next-tenant-app/src/lib/platform/env.server.ts +84 -20
- package/assets/templates/next-tenant-app/src/lib/public-site.test.ts +1 -1
- package/assets/templates/next-tenant-app/src/lib/public-site.ts +30 -6
- package/assets/templates/next-tenant-app/tools/template/with-public-site-fixture.mjs +1 -0
- package/assets/templates/next-tenant-app/vitest.config.ts +2 -0
- 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:
|
|
29
|
+
url: buildSitemapUrl(appRoutes.publicHome),
|
|
16
30
|
changeFrequency: "weekly",
|
|
17
31
|
priority: 1,
|
|
18
32
|
},
|
|
19
33
|
{
|
|
20
|
-
url:
|
|
34
|
+
url: buildSitemapUrl(appRoutes.pricing),
|
|
21
35
|
changeFrequency: "monthly",
|
|
22
36
|
priority: 0.8,
|
|
23
37
|
},
|
|
24
38
|
{
|
|
25
|
-
url:
|
|
39
|
+
url: buildSitemapUrl(appRoutes.docsIndex),
|
|
26
40
|
changeFrequency: "weekly",
|
|
27
41
|
priority: 0.8,
|
|
28
42
|
},
|
|
29
43
|
{
|
|
30
|
-
url:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
250
|
+
export class MinuteWorkPublicSiteContentAdapter extends PublicSiteSnapshotContentAdapter {
|
|
192
251
|
constructor(
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
302
|
+
this.throwDisabled();
|
|
202
303
|
}
|
|
203
304
|
|
|
204
|
-
async getMarketingPage(
|
|
205
|
-
|
|
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(
|
|
212
|
-
|
|
309
|
+
async listEntries(): Promise<PublicEntrySummary[]> {
|
|
310
|
+
this.throwDisabled();
|
|
213
311
|
}
|
|
214
312
|
|
|
215
|
-
async getEntry(
|
|
216
|
-
|
|
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(
|
|
224
|
-
|
|
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(
|
|
231
|
-
|
|
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
|
-
|
|
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,26 @@ 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 minutework_cms", async () => {
|
|
69
|
+
applyServerEnv({
|
|
70
|
+
MW_PUBLIC_CONTENT_SOURCE: undefined,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { env } = await import("./env.server");
|
|
74
|
+
|
|
75
|
+
expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("minutework_cms");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("defaults MW_STATIC_PUBLIC_CONTENT_PATH to content/public-site.json", async () => {
|
|
79
|
+
applyServerEnv({
|
|
80
|
+
MW_STATIC_PUBLIC_CONTENT_PATH: undefined,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { env } = await import("./env.server");
|
|
84
|
+
|
|
85
|
+
expect(env.MW_STATIC_PUBLIC_CONTENT_PATH).toBe("content/public-site.json");
|
|
86
|
+
});
|
|
87
|
+
|
|
48
88
|
it("accepts MW_PUBLIC_SITE_ENV=live", async () => {
|
|
49
89
|
applyServerEnv({
|
|
50
90
|
MW_PUBLIC_SITE_ENV: "live",
|
|
@@ -81,6 +121,69 @@ describe("server env", () => {
|
|
|
81
121
|
);
|
|
82
122
|
});
|
|
83
123
|
|
|
124
|
+
it("accepts custom public content without MinuteWork CMS credentials", async () => {
|
|
125
|
+
applyServerEnv({
|
|
126
|
+
MW_PUBLIC_CONTENT_SOURCE: "custom",
|
|
127
|
+
MW_CONTENT_API_TOKEN: undefined,
|
|
128
|
+
MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const { env } = await import("./env.server");
|
|
132
|
+
|
|
133
|
+
expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("custom");
|
|
134
|
+
expect(env.MW_CONTENT_API_TOKEN).toBeUndefined();
|
|
135
|
+
expect(env.MW_PUBLIC_SITE_PROPERTY_KEY).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("requires MW_PUBLIC_BASE_URL for custom public content", async () => {
|
|
139
|
+
applyServerEnv({
|
|
140
|
+
MW_PUBLIC_CONTENT_SOURCE: "custom",
|
|
141
|
+
MW_CONTENT_API_TOKEN: undefined,
|
|
142
|
+
MW_PUBLIC_BASE_URL: undefined,
|
|
143
|
+
MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await expect(import("./env.server")).rejects.toThrow("MW_PUBLIC_BASE_URL");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts static JSON public content without MinuteWork CMS credentials", async () => {
|
|
150
|
+
applyServerEnv({
|
|
151
|
+
MW_PUBLIC_CONTENT_SOURCE: "static_json",
|
|
152
|
+
MW_CONTENT_API_TOKEN: undefined,
|
|
153
|
+
MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
|
|
154
|
+
MW_STATIC_PUBLIC_CONTENT_PATH: "content/site.json",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const { env } = await import("./env.server");
|
|
158
|
+
|
|
159
|
+
expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("static_json");
|
|
160
|
+
expect(env.MW_STATIC_PUBLIC_CONTENT_PATH).toBe("content/site.json");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("accepts private-only mode without public or CMS environment", async () => {
|
|
164
|
+
applyServerEnv({
|
|
165
|
+
MW_PUBLIC_CONTENT_SOURCE: "none",
|
|
166
|
+
MW_CONTENT_API_TOKEN: undefined,
|
|
167
|
+
MW_PUBLIC_BASE_URL: undefined,
|
|
168
|
+
MW_PUBLIC_SITE_PROPERTY_KEY: undefined,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const { env } = await import("./env.server");
|
|
172
|
+
|
|
173
|
+
expect(env.MW_PUBLIC_CONTENT_SOURCE).toBe("none");
|
|
174
|
+
expect(env.MW_PUBLIC_BASE_URL).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("ships .env.example as private-only so generated apps boot without CMS", async () => {
|
|
178
|
+
await expect(readEnvExample()).resolves.toMatchObject({
|
|
179
|
+
MW_PUBLIC_CONTENT_SOURCE: "none",
|
|
180
|
+
MW_CONTENT_API_TOKEN: "",
|
|
181
|
+
MW_PUBLIC_BASE_URL: "",
|
|
182
|
+
MW_PUBLIC_SITE_PROPERTY_KEY: "",
|
|
183
|
+
MW_STATIC_PUBLIC_CONTENT_PATH: "content/public-site.json",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
84
187
|
it("requires MW_PLATFORM_BASE_URL in production", async () => {
|
|
85
188
|
applyServerEnv({
|
|
86
189
|
MW_PLATFORM_BASE_URL: undefined,
|