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
@@ -190,8 +190,10 @@ a concrete backend responsibility such as:
190
190
  `submission` schemas before Builder starts authoring.
191
191
  - Author public content against runtime/CMS records and published-web flows, not
192
192
  against a fixed in-repo marketing template.
193
- - Use `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and
194
- `MW_PUBLIC_SITE_ENV` as the standard site env contract.
193
+ - Use `MW_PUBLIC_CONTENT_SOURCE` to select the public content adapter mode.
194
+ - `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and
195
+ `MW_PUBLIC_SITE_ENV` are required only for `MW_PUBLIC_CONTENT_SOURCE=minutework_cms`.
196
+ - `MW_STATIC_PUBLIC_CONTENT_PATH` is used for `MW_PUBLIC_CONTENT_SOURCE=static_json`.
195
197
  - `MW_PUBLIC_SITE_ENV=preview` is for preview or draft-safe reads.
196
198
  - `MW_PUBLIC_SITE_ENV=live` is for publication-safe delivery.
197
199
  - Anonymous live delivery should prefer published snapshots instead of direct
@@ -26,8 +26,10 @@ flows, or the default MinuteWork site model.
26
26
  `contract-first-public-intake/SKILL.md`.
27
27
  - Use `published-web` and hosted public-release flows for anonymous delivery by
28
28
  default.
29
+ - `MW_PUBLIC_CONTENT_SOURCE` selects the public content adapter mode.
29
30
  - `MW_CONTENT_API_TOKEN`, `MW_PUBLIC_SITE_PROPERTY_KEY`, and
30
- `MW_PUBLIC_SITE_ENV` are the standard site env variables.
31
+ `MW_PUBLIC_SITE_ENV` are required only for `MW_PUBLIC_CONTENT_SOURCE=minutework_cms`.
32
+ - `MW_STATIC_PUBLIC_CONTENT_PATH` is used for `MW_PUBLIC_CONTENT_SOURCE=static_json`.
31
33
  - `MW_PUBLIC_SITE_ENV=preview` is for preview or draft-safe reads.
32
34
  - `MW_PUBLIC_SITE_ENV=live` is for publication-safe reads only.
33
35
  - Anonymous live traffic should prefer published snapshots instead of direct
@@ -1,8 +1,9 @@
1
1
  MW_PLATFORM_BASE_URL=http://127.0.0.1:8000
2
+ MW_PUBLIC_CONTENT_SOURCE=none
2
3
  MW_CONTENT_API_TOKEN=
3
4
  MW_TEMPLATE_APP_NAME=MinuteWork Combined Starter
4
- # Local development example. Set this explicitly per environment.
5
- MW_PUBLIC_BASE_URL=http://127.0.0.1:3000
6
- MW_PUBLIC_SITE_PROPERTY_KEY=main-site
5
+ MW_PUBLIC_BASE_URL=
6
+ MW_PUBLIC_SITE_PROPERTY_KEY=
7
7
  MW_PUBLIC_SITE_ENV=preview
8
+ MW_STATIC_PUBLIC_CONTENT_PATH=content/public-site.json
8
9
  MW_ENABLE_RUNTIME_COMMAND_EXAMPLE=false
@@ -89,14 +89,19 @@ When disabled:
89
89
 
90
90
  ## Environment
91
91
 
92
- Copy `.env.example` to `.env.local` when running the template directly.
92
+ Copy `.env.example` to `.env.local` when running the template directly. The
93
+ example file uses `MW_PUBLIC_CONTENT_SOURCE=none` so the private `/app` surface
94
+ can boot without MinuteWork CMS credentials; set `MW_PUBLIC_CONTENT_SOURCE` to
95
+ `minutework_cms` and fill the CMS values below when enabling the public site.
93
96
 
94
97
  - `MW_PLATFORM_BASE_URL` is required
95
- - `MW_CONTENT_API_TOKEN` is required and must stay server-only
98
+ - `MW_PUBLIC_CONTENT_SOURCE` defaults to `none` when omitted; supported values are `minutework_cms`, `custom`, `static_json`, and `none`
99
+ - `MW_CONTENT_API_TOKEN` is required only for `minutework_cms` and must stay server-only
96
100
  - `MW_TEMPLATE_APP_NAME` defaults to `MinuteWork Combined Starter`
