@pantheon-systems/create-p1-starter-kit 0.7.0 → 0.10.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 (69) hide show
  1. package/README.md +114 -0
  2. package/lib/cli.js +1 -1
  3. package/lib/messages.js +1 -1
  4. package/package.json +14 -5
  5. package/template/.env.example +33 -6
  6. package/template/CHANGELOG.md +20 -0
  7. package/template/__tests__/ai-generate.test.ts +74 -0
  8. package/template/__tests__/auth-route.test.ts +1 -1
  9. package/template/__tests__/chatbot-flag-wiring.test.ts +22 -2
  10. package/template/__tests__/editor-integration.test.ts +2 -2
  11. package/template/__tests__/editor-route-group.test.ts +44 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
  13. package/template/__tests__/page-seo-meta.test.ts +98 -0
  14. package/template/__tests__/page-seo.test.ts +127 -0
  15. package/template/__tests__/paragraph-block.test.ts +39 -0
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  18. package/template/__tests__/puck-root-meta.test.ts +102 -0
  19. package/template/__tests__/puck-root-selects.test.ts +86 -0
  20. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  21. package/template/__tests__/sanitize-richtext.test.ts +58 -0
  22. package/template/__tests__/seo-metadata-meta.test.ts +180 -0
  23. package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
  24. package/template/__tests__/seo-metadata.test.ts +105 -0
  25. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  26. package/template/app/[...puckPath]/page.tsx +27 -30
  27. package/template/app/layout.tsx +25 -1
  28. package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +49 -25
  29. package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
  30. package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
  31. package/template/app/p1/(editor)/layout.tsx +13 -0
  32. package/template/app/page.tsx +3 -21
  33. package/template/app/styles.css +19 -1
  34. package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
  35. package/template/components/p1-lockup.tsx +6 -26
  36. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  37. package/template/components/puck/data-list-block/index.ts +1 -0
  38. package/template/components/puck/grid-block.tsx +1 -1
  39. package/template/components/puck/media-figure-block.tsx +12 -0
  40. package/template/components/puck/paragraph-block.tsx +18 -33
  41. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  42. package/template/components/puck/paragraph-markdown.tsx +15 -0
  43. package/template/components/puck/root.tsx +185 -4
  44. package/template/components/puck/sanitize-richtext.ts +44 -0
  45. package/template/constants/assets.ts +1 -0
  46. package/template/eslint.config.js +20 -4
  47. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  48. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  49. package/template/lib/monsters-api.ts +14 -8
  50. package/template/lib/page-seo.ts +112 -0
  51. package/template/lib/remote-datasources.ts +26 -31
  52. package/template/lib/seo-metadata.consts.ts +21 -0
  53. package/template/lib/seo-metadata.ts +129 -0
  54. package/template/lib/swapi.ts +5 -5
  55. package/template/middleware.ts +15 -0
  56. package/template/next.config.mjs +11 -0
  57. package/template/package.json +17 -10
  58. package/template/pnpm-workspace.yaml +6 -0
  59. package/template/public/images/p1_logo.svg +5 -12
  60. package/template/public/images/p1_logo_reverse.svg +5 -0
  61. package/template/puck.config.tsx +6 -2
  62. package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
  63. package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
  64. package/template/scripts/asset-stub-hooks.mjs +25 -1
  65. package/template/scripts/sync-puck-registry.ts +84 -7
  66. package/template/tsconfig/nextjs.json +1 -1
  67. package/template/tsconfig.test.json +6 -0
  68. package/template/vitest.config.ts +25 -0
  69. package/template/next-env.d.ts +0 -6
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @pantheon-systems/create-p1-starter-kit
2
+
3
+ Scaffold a new P1 project — a Next.js app with the Puck visual editor and Pantheon Content
4
+ Publisher integration wired up and ready to run.
5
+
6
+ ## Usage
7
+
8
+ ```bash
9
+ # pnpm
10
+ pnpm create @pantheon-systems/p1-starter-kit my-app
11
+
12
+ # npm
13
+ npm create @pantheon-systems/p1-starter-kit my-app
14
+
15
+ # yarn
16
+ yarn create @pantheon-systems/p1-starter-kit my-app
17
+ ```
18
+
19
+ The CLI prompts for:
20
+
21
+ - **Project name** — defaults to the directory name argument
22
+ - **Package manager** — auto-detects pnpm/npm/yarn, allows override
23
+ - **Git initialization** — creates a repo with an initial commit
24
+ - **Dependency installation** — runs install immediately
25
+
26
+ ## What's included
27
+
28
+ - **Next.js 16 App Router** — server components and modern routing
29
+ - **Puck editor** — visual drag-and-drop page building at `/p1/<path>`
30
+ - **Content Publisher integration** — CMS with datasource bindings
31
+ - **Pre-built blocks** — typography, media, layout, and action components
32
+ - **Tailwind CSS v4**, **TypeScript**, **Vitest**, **ESLint**
33
+
34
+ ## Getting started
35
+
36
+ After scaffolding:
37
+
38
+ 1. **Configure environment variables:**
39
+
40
+ ```bash
41
+ cp .env.example .env
42
+ ```
43
+
44
+ Fill in:
45
+
46
+ | Variable | Purpose |
47
+ | --- | --- |
48
+ | `PCC_SITE_ID` | Content Cloud site ID |
49
+ | `PCC_TOKEN` | API token for your site |
50
+ | `NEXT_PUBLIC_CSS_BASE_URL` | CSS API base URL |
51
+ | `NEXT_PUBLIC_CSS_SITE_ID` | Site identifier (UUID) |
52
+ | `CSS_API_KEY` | Server-side API key |
53
+
54
+ 2. **Start the dev server:**
55
+
56
+ ```bash
57
+ pnpm dev
58
+ ```
59
+
60
+ 3. **Open:**
61
+ - Site — http://localhost:3000
62
+ - Dashboard — http://localhost:3000/p1
63
+ - Editor — http://localhost:3000/p1/your-page-path
64
+
65
+ ## Project structure
66
+
67
+ ```
68
+ my-app/
69
+ ├── app/
70
+ │ ├── page.tsx # Site root
71
+ │ ├── [...puckPath]/ # Published pages
72
+ │ └── p1/
73
+ │ ├── page.tsx # Dashboard
74
+ │ ├── [[...p1]]/ # Editor & renderer
75
+ │ ├── api/[...p1]/ # API routes
76
+ │ └── auth/[...action]/ # Auth routes
77
+ ├── components/puck/ # Block definitions
78
+ ├── lib/ # Datasources and utilities
79
+ ├── puck.config.tsx # Puck configuration
80
+ └── .env.example # Environment template
81
+ ```
82
+
83
+ ## Customization
84
+
85
+ - **Add blocks** — create components in `components/puck/`, register them in `puck.config.tsx`
86
+ - **Add datasources** — define in `lib/` and register for use in blocks
87
+ - **Styling** — edit the Tailwind config or component styles
88
+
89
+ Your scaffolded project also includes an optional `scripts/sync-puck-registry.ts` for syncing
90
+ the component registry from CI, with a sample workflow in `ci-examples/`. It is inert until you
91
+ wire it up; see the comments in those files for setup.
92
+
93
+ ## Troubleshooting
94
+
95
+ **Module resolution errors.** Next.js needs the P1 packages transpiled. Verify
96
+ `next.config.mjs` includes:
97
+
98
+ ```js
99
+ transpilePackages: [
100
+ "@pantheon-systems/css-client",
101
+ "@pantheon-systems/puck-css",
102
+ "@pantheon-systems/p1-next-sdk",
103
+ ],
104
+ ```
105
+
106
+ ## Resources
107
+
108
+ - [Pantheon documentation](https://docs.pantheon.io)
109
+ - [Puck editor docs](https://puckeditor.com)
110
+ - [Next.js docs](https://nextjs.org/docs)
111
+
112
+ ## License
113
+
114
+ MIT
package/lib/cli.js CHANGED
@@ -106,7 +106,7 @@ export async function runCLI() {
106
106
  if (error.code !== 'EEXIST') {
107
107
  try {
108
108
  fs.rmSync(targetDir, { recursive: true, force: true });
109
- } catch (cleanupError) {
109
+ } catch {
110
110
  // Ignore cleanup errors
111
111
  }
112
112
  }
package/lib/messages.js CHANGED
@@ -6,7 +6,7 @@ export function showWelcome() {
6
6
  console.log(pc.bold(pc.cyan('└─────────────────────────────────────────┘\n')));
7
7
  }
8
8
 
9
- export function showSuccess(projectName, projectPath, packageManager) {
9
+ export function showSuccess(projectName, _projectPath, packageManager) {
10
10
  console.log(pc.green('\n✔ Project created successfully!\n'));
11
11
  console.log(`${pc.bold('Next steps:')}\n`);
12
12
  console.log(` ${pc.cyan('cd')} ${projectName}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantheon-systems/create-p1-starter-kit",
3
- "version": "0.7.0",
3
+ "version": "0.10.0",
4
4
  "description": "Scaffold a new P1 starter project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/pantheon-systems/puck-css-integration.git",
21
+ "url": "git+https://github.com/pantheon-systems/p1-platform.git",
22
22
  "directory": "packages/create-p1-starter-kit"
23
23
  },
24
24
  "keywords": [
@@ -30,20 +30,29 @@
30
30
  "template"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=20.12.0"
33
+ "node": ">=24.0.0"
34
34
  },
35
35
  "devDependencies": {
36
+ "@vitejs/plugin-react": "^6.0.5",
37
+ "eslint": "^9.27.0",
38
+ "isomorphic-dompurify": "^3.18.0",
39
+ "react": "^19.2.5",
36
40
  "vitest": "^4.1.5",
37
- "@pantheon-systems/css-client": "0.7.0",
38
- "@pantheon-systems/puck-css": "0.7.0"
41
+ "@pantheon-systems/css-client": "0.10.0",
42
+ "@pantheon-systems/eslint-config": "0.1.0",
43
+ "@pantheon-systems/puck-css": "0.10.0"
39
44
  },
40
45
  "dependencies": {
41
46
  "@clack/prompts": "^1.5.1",
42
47
  "picocolors": "^1.1.1"
43
48
  },
44
49
  "license": "MIT",
50
+ "homepage": "https://github.com/pantheon-systems/p1-platform/tree/main/packages/create-p1-starter-kit",
45
51
  "scripts": {
46
52
  "build": "node scripts/build-template.js",
53
+ "clean": "rm -rf template node_modules/.vite",
54
+ "lint": "eslint index.js lib scripts",
55
+ "lint:fix": "eslint index.js lib scripts --fix",
47
56
  "test": "vitest run"
48
57
  }
49
58
  }
@@ -1,13 +1,28 @@
1
- PCC_SITE_ID=your-site-id
2
- PCC_TOKEN=your-token
3
-
4
1
  # --- P1 backend ---
5
- # NEXT_PUBLIC_CSS_BASE_URL defaults to https://ccr.p1.pantheon for production.
6
- # Override for staging, local, or sandbox deployments:
7
- # NEXT_PUBLIC_CSS_BASE_URL=https://staging.example.com
8
2
  NEXT_PUBLIC_CSS_SITE_ID=site-123
9
3
  CSS_API_KEY=your-api-key
10
4
 
5
+ # Public URL of THIS environment, used to build the post-login redirect.
6
+ # Read at runtime (not a NEXT_PUBLIC_ build-time var), so redeploy after setting it.
7
+ #
8
+ # Set it per environment — each of dev/test/live/multidev needs its own value
9
+ # matching its own public URL. A site-level value that every environment inherits
10
+ # sends logins to the wrong origin, silently.
11
+ # terminus secret:site:set <site>.live P1_SITE_URL "https://www.yoursite.com" \
12
+ # --type=env --scope=web
13
+ #
14
+ # Can be left unset once this site's origins are registered in the P1 dashboard:
15
+ # the browser then states its origin and the backend verifies it against that
16
+ # list, which one wildcard entry covers for every environment. Unset and
17
+ # unregistered falls back to the request origin, which is an internal hostname on
18
+ # Pantheon — that is the cause of post-login redirects to localhost.
19
+ # P1_SITE_URL=https://www.yoursite.com
20
+
21
+ # --- P1 backend URL (optional) ---
22
+ # P1 backend defaults to https://ccr.p1.pantheon.io for production.
23
+ # Override for staging, local, or sandbox deployments:
24
+ # NEXT_PUBLIC_CSS_BASE_URL=https://staging.ccr.p1.pantheon.io
25
+
11
26
  # --- P1 Admin Dashboard (optional) ---
12
27
  # Override the default dashboard URL (https://content.pantheon.io)
13
28
  # NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL=https://staging.content.pantheon.io
@@ -25,3 +40,15 @@ CSS_API_KEY=your-api-key
25
40
  # public by design (safe to expose in the browser). When unset, the chatbot
26
41
  # stays hidden.
27
42
  # NEXT_PUBLIC_LD_CLIENT_ID=your-launchdarkly-client-side-id
43
+
44
+ # --- Media library (on by default) ---
45
+ # CDN image origin used to validate media URLs — NOT the Worker API URL.
46
+ # Defaults to the production origin (https://media.p1.pantheon.io). Override
47
+ # for staging, sandbox, or local dev.
48
+ # NEXT_PUBLIC_MEDIA_BASE_URL=https://staging.media.p1.pantheon.io
49
+
50
+ # --- Content Publisher (optional) ---
51
+ # Add your secrets to connect to Content Publisher collections and
52
+ # automatically pull documents from there into your P1 site.
53
+ # PCC_SITE_ID=your-content-publisher-collection-id
54
+ # PCC_TOKEN=your-content-publisher-access-token
@@ -1,5 +1,25 @@
1
1
  # @pantheon-systems/p1-starter
2
2
 
3
+ ## 1.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - Updated dependencies
9
+ - Updated dependencies
10
+ - Updated dependencies [3ed945e]
11
+ - Updated dependencies
12
+ - Updated dependencies
13
+ - Updated dependencies
14
+ - Updated dependencies
15
+ - Updated dependencies
16
+ - Updated dependencies
17
+ - Updated dependencies
18
+ - Updated dependencies
19
+ - @pantheon-systems/puck-css@0.8.0
20
+ - @pantheon-systems/p1-next-sdk@0.8.0
21
+ - @pantheon-systems/css-client@0.8.0
22
+
3
23
  ## 1.0.6
4
24
 
5
25
  ### Patch Changes
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { DraftRequest, DraftRequestChannel } from "@pantheon-systems/p1-ai-chat";
3
+ import { createGenerateWithAIHandler } from "../lib/chatbot-flag/ai-generate";
4
+
5
+ /**
6
+ * A recording stand-in for the request channel. The real channel is unit-tested in
7
+ * `p1-ai-chat`; here we only assert what the handler publishes. Using a fake also
8
+ * keeps this test from importing the package's runtime (which pulls in CSS).
9
+ */
10
+ function fakeChannel(): { channel: DraftRequestChannel; published: DraftRequest[] } {
11
+ const published: DraftRequest[] = [];
12
+ const channel: DraftRequestChannel = {
13
+ publish: (request) => {
14
+ published.push(request);
15
+ },
16
+ subscribe: () => () => {},
17
+ getLatest: () => published[published.length - 1] ?? null,
18
+ clearLatest: () => {},
19
+ };
20
+ return { channel, published };
21
+ }
22
+
23
+ describe("createGenerateWithAIHandler", () => {
24
+ it("returns undefined when the chatbot is disabled (leaves the modal tile a placeholder)", () => {
25
+ const { channel } = fakeChannel();
26
+ expect(createGenerateWithAIHandler(channel, false)).toBeUndefined();
27
+ });
28
+
29
+ it("asks for the page to be created, with the title and path the dialog collected", () => {
30
+ const { channel, published } = fakeChannel();
31
+
32
+ const handler = createGenerateWithAIHandler(channel, true);
33
+ // Narrowed with a throw rather than a `!`: the return type is optional because the
34
+ // handler is undefined when the chatbot is disabled, and the lint rule forbids
35
+ // asserting that away. This keeps the "must be defined when enabled" claim asserted.
36
+ if (!handler) throw new Error("handler must be defined when the chatbot is enabled");
37
+ handler("a launch page", { path: "launch", title: "Launch" });
38
+
39
+ // `create-page`, not `fill-page`: nothing exists yet, because the template the page starts
40
+ // from is settled in the chat first and can only be applied as the page is created.
41
+ expect(published).toEqual([
42
+ { kind: "create-page", brief: "a launch page", page: { path: "launch", title: "Launch" } },
43
+ ]);
44
+ });
45
+
46
+ it("keeps the nested path the dialog built from a template's route", () => {
47
+ const { channel, published } = fakeChannel();
48
+ const handler = createGenerateWithAIHandler(channel, true);
49
+ if (!handler) throw new Error("handler must be defined when the chatbot is enabled");
50
+
51
+ handler("draft", { path: "resources/guide", title: "Guide" });
52
+
53
+ expect(published[0]).toEqual({
54
+ kind: "create-page",
55
+ brief: "draft",
56
+ page: { path: "resources/guide", title: "Guide" },
57
+ });
58
+ });
59
+
60
+ // The dialog's title field can be left empty; the agent draws one from the brief instead.
61
+ it("sends an empty title rather than omitting it", () => {
62
+ const { channel, published } = fakeChannel();
63
+ const handler = createGenerateWithAIHandler(channel, true);
64
+ if (!handler) throw new Error("handler must be defined when the chatbot is enabled");
65
+
66
+ handler("draft", { path: "launch" });
67
+
68
+ expect(published[0]).toEqual({
69
+ kind: "create-page",
70
+ brief: "draft",
71
+ page: { path: "launch", title: "" },
72
+ });
73
+ });
74
+ });
@@ -1,7 +1,7 @@
1
- import { describe, expect, it } from "vitest";
2
1
  import { readFileSync } from "fs";
3
2
  import { resolve, dirname } from "path";
4
3
  import { fileURLToPath } from "url";
4
+ import { describe, expect, it } from "vitest";
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const appDir = resolve(__dirname, "..");
@@ -1,14 +1,14 @@
1
- import { describe, expect, it } from "vitest";
2
1
  import { readFileSync } from "fs";
3
2
  import { resolve, dirname } from "path";
4
3
  import { fileURLToPath } from "url";
4
+ import { describe, expect, it } from "vitest";
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const appDir = resolve(__dirname, "..");
8
8
 
9
9
  describe("editor-client gates the chatbot behind the p1-chatbot flag", () => {
10
10
  const content = readFileSync(
11
- resolve(appDir, "app/p1/[[...p1]]/editor-client.tsx"),
11
+ resolve(appDir, "app/p1/(editor)/[[...p1]]/editor-client.tsx"),
12
12
  "utf-8",
13
13
  );
14
14
 
@@ -29,6 +29,26 @@ describe("editor-client gates the chatbot behind the p1-chatbot flag", () => {
29
29
  });
30
30
  });
31
31
 
32
+ describe("editor-client wires Generate with AI to the chat sidebar", () => {
33
+ const content = readFileSync(
34
+ resolve(appDir, "app/p1/(editor)/[[...p1]]/editor-client.tsx"),
35
+ "utf-8",
36
+ );
37
+
38
+ it("uses the shared (singleton) agent request channel", () => {
39
+ expect(content).toContain("getDraftRequestChannel");
40
+ });
41
+
42
+ it("passes the request channel to the chat plugin", () => {
43
+ expect(content).toMatch(/createAIChatPlugin\(\{[^}]*draftRequests/);
44
+ });
45
+
46
+ it("wires onGenerateWithAI to publish onto the channel via the handler", () => {
47
+ expect(content).toContain("onGenerateWithAI");
48
+ expect(content).toContain("createGenerateWithAIHandler");
49
+ });
50
+ });
51
+
32
52
  describe("chatbot flag provider is a client-side LaunchDarkly gate", () => {
33
53
  const content = readFileSync(
34
54
  resolve(appDir, "components/ChatbotFlagProvider.tsx"),
@@ -1,14 +1,14 @@
1
- import { describe, expect, it } from "vitest";
2
1
  import { readFileSync } from "fs";
3
2
  import { resolve, dirname } from "path";
4
3
  import { fileURLToPath } from "url";
4
+ import { describe, expect, it } from "vitest";
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const appDir = resolve(__dirname, "..");
8
8
 
9
9
  describe("editor-client uses P1 plugins", () => {
10
10
  const content = readFileSync(
11
- resolve(appDir, "app/p1/[[...p1]]/editor-client.tsx"),
11
+ resolve(appDir, "app/p1/(editor)/[[...p1]]/editor-client.tsx"),
12
12
  "utf-8",
13
13
  );
14
14
 
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The persistent editor is scoped to the `(editor)` route group so it wraps
3
+ * only the catch-all editor page — not sibling routes like /p1/merge (or a
4
+ * future /p1/settings). This structure replaces the old NON_EDITOR_ROUTES
5
+ * opt-out list: siblings stay editor-free by construction, not by enumeration.
6
+ */
7
+ import { existsSync, readFileSync } from "fs";
8
+ import { resolve, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const appDir = resolve(__dirname, "..");
14
+ const p1 = resolve(appDir, "app/p1");
15
+
16
+ describe("editor is scoped to the (editor) route group", () => {
17
+ it("mounts the persistent editor layout inside the group", () => {
18
+ const layout = resolve(p1, "(editor)/layout.tsx");
19
+ expect(existsSync(layout)).toBe(true);
20
+ expect(readFileSync(layout, "utf-8")).toContain("pages.Layout");
21
+ });
22
+
23
+ it("keeps the catch-all editor page inside the group", () => {
24
+ expect(existsSync(resolve(p1, "(editor)/[[...p1]]/page.tsx"))).toBe(true);
25
+ expect(existsSync(resolve(p1, "(editor)/[[...p1]]/editor-client.tsx"))).toBe(true);
26
+ });
27
+
28
+ it("does not mount a layout at /p1 that would wrap every sibling", () => {
29
+ expect(existsSync(resolve(p1, "layout.tsx"))).toBe(false);
30
+ });
31
+
32
+ it("keeps /p1/merge a sibling outside the group so the editor never wraps it", () => {
33
+ expect(existsSync(resolve(p1, "merge/page.tsx"))).toBe(true);
34
+ expect(existsSync(resolve(p1, "(editor)/merge/page.tsx"))).toBe(false);
35
+ });
36
+
37
+ it("no longer guards routes with a NON_EDITOR_ROUTES opt-out list", () => {
38
+ const editorClient = readFileSync(
39
+ resolve(p1, "(editor)/[[...p1]]/editor-client.tsx"),
40
+ "utf-8",
41
+ );
42
+ expect(editorClient).not.toContain("NON_EDITOR_ROUTES");
43
+ });
44
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../lib/remote-datasource-fetchers", () => ({
4
+ REMOTE_DATASOURCE_FETCHERS: {},
5
+ }));
6
+
7
+ vi.mock("@pantheon-systems/puck-css/server", () => ({
8
+ listRouteTemplateKeysFromDatabase: vi.fn().mockResolvedValue([]),
9
+ extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
10
+ loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
11
+ resolveStringTemplates: vi.fn(async (input: string) =>
12
+ input
13
+ .replace(/\{\{\s*name\s*\}\}/g, "World")
14
+ .replace(/\{\{\s*slug\s*\}\}/g, "widgets"),
15
+ ),
16
+ }));
17
+
18
+ import * as puckServer from "@pantheon-systems/puck-css/server";
19
+ import { resolvePageMetadata } from "../lib/page-seo";
20
+
21
+ /**
22
+ * `{{ }}` interpolation on the metadata fields.
23
+ *
24
+ * Loading the datasource context is a network round trip, so it happens only
25
+ * when something on the page actually carries a template — and once, however
26
+ * many fields do.
27
+ *
28
+ * The two fixed-vocabulary fields are deliberately excluded: their values are
29
+ * validated against a union, so a template could only ever produce a value the
30
+ * renderer rejects.
31
+ */
32
+
33
+ type PageData = Parameters<typeof resolvePageMetadata>[0]["pageData"];
34
+
35
+ function pageWithRootProps(props: Record<string, unknown>): PageData {
36
+ return { root: { props }, content: [] } as unknown as PageData;
37
+ }
38
+
39
+ const resolve = (props: Record<string, unknown>) =>
40
+ resolvePageMetadata({
41
+ pageData: pageWithRootProps(props),
42
+ path: "/q3",
43
+ searchParams: {},
44
+ });
45
+
46
+ const og = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
47
+ (m.openGraph ?? {}) as Record<string, unknown>;
48
+ const tw = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
49
+ (m.twitter ?? {}) as Record<string, unknown>;
50
+
51
+ describe("resolvePageMetadata — {{ }} in _meta", () => {
52
+ beforeEach(() => vi.clearAllMocks());
53
+
54
+ it("resolves templates in the free-text metadata fields", async () => {
55
+ const meta = await resolve({
56
+ title: "Q3",
57
+ _meta: {
58
+ ogTitle: "Hello {{ name }}",
59
+ ogDescription: "All about {{ name }}",
60
+ twitterTitle: "{{ name }} on X",
61
+ ogLocale: "{{ name }}",
62
+ },
63
+ });
64
+
65
+ expect(og(meta).title).toBe("Hello World");
66
+ expect(og(meta).description).toBe("All about World");
67
+ expect(tw(meta).title).toBe("World on X");
68
+ expect(og(meta).locale).toBe("World");
69
+ });
70
+
71
+ it("resolves templates in the image URLs", async () => {
72
+ const meta = await resolve({
73
+ title: "Q3",
74
+ _meta: {
75
+ ogImage: "https://cdn.example/{{ slug }}.png",
76
+ twitterImage: "https://cdn.example/{{ slug }}-x.png",
77
+ },
78
+ });
79
+
80
+ expect(og(meta).images).toBe("https://cdn.example/widgets.png");
81
+ expect(tw(meta).images).toBe("https://cdn.example/widgets-x.png");
82
+ });
83
+
84
+ it("leaves the fixed-vocabulary fields alone", async () => {
85
+ const meta = await resolve({
86
+ title: "Q3",
87
+ _meta: { ogType: "{{ name }}", twitterCard: "{{ name }}", ogTitle: "Set" },
88
+ });
89
+
90
+ // Unresolved and unrecognised, so the union check falls back.
91
+ expect(og(meta).type).toBe("website");
92
+ expect(tw(meta).card).toBe("summary");
93
+ for (const call of vi.mocked(puckServer.resolveStringTemplates).mock.calls) {
94
+ expect(call[0]).not.toBe("{{ name }}");
95
+ }
96
+ });
97
+
98
+ it("loads the datasource context once, however many fields template", async () => {
99
+ await resolve({
100
+ title: "Hello {{ name }}",
101
+ description: "About {{ name }}",
102
+ _meta: { ogTitle: "{{ name }}", twitterTitle: "{{ name }}" },
103
+ });
104
+
105
+ expect(puckServer.loadRemoteDatasourceContext).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("does not touch the network when nothing carries a template", async () => {
109
+ const meta = await resolve({
110
+ title: "Q3 Launch Recap",
111
+ _meta: { ogTitle: "The launch, in numbers" },
112
+ });
113
+
114
+ expect(puckServer.loadRemoteDatasourceContext).not.toHaveBeenCalled();
115
+ expect(puckServer.resolveStringTemplates).not.toHaveBeenCalled();
116
+ expect(og(meta).title).toBe("The launch, in numbers");
117
+ });
118
+
119
+ it("resolves only the fields that carry a template", async () => {
120
+ await resolve({
121
+ title: "Q3",
122
+ _meta: { ogTitle: "Hello {{ name }}", ogDescription: "Static" },
123
+ });
124
+
125
+ expect(puckServer.resolveStringTemplates).toHaveBeenCalledTimes(1);
126
+ expect(puckServer.resolveStringTemplates).toHaveBeenCalledWith("Hello {{ name }}", {});
127
+ });
128
+
129
+ it("resolves a metadata field when the title carries no template", async () => {
130
+ // The context load used to be gated on title/description alone, so a
131
+ // template that appeared only in _meta would never have been resolved.
132
+ const meta = await resolve({ title: "Q3", _meta: { ogTitle: "Hello {{ name }}" } });
133
+
134
+ expect(og(meta).title).toBe("Hello World");
135
+ });
136
+
137
+ it("still derives an unset field from the resolved title", async () => {
138
+ const meta = await resolve({ title: "Hello {{ name }}", _meta: { ogTitle: "" } });
139
+
140
+ expect(og(meta).title).toBe("Hello World");
141
+ expect(tw(meta).title).toBe("Hello World");
142
+ });
143
+ });