@pantheon-systems/create-p1-starter-kit 0.8.0 → 0.11.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 (61) 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 +11 -5
  5. package/template/.env.example +27 -6
  6. package/template/__tests__/ai-generate.test.ts +74 -0
  7. package/template/__tests__/auth-route.test.ts +1 -1
  8. package/template/__tests__/chatbot-flag-wiring.test.ts +21 -1
  9. package/template/__tests__/editor-integration.test.ts +1 -1
  10. package/template/__tests__/editor-route-group.test.ts +1 -1
  11. package/template/__tests__/image-block.test.tsx +48 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +145 -0
  13. package/template/__tests__/page-seo-meta.test.ts +97 -0
  14. package/template/__tests__/page-seo.test.ts +4 -6
  15. package/template/__tests__/paragraph-block.test.ts +24 -22
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/published-page-404.test.ts +65 -0
  18. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  19. package/template/__tests__/puck-root-meta.test.ts +102 -0
  20. package/template/__tests__/puck-root-selects.test.ts +86 -0
  21. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  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__/styles-canvas-scope.test.ts +50 -0
  25. package/template/app/[...puckPath]/page.tsx +67 -67
  26. package/template/app/layout.tsx +11 -1
  27. package/template/app/not-found.tsx +11 -0
  28. package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
  29. package/template/app/page.tsx +20 -22
  30. package/template/app/styles.css +18 -1
  31. package/template/components/content-unavailable.tsx +22 -0
  32. package/template/components/p1-lockup.tsx +6 -26
  33. package/template/components/page-missing.tsx +49 -0
  34. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  35. package/template/components/puck/data-list-block/index.ts +1 -0
  36. package/template/components/puck/grid-block.tsx +1 -1
  37. package/template/components/puck/image-block.tsx +22 -1
  38. package/template/components/puck/paragraph-block.tsx +7 -2
  39. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  40. package/template/components/puck/paragraph-markdown.tsx +15 -0
  41. package/template/components/puck/root.tsx +185 -4
  42. package/template/constants/assets.ts +1 -0
  43. package/template/eslint.config.js +20 -4
  44. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  45. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  46. package/template/lib/monsters-api.ts +14 -8
  47. package/template/lib/page-seo.ts +46 -16
  48. package/template/lib/remote-datasources.ts +26 -31
  49. package/template/lib/seo-metadata.consts.ts +21 -0
  50. package/template/lib/seo-metadata.ts +96 -15
  51. package/template/lib/swapi.ts +5 -5
  52. package/template/middleware.ts +15 -0
  53. package/template/next.config.mjs +11 -0
  54. package/template/package.json +15 -12
  55. package/template/pnpm-workspace.yaml +3 -0
  56. package/template/public/images/p1_logo.svg +5 -12
  57. package/template/public/images/p1_logo_reverse.svg +5 -0
  58. package/template/puck.config.tsx +3 -1
  59. package/template/tsconfig/nextjs.json +1 -1
  60. package/template/vitest.config.ts +20 -0
  61. 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.8.0",
3
+ "version": "0.11.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": [
@@ -33,20 +33,26 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@vitejs/plugin-react": "^4.7.0",
36
+ "@vitejs/plugin-react": "^6.0.5",
37
+ "eslint": "^9.27.0",
37
38
  "isomorphic-dompurify": "^3.18.0",
38
39
  "react": "^19.2.5",
39
40
  "vitest": "^4.1.5",
40
- "@pantheon-systems/puck-css": "0.8.0",
41
- "@pantheon-systems/css-client": "0.8.0"
41
+ "@pantheon-systems/css-client": "0.11.0",
42
+ "@pantheon-systems/eslint-config": "0.1.0",
43
+ "@pantheon-systems/puck-css": "0.11.0"
42
44
  },
43
45
  "dependencies": {
44
46
  "@clack/prompts": "^1.5.1",
45
47
  "picocolors": "^1.1.1"
46
48
  },
47
49
  "license": "MIT",
50
+ "homepage": "https://github.com/pantheon-systems/p1-platform/tree/main/packages/create-p1-starter-kit",
48
51
  "scripts": {
49
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",
50
56
  "test": "vitest run"
51
57
  }