97
- - `MW_PUBLIC_BASE_URL` is required and should match the deployed public origin
98
- - `MW_PUBLIC_SITE_PROPERTY_KEY` is required and selects the `PublishedWebProperty` backing the site
101
+ - `MW_PUBLIC_BASE_URL` is required for public content sources and should match the deployed public origin; it may be omitted when `MW_PUBLIC_CONTENT_SOURCE=none`
102
+ - `MW_PUBLIC_SITE_PROPERTY_KEY` is required only for `minutework_cms` and selects the `PublishedWebProperty` backing the site
99
103
  - `MW_PUBLIC_SITE_ENV` defaults to `preview`
104
+ - `MW_STATIC_PUBLIC_CONTENT_PATH` defaults to `content/public-site.json` and is used when `MW_PUBLIC_CONTENT_SOURCE=static_json`
100
105
  - `MW_ENABLE_RUNTIME_COMMAND_EXAMPLE` defaults to `false`
101
106
 
102
107
  ## SEO baseline
@@ -4,11 +4,19 @@ import { notFound } from "next/navigation";
4
4
  import { ContentStructureRenderer } from "@/features/public-shell/components/section-renderer";
5
5
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
6
6
  import { getPageByPath, getSiteConfig, listPages } from "@/lib/content/adapter.server";
7
- import { buildPublicMetadata } from "@/lib/public-site";
7
+ import {
8
+ buildDisabledPublicMetadata,
9
+ buildPublicMetadata,
10
+ isPublicContentDisabled,
11
+ } from "@/lib/public-site";
8
12
 
9
13
  type PageParams = { path: string[] };
10
14
 
