@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.
- package/README.md +114 -0
- package/lib/cli.js +1 -1
- package/lib/messages.js +1 -1
- package/package.json +11 -5
- package/template/.env.example +27 -6
- package/template/__tests__/ai-generate.test.ts +74 -0
- package/template/__tests__/auth-route.test.ts +1 -1
- package/template/__tests__/chatbot-flag-wiring.test.ts +21 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +1 -1
- package/template/__tests__/image-block.test.tsx +48 -0
- package/template/__tests__/page-seo-meta-templates.test.ts +145 -0
- package/template/__tests__/page-seo-meta.test.ts +97 -0
- package/template/__tests__/page-seo.test.ts +4 -6
- package/template/__tests__/paragraph-block.test.ts +24 -22
- package/template/__tests__/paragraph-editor-text.test.ts +79 -0
- package/template/__tests__/published-page-404.test.ts +65 -0
- package/template/__tests__/puck-root-guidance.test.ts +109 -0
- package/template/__tests__/puck-root-meta.test.ts +102 -0
- package/template/__tests__/puck-root-selects.test.ts +86 -0
- package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
- package/template/__tests__/seo-metadata-meta.test.ts +180 -0
- package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
- package/template/__tests__/styles-canvas-scope.test.ts +50 -0
- package/template/app/[...puckPath]/page.tsx +67 -67
- package/template/app/layout.tsx +11 -1
- package/template/app/not-found.tsx +11 -0
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
- package/template/app/page.tsx +20 -22
- package/template/app/styles.css +18 -1
- package/template/components/content-unavailable.tsx +22 -0
- package/template/components/p1-lockup.tsx +6 -26
- package/template/components/page-missing.tsx +49 -0
- package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
- package/template/components/puck/data-list-block/index.ts +1 -0
- package/template/components/puck/grid-block.tsx +1 -1
- package/template/components/puck/image-block.tsx +22 -1
- package/template/components/puck/paragraph-block.tsx +7 -2
- package/template/components/puck/paragraph-editor-text.tsx +84 -0
- package/template/components/puck/paragraph-markdown.tsx +15 -0
- package/template/components/puck/root.tsx +185 -4
- package/template/constants/assets.ts +1 -0
- package/template/eslint.config.js +20 -4
- package/template/lib/chatbot-flag/ai-generate.ts +23 -0
- package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
- package/template/lib/monsters-api.ts +14 -8
- package/template/lib/page-seo.ts +46 -16
- package/template/lib/remote-datasources.ts +26 -31
- package/template/lib/seo-metadata.consts.ts +21 -0
- package/template/lib/seo-metadata.ts +96 -15
- package/template/lib/swapi.ts +5 -5
- package/template/middleware.ts +15 -0
- package/template/next.config.mjs +11 -0
- package/template/package.json +15 -12
- package/template/pnpm-workspace.yaml +3 -0
- package/template/public/images/p1_logo.svg +5 -12
- package/template/public/images/p1_logo_reverse.svg +5 -0
- package/template/puck.config.tsx +3 -1
- package/template/tsconfig/nextjs.json +1 -1
- package/template/vitest.config.ts +20 -0
- 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
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,
|
|
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.
|
|
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/
|
|
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": "^
|
|
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/
|
|
41
|
-
"@pantheon-systems/
|
|
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
|
}
|
package/template/.env.example
CHANGED
|
@@ -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
|
+
});
|