52
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
@@ -31,3 +46,9 @@ CSS_API_KEY=your-api-key
31
46
  # Defaults to the production origin (https://media.p1.pantheon.io). Override
32
47
  # for staging, sandbox, or local dev.
33
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
@@ -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,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, "..");
@@ -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,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, "..");
@@ -4,10 +4,10 @@
4
4
  * future /p1/settings). This structure replaces the old NON_EDITOR_ROUTES
5
5
  * opt-out list: siblings stay editor-free by construction, not by enumeration.
6
6
  */
7
- import { describe, expect, it } from "vitest";
8
7
  import { existsSync, readFileSync } from "fs";
9
8
  import { resolve, dirname } from "path";
10
9
  import { fileURLToPath } from "url";
10
+ import { describe, expect, it } from "vitest";
11
11
 
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const appDir = resolve(__dirname, "..");
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isValidElement, type ReactNode } from "react";
3
+
4
+ const SRC = "https://example.com/photo.jpg";
5
+
6
+ // react-dom is not available where the scaffolded template runs its tests, so
7
+ // inspect the element tree the block returns rather than rendering it.
8
+ function findImg(node: ReactNode): Record<string, unknown> | null {
9
+ if (node == null || typeof node !== "object") return null;
10
+ if (Array.isArray(node)) {
11
+ for (const child of node) {
12
+ const found = findImg(child);
13
+ if (found) return found;
14
+ }
15
+ return null;
16
+ }
17
+ if (!isValidElement(node)) return null;
18
+ const props = (node.props ?? {}) as Record<string, unknown>;
19
+ if (node.type === "img") return props;
20
+ return findImg(props.children as ReactNode);
21
+ }
22
+
23
+ describe("imageBlock", () => {
24
+ it("exposes a loading field defaulting to lazy", async () => {
25
+ const { imageBlock } = await import("../components/puck/image-block");
26
+ expect(imageBlock.fields.loading.type).toBe("radio");
27
+ expect(imageBlock.fields.loading.options.map((o) => o.value)).toEqual([
28
+ "lazy",
29
+ "eager",
30
+ ]);
31
+ expect(imageBlock.defaultProps.loading).toBe("lazy");
32
+ });
33
+
34
+ it("lazy-loads even when the prop is missing (documents saved before the field existed)", async () => {
35
+ const { imageBlock } = await import("../components/puck/image-block");
36
+ const img = findImg(imageBlock.render({ src: SRC, alt: "A photo" }));
37
+ expect(img?.loading).toBe("lazy");
38
+ expect(img?.decoding).toBe("async");
39
+ });
40
+
41
+ it("renders eager when the editor chooses it", async () => {
42
+ const { imageBlock } = await import("../components/puck/image-block");
43
+ const img = findImg(
44
+ imageBlock.render({ src: SRC, alt: "A photo", loading: "eager" }),
45
+ );
46
+ expect(img?.loading).toBe("eager");
47
+ });
48
+ });
@@ -0,0 +1,145 @@
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/p1-next-sdk/server", () => ({
8
+ loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
9
+ }));
10
+
11
+ vi.mock("@pantheon-systems/puck-css/server", () => ({
12
+ extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
13
+ loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
14
+ resolveStringTemplates: vi.fn(async (input: string) =>
15
+ input
16
+ .replace(/\{\{\s*name\s*\}\}/g, "World")
17
+ .replace(/\{\{\s*slug\s*\}\}/g, "widgets"),
18
+ ),
19
+ }));
20
+
21
+ import * as puckServer from "@pantheon-systems/puck-css/server";
22
+ import { resolvePageMetadata } from "../lib/page-seo";
23
+
24
+ /**
25
+ * `{{ }}` interpolation on the metadata fields.
26
+ *
27
+ * Loading the datasource context is a network round trip, so it happens only
28
+ * when something on the page actually carries a template — and once, however
29
+ * many fields do.
30
+ *
31
+ * The two fixed-vocabulary fields are deliberately excluded: their values are
32
+ * validated against a union, so a template could only ever produce a value the
33
+ * renderer rejects.
34
+ */
35
+
36
+ type PageData = Parameters<typeof resolvePageMetadata>[0]["pageData"];
37
+
38
+ function pageWithRootProps(props: Record<string, unknown>): PageData {
39
+ return { root: { props }, content: [] } as unknown as PageData;
40
+ }
41
+
42
+ const resolve = (props: Record<string, unknown>) =>
43
+ resolvePageMetadata({
44
+ pageData: pageWithRootProps(props),
45
+ path: "/q3",
46
+ });
47
+
48
+ const og = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
49
+ (m.openGraph ?? {}) as Record<string, unknown>;
50
+ const tw = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
51
+ (m.twitter ?? {}) as Record<string, unknown>;
52
+
53
+ describe("resolvePageMetadata — {{ }} in _meta", () => {
54
+ beforeEach(() => vi.clearAllMocks());
55
+
56
+ it("resolves templates in the free-text metadata fields", async () => {
57
+ const meta = await resolve({
58
+ title: "Q3",
59
+ _meta: {
60
+ ogTitle: "Hello {{ name }}",
61
+ ogDescription: "All about {{ name }}",
62
+ twitterTitle: "{{ name }} on X",
63
+ ogLocale: "{{ name }}",
64
+ },
65
+ });
66
+
67
+ expect(og(meta).title).toBe("Hello World");
68
+ expect(og(meta).description).toBe("All about World");
69
+ expect(tw(meta).title).toBe("World on X");
70
+ expect(og(meta).locale).toBe("World");
71
+ });
72
+
73
+ it("resolves templates in the image URLs", async () => {
74
+ const meta = await resolve({
75
+ title: "Q3",
76
+ _meta: {
77
+ ogImage: "https://cdn.example/{{ slug }}.png",
78
+ twitterImage: "https://cdn.example/{{ slug }}-x.png",
79
+ },
80
+ });
81
+
82
+ expect(og(meta).images).toBe("https://cdn.example/widgets.png");
83
+ expect(tw(meta).images).toBe("https://cdn.example/widgets-x.png");
84
+ });
85
+
86
+ it("leaves the fixed-vocabulary fields alone", async () => {
87
+ const meta = await resolve({
88
+ title: "Q3",
89
+ _meta: { ogType: "{{ name }}", twitterCard: "{{ name }}", ogTitle: "Set" },
90
+ });
91
+
92
+ // Unresolved and unrecognised, so the union check falls back.
93
+ expect(og(meta).type).toBe("website");
94
+ expect(tw(meta).card).toBe("summary");
95
+ for (const call of vi.mocked(puckServer.resolveStringTemplates).mock.calls) {
96
+ expect(call[0]).not.toBe("{{ name }}");
97
+ }
98
+ });
99
+
100
+ it("loads the datasource context once, however many fields template", async () => {
101
+ await resolve({
102
+ title: "Hello {{ name }}",
103
+ description: "About {{ name }}",
104
+ _meta: { ogTitle: "{{ name }}", twitterTitle: "{{ name }}" },
105
+ });
106
+
107
+ expect(puckServer.loadRemoteDatasourceContext).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it("does not touch the network when nothing carries a template", async () => {
111
+ const meta = await resolve({
112
+ title: "Q3 Launch Recap",
113
+ _meta: { ogTitle: "The launch, in numbers" },
114
+ });
115
+
116
+ expect(puckServer.loadRemoteDatasourceContext).not.toHaveBeenCalled();
117
+ expect(puckServer.resolveStringTemplates).not.toHaveBeenCalled();
118
+ expect(og(meta).title).toBe("The launch, in numbers");
119
+ });
120
+
121
+ it("resolves only the fields that carry a template", async () => {
122
+ await resolve({
123
+ title: "Q3",
124
+ _meta: { ogTitle: "Hello {{ name }}", ogDescription: "Static" },
125
+ });
126
+
127
+ expect(puckServer.resolveStringTemplates).toHaveBeenCalledTimes(1);
128
+ expect(puckServer.resolveStringTemplates).toHaveBeenCalledWith("Hello {{ name }}", {});
129
+ });
130
+
131
+ it("resolves a metadata field when the title carries no template", async () => {
132
+ // The context load used to be gated on title/description alone, so a
133
+ // template that appeared only in _meta would never have been resolved.
134
+ const meta = await resolve({ title: "Q3", _meta: { ogTitle: "Hello {{ name }}" } });
135
+
136
+ expect(og(meta).title).toBe("Hello World");
137
+ });
138
+
139
+ it("still derives an unset field from the resolved title", async () => {
140
+ const meta = await resolve({ title: "Hello {{ name }}", _meta: { ogTitle: "" } });
141
+
142
+ expect(og(meta).title).toBe("Hello World");
143
+ expect(tw(meta).title).toBe("Hello World");
144
+ });
145
+ });
@@ -0,0 +1,97 @@
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/p1-next-sdk/server", () => ({
8
+ loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
9
+ }));
10
+
11
+ vi.mock("@pantheon-systems/puck-css/server", () => ({
12
+ extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
13
+ loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
14
+ resolveStringTemplates: vi.fn(async (input: string) =>
15
+ input.replace(/\{\{\s*name\s*\}\}/g, "World"),
16
+ ),
17
+ }));
18
+
19
+ import { resolvePageMetadata } from "../lib/page-seo";
20
+
21
+ /**
22
+ * `root.props._meta` reaches <head> through resolvePageMetadata.
23
+ */
24
+
25
+ type PageData = Parameters<typeof resolvePageMetadata>[0]["pageData"];
26
+
27
+ function pageWithRootProps(props: Record<string, unknown>): PageData {
28
+ return { root: { props }, content: [] } as unknown as PageData;
29
+ }
30
+
31
+ const og = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
32
+ (m.openGraph ?? {}) as Record<string, unknown>;
33
+ const tw = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
34
+ (m.twitter ?? {}) as Record<string, unknown>;
35
+
36
+ describe("resolvePageMetadata — _meta", () => {
37
+ beforeEach(() => vi.clearAllMocks());
38
+
39
+ it("carries authored _meta through to the og and twitter tags", async () => {
40
+ const meta = await resolvePageMetadata({
41
+ pageData: pageWithRootProps({
42
+ title: "Q3 Launch Recap",
43
+ description: "How the launch went.",
44
+ _meta: {
45
+ ogTitle: "The launch, in numbers",
46
+ ogImage: "https://cdn.example/hero.jpg",
47
+ ogType: "article",
48
+ twitterTitle: "Launch, for X",
49
+ },
50
+ }),
51
+ path: "/q3",
52
+ });
53
+
54
+ expect(og(meta).title).toBe("The launch, in numbers");
55
+ expect(og(meta).images).toBe("https://cdn.example/hero.jpg");
56
+ expect(og(meta).type).toBe("article");
57
+ expect(tw(meta).title).toBe("Launch, for X");
58
+ expect(tw(meta).card).toBe("summary_large_image");
59
+ });
60
+
61
+ it("inherits from title and description when _meta is absent", async () => {
62
+ const meta = await resolvePageMetadata({
63
+ pageData: pageWithRootProps({
64
+ title: "Q3 Launch Recap",
65
+ description: "How the launch went.",
66
+ }),
67
+ path: "/q3",
68
+ });
69
+
70
+ expect(og(meta).title).toBe("Q3 Launch Recap");
71
+ expect(og(meta).description).toBe("How the launch went.");
72
+ expect(og(meta)).not.toHaveProperty("images");
73
+ });
74
+
75
+ it("still resolves {{ }} in the page title alongside authored _meta", async () => {
76
+ const meta = await resolvePageMetadata({
77
+ pageData: pageWithRootProps({
78
+ title: "Hello {{ name }}",
79
+ _meta: { ogDescription: "Static description." },
80
+ }),
81
+ path: "/greet",
82
+ });
83
+
84
+ expect(meta.title).toBe("Hello World");
85
+ expect(og(meta).description).toBe("Static description.");
86
+ });
87
+
88
+ it("ignores the editor's default title boilerplate when deriving og:title", async () => {
89
+ const meta = await resolvePageMetadata({
90
+ pageData: pageWithRootProps({ title: "My Puck Editor" }),
91
+ path: "/new",
92
+ });
93
+
94
+ expect(meta.title).toBeUndefined();
95
+ expect(og(meta)).not.toHaveProperty("title");
96
+ });
97
+ });