11
15
  export async function generateStaticParams(): Promise<PageParams[]> {
16
+ if (isPublicContentDisabled()) {
17
+ return [];
18
+ }
19
+
12
20
  const pages = await listPages("published");
13
21
  return pages
14
22
  .filter((p) => p.path !== "/")
@@ -22,6 +30,10 @@ export async function generateMetadata({
22
30
  }: {
23
31
  params: Promise<PageParams>;
24
32
  }): Promise<Metadata> {
33
+ if (isPublicContentDisabled()) {
34
+ return buildDisabledPublicMetadata();
35
+ }
36
+
25
37
  const { path: pathSegments } = await params;
26
38
  const pagePath = `/${pathSegments.join("/")}`;
27
39
  const page = await getPageByPath(pagePath);
@@ -41,6 +53,10 @@ export default async function CmsPage({
41
53
  }: {
42
54
  params: Promise<PageParams>;
43
55
  }) {
56
+ if (isPublicContentDisabled()) {
57
+ notFound();
58
+ }
59
+
44
60
  const { path: pathSegments } = await params;
45
61
  const pagePath = `/${pathSegments.join("/")}`;
46
62
  const [siteConfig, page] = await Promise.all([
@@ -0,0 +1,88 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const originalEnv = { ...process.env };
4
+ const resolveAuthenticatedSession = vi.fn();
5
+
6
+ function restoreProcessEnv() {
7
+ for (const key of Object.keys(process.env)) {
8
+ if (!(key in originalEnv)) {
9
+ delete process.env[key];
10
+ }
11
+ }
12
+
13
+ Object.assign(process.env, originalEnv);
14
+ }
15
+
16
+ function applyPrivateOnlyServerEnv() {
17
+ restoreProcessEnv();
18
+ process.env.MW_PLATFORM_BASE_URL = "http://127.0.0.1:8000";
19
+ process.env.MW_PUBLIC_CONTENT_SOURCE = "none";
20
+ process.env.MW_TEMPLATE_APP_NAME = "Private Only Workspace";
21
+ process.env.MW_ENABLE_RUNTIME_COMMAND_EXAMPLE = "false";
22
+ delete process.env.MW_CONTENT_API_TOKEN;
23
+ delete process.env.MW_PUBLIC_BASE_URL;
24
+ delete process.env.MW_PUBLIC_SITE_PROPERTY_KEY;
25
+ }
26
+
27
+ vi.mock("@/features/shell/components/private-app-shell", () => ({
28
+ PrivateAppShell: () => null,
29
+ }));
30
+
31
+ vi.mock("@/lib/platform/auth.server", () => ({
32
+ resolveAuthenticatedSession,
33
+ }));
34
+
35
+ vi.mock("@/lib/platform/endpoints.server", () => ({
36
+ platformAuthEndpoints: {
37
+ operatorConsole: "http://127.0.0.1:8000/ops/login/",
38
+ },
39
+ }));
40
+
41
+ describe("private app routes with disabled public content", () => {
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ vi.resetModules();
45
+ applyPrivateOnlyServerEnv();
46
+ resolveAuthenticatedSession.mockResolvedValue({
47
+ authenticated: true,
48
+ user: {
49
+ id: "user-1",
50
+ username: "demo-user",
51
+ email: "demo@example.com",
52
+ },
53
+ active_tenant_id: "tenant-1",
54
+ active_tenant: {
55
+ tenant_id: "tenant-1",
56
+ tenant_slug: "alpha",
57
+ tenant_name: "Alpha",
58
+ role: "admin",
59
+ },
60
+ memberships: [
61
+ {
62
+ tenant_id: "tenant-1",
63
+ tenant_slug: "alpha",
64
+ tenant_name: "Alpha",
65
+ role: "admin",
66
+ },
67
+ ],
68
+ });
69
+ });
70
+
71
+ afterEach(() => {
72
+ restoreProcessEnv();
73
+ vi.resetModules();
74
+ });
75
+
76
+ it("imports /app entrypoints without CMS or public base environment", async () => {
77
+ const [layout, page] = await Promise.all([
78
+ import("./layout"),
79
+ import("./page"),
80
+ ]);
81
+
82
+ await expect(layout.default({ children: "workspace" })).resolves.toBe(
83
+ "workspace",
84
+ );
85
+ await expect(page.default()).resolves.toBeDefined();
86
+ expect(resolveAuthenticatedSession).toHaveBeenCalledTimes(2);
87
+ });
88
+ });
@@ -4,7 +4,11 @@ import { ContentArticle } from "@/features/public-shell/components/content-artic
4
4
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
5
5
  import { getEntry, getSiteConfig, listEntries } from "@/lib/content/adapter.server";
6
6
  import { appRoutes } from "@/lib/app-routes";
7
- import { buildPublicMetadata } from "@/lib/public-site";
7
+ import {
8
+ buildDisabledPublicMetadata,
9
+ buildPublicMetadata,
10
+ isPublicContentDisabled,
11
+ } from "@/lib/public-site";
8
12
 
9
13
  type BlogArticlePageProps = {
10
14
  params: Promise<{ slug: string }>;
@@ -13,12 +17,20 @@ type BlogArticlePageProps = {
13
17
  export const dynamicParams = false;
14
18
 
15
19
  export async function generateStaticParams() {
20
+ if (isPublicContentDisabled()) {
21
+ return [];
22
+ }
23
+
16
24
  return (await listEntries("blog")).map((entry) => ({
17
25
  slug: entry.slug[0],
18
26
  }));
19
27
  }
20
28
 
21
29
  export async function generateMetadata({ params }: BlogArticlePageProps) {
30
+ if (isPublicContentDisabled()) {
31
+ return buildDisabledPublicMetadata();
32
+ }
33
+
22
34
  const [{ slug }, siteConfig] = await Promise.all([params, getSiteConfig()]);
23
35
  const entry = await getEntry("blog", [slug]);
24
36
 
@@ -42,6 +54,10 @@ export async function generateMetadata({ params }: BlogArticlePageProps) {
42
54
  }
43
55
 
44
56
  export default async function BlogArticlePage({ params }: BlogArticlePageProps) {
57
+ if (isPublicContentDisabled()) {
58
+ notFound();
59
+ }
60
+
45
61
  const [{ slug }, siteConfig] = await Promise.all([params, getSiteConfig()]);
46
62
  const entry = await getEntry("blog", [slug]);
47
63
 
@@ -16,6 +16,10 @@ vi.mock("@/lib/content/adapter.server", () => ({
16
16
  listEntries,
17
17
  }));
18
18
 
19
+ vi.mock("next/navigation", () => ({
20
+ notFound: vi.fn(),
21
+ }));
22
+
19
23
  describe("blog index page", () => {
20
24
  beforeEach(() => {
21
25
  vi.clearAllMocks();
@@ -1,10 +1,20 @@
1
+ import { notFound } from "next/navigation";
2
+
1
3
  import { ContentCollection } from "@/features/public-shell/components/content-collection";
2
4
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
3
5
  import { getSiteConfig, listEntries } from "@/lib/content/adapter.server";
4
6
  import { appRoutes } from "@/lib/app-routes";
5
- import { buildPublicMetadata } from "@/lib/public-site";
7
+ import {
8
+ buildDisabledPublicMetadata,
9
+ buildPublicMetadata,
10
+ isPublicContentDisabled,
11
+ } from "@/lib/public-site";
6
12
 
7
13
  export async function generateMetadata() {
14
+ if (isPublicContentDisabled()) {
15
+ return buildDisabledPublicMetadata();
16
+ }
17
+
8
18
  const siteConfig = await getSiteConfig();
9
19
  const collection = siteConfig.collections.blog;
10
20
 
@@ -17,6 +27,10 @@ export async function generateMetadata() {
17
27
  }
18
28
 
19
29
  export default async function BlogIndexPage() {
30
+ if (isPublicContentDisabled()) {
31
+ notFound();
32
+ }
33
+
20
34
  const [siteConfig, entries] = await Promise.all([
21
35
  getSiteConfig(),
22
36
  listEntries("blog"),
@@ -4,7 +4,11 @@ import { ContentArticle } from "@/features/public-shell/components/content-artic
4
4
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
5
5
  import { getEntry, getSiteConfig, listEntries } from "@/lib/content/adapter.server";
6
6
  import { appRoutes } from "@/lib/app-routes";
7
- import { buildPublicMetadata } from "@/lib/public-site";
7
+ import {
8
+ buildDisabledPublicMetadata,
9
+ buildPublicMetadata,
10
+ isPublicContentDisabled,
11
+ } from "@/lib/public-site";
8
12
 
9
13
  type DocsArticlePageProps = {
10
14
  params: Promise<{ slug: string[] }>;
@@ -13,12 +17,20 @@ type DocsArticlePageProps = {
13
17
  export const dynamicParams = false;
14
18
 
15
19
  export async function generateStaticParams() {
20
+ if (isPublicContentDisabled()) {
21
+ return [];
22
+ }
23
+
16
24
  return (await listEntries("docs")).map((entry) => ({
17
25
  slug: entry.slug,
18
26
  }));
19
27
  }
20
28
 
21
29
  export async function generateMetadata({ params }: DocsArticlePageProps) {
30
+ if (isPublicContentDisabled()) {
31
+ return buildDisabledPublicMetadata();
32
+ }
33
+
22
34
  const [{ slug }, siteConfig] = await Promise.all([params, getSiteConfig()]);
23
35
  const entry = await getEntry("docs", slug);
24
36
 
@@ -40,6 +52,10 @@ export async function generateMetadata({ params }: DocsArticlePageProps) {
40
52
  }
41
53
 
42
54
  export default async function DocsArticlePage({ params }: DocsArticlePageProps) {
55
+ if (isPublicContentDisabled()) {
56
+ notFound();
57
+ }
58
+
43
59
  const [{ slug }, siteConfig] = await Promise.all([params, getSiteConfig()]);
44
60
  const entry = await getEntry("docs", slug);
45
61
 
@@ -16,6 +16,10 @@ vi.mock("@/lib/content/adapter.server", () => ({
16
16
  listEntries,
17
17
  }));
18
18
 
19
+ vi.mock("next/navigation", () => ({
20
+ notFound: vi.fn(),
21
+ }));
22
+
19
23
  describe("docs index page", () => {
20
24
  beforeEach(() => {
21
25
  vi.clearAllMocks();
@@ -1,10 +1,20 @@
1
+ import { notFound } from "next/navigation";
2
+
1
3
  import { ContentCollection } from "@/features/public-shell/components/content-collection";
2
4
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
3
5
  import { getSiteConfig, listEntries } from "@/lib/content/adapter.server";
4
6
  import { appRoutes } from "@/lib/app-routes";
5
- import { buildPublicMetadata } from "@/lib/public-site";
7
+ import {
8
+ buildDisabledPublicMetadata,
9
+ buildPublicMetadata,
10
+ isPublicContentDisabled,
11
+ } from "@/lib/public-site";
6
12
 
7
13
  export async function generateMetadata() {
14
+ if (isPublicContentDisabled()) {
15
+ return buildDisabledPublicMetadata();
16
+ }
17
+
8
18
  const siteConfig = await getSiteConfig();
9
19
  const collection = siteConfig.collections.docs;
10
20
 
@@ -17,6 +27,10 @@ export async function generateMetadata() {
17
27
  }
18
28
 
19
29
  export default async function DocsIndexPage() {
30
+ if (isPublicContentDisabled()) {
31
+ notFound();
32
+ }
33
+
20
34
  const [siteConfig, entries] = await Promise.all([
21
35
  getSiteConfig(),
22
36
  listEntries("docs"),
@@ -24,8 +24,10 @@ const geistMono = Geist_Mono({
24
24
  });
25
25
 
26
26
  export function generateMetadata(): Metadata {
27
+ const metadataBase = resolvePublicMetadataBase();
28
+
27
29
  return {
28
- metadataBase: resolvePublicMetadataBase(),
30
+ ...(metadataBase ? { metadataBase } : {}),
29
31
  title: {
30
32
  default: env.MW_TEMPLATE_APP_NAME,
31
33
  template: `%s | ${env.MW_TEMPLATE_APP_NAME}`,
@@ -36,9 +38,9 @@ export function generateMetadata(): Metadata {
36
38
  title: env.MW_TEMPLATE_APP_NAME,
37
39
  description:
38
40
  "SEO-first public and authenticated Next.js starter with a server-owned platform session BFF.",
39
- url: env.MW_PUBLIC_BASE_URL,
40
41
  siteName: env.MW_TEMPLATE_APP_NAME,
41
42
  type: "website",
43
+ ...(env.MW_PUBLIC_BASE_URL ? { url: env.MW_PUBLIC_BASE_URL } : {}),
42
44
  },
43
45
  };
44
46
  }
@@ -16,6 +16,10 @@ vi.mock("@/lib/content/adapter.server", () => ({
16
16
  getSiteConfig,
17
17
  }));
18
18
 
19
+ vi.mock("next/navigation", () => ({
20
+ notFound: vi.fn(),
21
+ }));
22
+
19
23
  describe("home page", () => {
20
24
  beforeEach(() => {
21
25
  vi.clearAllMocks();
@@ -1,11 +1,21 @@
1
+ import { notFound } from "next/navigation";
2
+
1
3
  import { MarketingPageCanvas } from "@/features/public-shell/components/marketing-page-canvas";
2
4
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
3
5
  import { getMarketingPage, getSiteConfig } from "@/lib/content/adapter.server";
4
6
  import { buildEmptyMarketingPage } from "@/lib/content/empty-state";
5
7
  import { appRoutes } from "@/lib/app-routes";
6
- import { buildPublicMetadata } from "@/lib/public-site";
8
+ import {
9
+ buildDisabledPublicMetadata,
10
+ buildPublicMetadata,
11
+ isPublicContentDisabled,
12
+ } from "@/lib/public-site";
7
13
 
8
14
  export async function generateMetadata() {
15
+ if (isPublicContentDisabled()) {
16
+ return buildDisabledPublicMetadata();
17
+ }
18
+
9
19
  const [siteConfig, page] = await Promise.all([
10
20
  getSiteConfig(),
11
21
  getMarketingPage("home"),
@@ -21,6 +31,10 @@ export async function generateMetadata() {
21
31
  }
22
32
 
23
33
  export default async function HomePage() {
34
+ if (isPublicContentDisabled()) {
35
+ notFound();
36
+ }
37
+
24
38
  const [siteConfig, page] = await Promise.all([
25
39
  getSiteConfig(),
26
40
  getMarketingPage("home"),
@@ -16,6 +16,10 @@ vi.mock("@/lib/content/adapter.server", () => ({
16
16
  getSiteConfig,
17
17
  }));
18
18
 
19
+ vi.mock("next/navigation", () => ({
20
+ notFound: vi.fn(),
21
+ }));
22
+
19
23
  describe("pricing page", () => {
20
24
  beforeEach(() => {
21
25
  vi.clearAllMocks();
@@ -1,11 +1,21 @@
1
+ import { notFound } from "next/navigation";
2
+
1
3
  import { MarketingPageCanvas } from "@/features/public-shell/components/marketing-page-canvas";
2
4
  import { PublicSiteShell } from "@/features/public-shell/components/public-site-shell";
3
5
  import { getMarketingPage, getSiteConfig } from "@/lib/content/adapter.server";
4
6
  import { buildEmptyMarketingPage } from "@/lib/content/empty-state";
5
7
  import { appRoutes } from "@/lib/app-routes";
6
- import { buildPublicMetadata } from "@/lib/public-site";
8
+ import {
9
+ buildDisabledPublicMetadata,
10
+ buildPublicMetadata,
11
+ isPublicContentDisabled,
12
+ } from "@/lib/public-site";
7
13
 
8
14
  export async function generateMetadata() {
15
+ if (isPublicContentDisabled()) {
16
+ return buildDisabledPublicMetadata();
17
+ }
18
+
9
19
  const [siteConfig, page] = await Promise.all([
10
20
  getSiteConfig(),
11
21
  getMarketingPage("pricing"),
@@ -21,6 +31,10 @@ export async function generateMetadata() {
21
31
  }
22
32
 
23
33
  export default async function PricingPage() {
34
+ if (isPublicContentDisabled()) {
35
+ notFound();
36
+ }
37
+
24
38
  const [siteConfig, page] = await Promise.all([
25
39
  getSiteConfig(),
26
40
  getMarketingPage("pricing"),
@@ -1,11 +1,15 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import robots from "./robots";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
2
 
5
3
  describe("robots route", () => {
6
- it("returns a public robots policy and sitemap url", () => {
4
+ beforeEach(() => {
5
+ vi.resetModules();
6
+ vi.doUnmock("@/lib/public-site");
7
+ });
8
+
9
+ it("returns a public robots policy and sitemap url", async () => {
7
10
  expect(process.env.MW_PUBLIC_BASE_URL).toBe("http://127.0.0.1:3000");
8
11
 
12
+ const { default: robots } = await import("./robots");
9
13
  const metadata = robots();
10
14
 
11
15
  expect(metadata.host).toBe("127.0.0.1:3000");
@@ -17,4 +21,20 @@ describe("robots route", () => {
17
21
  },
18
22
  ]);
19
23
  });
24
+
25
+ it("disallows indexing when public content is disabled", async () => {
26
+ vi.doMock("@/lib/public-site", () => ({
27
+ isPublicContentDisabled: () => true,
28
+ resolvePublicMetadataBase: () => null,
29
+ }));
30
+
31
+ const { default: robots } = await import("./robots");
32
+
33
+ expect(robots().rules).toEqual([
34
+ {
35
+ userAgent: "*",
36
+ disallow: "/",
37
+ },
38
+ ]);
39
+ });
20
40
  });
@@ -1,10 +1,24 @@
1
1
  import type { MetadataRoute } from "next";
2
2
 
3
- import { resolvePublicMetadataBase } from "@/lib/public-site";
3
+ import {
4
+ isPublicContentDisabled,
5
+ resolvePublicMetadataBase,
6
+ } from "@/lib/public-site";
4
7
 
5
8
  export default function robots(): MetadataRoute.Robots {
6
9
  const metadataBase = resolvePublicMetadataBase();
7
10
 
11
+ if (!metadataBase || isPublicContentDisabled()) {
12
+ return {
13
+ rules: [
14
+ {
15
+ userAgent: "*",
16
+ disallow: "/",
17
+ },
18
+ ],
19
+ };
20
+ }
21
+
8
22
  return {
9
23
  rules: [
10
24
  {
@@ -8,11 +8,11 @@ vi.mock("@/lib/content/adapter.server", () => ({
8
8
  listEntries,
9
9
  }));
10
10
 
11
- import sitemap from "./sitemap";
12
-
13
11
  describe("sitemap route", () => {
14
12
  beforeEach(() => {
15
13
  vi.clearAllMocks();
14
+ vi.resetModules();
15
+ vi.doUnmock("@/lib/public-site");
16
16
  });
17
17
 
18
18
  it("includes the public route surface and published content entries", async () => {
@@ -24,6 +24,7 @@ describe("sitemap route", () => {
24
24
 
25
25
  expect(process.env.MW_PUBLIC_BASE_URL).toBe("http://127.0.0.1:3000");
26
26
 
27
+ const { default: sitemap } = await import("./sitemap");
27
28
  const entries = await sitemap();
28
29
  const urls = entries.map((entry) => entry.url);
29
30
 
@@ -38,6 +39,7 @@ describe("sitemap route", () => {
38
39
  it("keeps home and pricing in the sitemap even when the snapshot has no marketing pages", async () => {
39
40
  listEntries.mockResolvedValue([]);
40
41
 
42
+ const { default: sitemap } = await import("./sitemap");
41
43
  const entries = await sitemap();
42
44
  const urls = entries.map((entry) => entry.url);
43
45
 
@@ -46,4 +48,16 @@ describe("sitemap route", () => {
46
48
  expect(urls).toContain("http://127.0.0.1:3000/docs");
47
49
  expect(urls).toContain("http://127.0.0.1:3000/blog");
48
50
  });
51
+
52
+ it("returns an empty sitemap when public content is disabled", async () => {
53
+ vi.doMock("@/lib/public-site", () => ({
54
+ isPublicContentDisabled: () => true,
55
+ resolvePublicSiteUrl: vi.fn(),
56
+ }));
57
+
58
+ const { default: sitemap } = await import("./sitemap");
59
+
60
+ await expect(sitemap()).resolves.toEqual([]);
61
+ expect(listEntries).not.toHaveBeenCalled();
62
+ });
49
63
  });