@pantheon-systems/create-p1-starter-kit 0.6.0 → 0.8.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/package.json +8 -3
- package/template/.env.example +6 -0
- package/template/CHANGELOG.md +32 -0
- package/template/__tests__/chatbot-flag-wiring.test.ts +1 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +44 -0
- package/template/__tests__/page-seo.test.ts +127 -0
- package/template/__tests__/paragraph-block.test.ts +37 -0
- package/template/__tests__/sanitize-richtext.test.ts +58 -0
- package/template/__tests__/seo-metadata.test.ts +105 -0
- package/template/app/[...puckPath]/page.tsx +5 -26
- package/template/app/layout.tsx +14 -0
- package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +20 -21
- package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
- package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
- package/template/app/p1/(editor)/layout.tsx +13 -0
- package/template/app/page.tsx +3 -21
- package/template/app/styles.css +1 -0
- package/template/ci-examples/github-actions-sync-puck-registry.yml +57 -0
- package/template/components/puck/media-figure-block.tsx +12 -0
- package/template/components/puck/paragraph-block.tsx +11 -31
- package/template/components/puck/sanitize-richtext.ts +44 -0
- package/template/lib/page-seo.ts +79 -0
- package/template/lib/seo-metadata.ts +48 -0
- package/template/package.json +12 -5
- package/template/pnpm-workspace.yaml +3 -0
- package/template/public/images/p1_logo.svg +11 -4
- package/template/puck.config.tsx +3 -1
- package/template/scripts/__tests__/asset-stub-hooks.test.ts +177 -0
- package/template/scripts/__tests__/sync-puck-registry.test.ts +230 -0
- package/template/scripts/asset-stub-hooks.mjs +58 -0
- package/template/scripts/sync-puck-registry.ts +225 -0
- package/template/tsconfig.test.json +6 -0
- package/template/vitest.config.ts +5 -0
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.8.0",
|
|
4
4
|
"description": "Scaffold a new P1 starter project",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,10 +30,15 @@
|
|
|
30
30
|
"template"
|
|
31
31
|
],
|
|
32
32
|
"engines": {
|
|
33
|
-
"node": ">=
|
|
33
|
+
"node": ">=24.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"
|
|
36
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
37
|
+
"isomorphic-dompurify": "^3.18.0",
|
|
38
|
+
"react": "^19.2.5",
|
|
39
|
+
"vitest": "^4.1.5",
|
|
40
|
+
"@pantheon-systems/puck-css": "0.8.0",
|
|
41
|
+
"@pantheon-systems/css-client": "0.8.0"
|
|
37
42
|
},
|
|
38
43
|
"dependencies": {
|
|
39
44
|
"@clack/prompts": "^1.5.1",
|
package/template/.env.example
CHANGED
|
@@ -25,3 +25,9 @@ CSS_API_KEY=your-api-key
|
|
|
25
25
|
# public by design (safe to expose in the browser). When unset, the chatbot
|
|
26
26
|
# stays hidden.
|
|
27
27
|
# NEXT_PUBLIC_LD_CLIENT_ID=your-launchdarkly-client-side-id
|
|
28
|
+
|
|
29
|
+
# --- Media library (on by default) ---
|
|
30
|
+
# CDN image origin used to validate media URLs — NOT the Worker API URL.
|
|
31
|
+
# Defaults to the production origin (https://media.p1.pantheon.io). Override
|
|
32
|
+
# for staging, sandbox, or local dev.
|
|
33
|
+
# NEXT_PUBLIC_MEDIA_BASE_URL=https://staging.media.p1.pantheon.io
|
package/template/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
|
|
23
|
+
## 1.0.6
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [b0254ff]
|
|
28
|
+
- Updated dependencies
|
|
29
|
+
- Updated dependencies [e937842]
|
|
30
|
+
- Updated dependencies [e937842]
|
|
31
|
+
- @pantheon-systems/puck-css@0.7.0
|
|
32
|
+
- @pantheon-systems/css-client@0.7.0
|
|
33
|
+
- @pantheon-systems/p1-next-sdk@0.7.0
|
|
34
|
+
|
|
3
35
|
## 1.0.5
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
|
@@ -8,7 +8,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
|
|
|
@@ -8,7 +8,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 { describe, expect, it } from "vitest";
|
|
8
|
+
import { existsSync, readFileSync } from "fs";
|
|
9
|
+
import { resolve, dirname } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
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,127 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The datasource fetchers module pulls in the cpub SDK; stub it — the helper
|
|
4
|
+
// only forwards it to loadRemoteDatasourceContext, which is mocked below.
|
|
5
|
+
vi.mock("../lib/remote-datasource-fetchers", () => ({
|
|
6
|
+
REMOTE_DATASOURCE_FETCHERS: {},
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("@pantheon-systems/puck-css/server", () => ({
|
|
10
|
+
listRouteTemplateKeysFromDatabase: vi.fn().mockResolvedValue([]),
|
|
11
|
+
extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
|
|
12
|
+
loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
|
|
13
|
+
resolveStringTemplates: vi.fn(async (input: string) =>
|
|
14
|
+
input.replace(/\{\{\s*name\s*\}\}/g, "World"),
|
|
15
|
+
),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import * as puckServer from "@pantheon-systems/puck-css/server";
|
|
19
|
+
import { resolvePageMetadata } from "../lib/page-seo";
|
|
20
|
+
|
|
21
|
+
type PageData = Parameters<typeof resolvePageMetadata>[0]["pageData"];
|
|
22
|
+
|
|
23
|
+
function pageWithRootProps(props: Record<string, unknown>): PageData {
|
|
24
|
+
return { root: { props }, content: [] } as unknown as PageData;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("resolvePageMetadata", () => {
|
|
28
|
+
beforeEach(() => vi.clearAllMocks());
|
|
29
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
30
|
+
|
|
31
|
+
it("maps root props and _seo.siteName to metadata without touching datasources when no templates", async () => {
|
|
32
|
+
const meta = await resolvePageMetadata({
|
|
33
|
+
pageData: pageWithRootProps({
|
|
34
|
+
title: "About Our Team",
|
|
35
|
+
description: "Meet the people behind the product.",
|
|
36
|
+
_seo: { siteName: "Acme Docs" },
|
|
37
|
+
}),
|
|
38
|
+
path: "/about/team",
|
|
39
|
+
searchParams: {},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(meta.title).toBe("About Our Team");
|
|
43
|
+
expect(meta.description).toBe("Meet the people behind the product.");
|
|
44
|
+
expect((meta.openGraph as { title?: string }).title).toBe(
|
|
45
|
+
"About Our Team",
|
|
46
|
+
);
|
|
47
|
+
expect((meta.openGraph as { siteName?: string }).siteName).toBe(
|
|
48
|
+
"Acme Docs",
|
|
49
|
+
);
|
|
50
|
+
// Callers never see the template step — and it's skipped entirely when the
|
|
51
|
+
// values contain no {{ }}.
|
|
52
|
+
expect(puckServer.loadRemoteDatasourceContext).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("treats the editor's default title boilerplate as absent", async () => {
|
|
56
|
+
// Untitled pages carry root.defaultProps.title ("My Puck Editor"); that
|
|
57
|
+
// boilerplate must not ship as <title>/og:title.
|
|
58
|
+
const meta = await resolvePageMetadata({
|
|
59
|
+
pageData: pageWithRootProps({
|
|
60
|
+
title: "My Puck Editor",
|
|
61
|
+
description: "Real description.",
|
|
62
|
+
_seo: { siteName: "Acme Docs" },
|
|
63
|
+
}),
|
|
64
|
+
path: "/untitled",
|
|
65
|
+
searchParams: {},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(meta.title).toBeUndefined();
|
|
69
|
+
expect((meta.openGraph as { title?: string }).title).toBeUndefined();
|
|
70
|
+
// Other fields are unaffected.
|
|
71
|
+
expect(meta.description).toBe("Real description.");
|
|
72
|
+
expect((meta.openGraph as { siteName?: string }).siteName).toBe(
|
|
73
|
+
"Acme Docs",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("resolves {{ }} templates in title/description via the datasource context", async () => {
|
|
78
|
+
const meta = await resolvePageMetadata({
|
|
79
|
+
pageData: pageWithRootProps({
|
|
80
|
+
title: "Hello {{name}}",
|
|
81
|
+
description: "Welcome, {{name}}!",
|
|
82
|
+
}),
|
|
83
|
+
path: "/greet",
|
|
84
|
+
searchParams: {},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(meta.title).toBe("Hello World");
|
|
88
|
+
expect(meta.description).toBe("Welcome, World!");
|
|
89
|
+
expect(puckServer.loadRemoteDatasourceContext).toHaveBeenCalledOnce();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("sources title/description from root props even when _seo carries legacy page fields", async () => {
|
|
93
|
+
// Guards the contract: _seo is site-level only; page-level values on it
|
|
94
|
+
// (e.g. from an older backend) are ignored in favor of root props.
|
|
95
|
+
const meta = await resolvePageMetadata({
|
|
96
|
+
pageData: pageWithRootProps({
|
|
97
|
+
title: "Root Title",
|
|
98
|
+
description: "Root description.",
|
|
99
|
+
_seo: {
|
|
100
|
+
title: "Stale backend title",
|
|
101
|
+
description: "Stale backend description.",
|
|
102
|
+
siteName: "Acme",
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
path: "/untitled",
|
|
106
|
+
searchParams: {},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(meta.title).toBe("Root Title");
|
|
110
|
+
expect(meta.description).toBe("Root description.");
|
|
111
|
+
expect((meta.openGraph as { siteName?: string }).siteName).toBe("Acme");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back to the path canonical when pageData/_seo is absent and a site URL is configured", async () => {
|
|
115
|
+
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://site.example");
|
|
116
|
+
const meta = await resolvePageMetadata({
|
|
117
|
+
pageData: null,
|
|
118
|
+
path: "/",
|
|
119
|
+
searchParams: {},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(meta.title).toBeUndefined();
|
|
123
|
+
expect(meta.description).toBeUndefined();
|
|
124
|
+
expect(meta.alternates?.canonical).toBe("/");
|
|
125
|
+
expect(puckServer.loadRemoteDatasourceContext).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("paragraphBlock", () => {
|
|
4
|
+
it("has a richtext field for text", async () => {
|
|
5
|
+
const { paragraphBlock } = await import("../components/puck/paragraph-block");
|
|
6
|
+
expect(paragraphBlock.fields.text.type).toBe("richtext");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("enables inline canvas editing", async () => {
|
|
10
|
+
const { paragraphBlock } = await import("../components/puck/paragraph-block");
|
|
11
|
+
expect((paragraphBlock.fields.text as any).contentEditable).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("includes ai instructions on the text field", async () => {
|
|
15
|
+
const { paragraphBlock } = await import("../components/puck/paragraph-block");
|
|
16
|
+
const ai = (paragraphBlock.fields.text as any).ai;
|
|
17
|
+
expect(ai).toBeDefined();
|
|
18
|
+
expect(typeof ai?.instructions).toBe("string");
|
|
19
|
+
expect(ai?.instructions.length).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("provides a renderMenu function", async () => {
|
|
23
|
+
const { paragraphBlock } = await import("../components/puck/paragraph-block");
|
|
24
|
+
expect(typeof (paragraphBlock.fields.text as any).renderMenu).toBe("function");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("has a default text prop", async () => {
|
|
28
|
+
const { paragraphBlock } = await import("../components/puck/paragraph-block");
|
|
29
|
+
expect(typeof paragraphBlock.defaultProps.text).toBe("string");
|
|
30
|
+
expect(paragraphBlock.defaultProps.text.length).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("provides a render function", async () => {
|
|
34
|
+
const { paragraphBlock } = await import("../components/puck/paragraph-block");
|
|
35
|
+
expect(typeof paragraphBlock.render).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sanitizeRichtextHtml } from "../components/puck/sanitize-richtext";
|
|
3
|
+
|
|
4
|
+
describe("sanitizeRichtextHtml", () => {
|
|
5
|
+
it("strips <script> tags", () => {
|
|
6
|
+
const out = sanitizeRichtextHtml('<p>hi</p><script>alert(1)</script>');
|
|
7
|
+
expect(out).not.toContain("<script");
|
|
8
|
+
expect(out).not.toContain("alert(1)");
|
|
9
|
+
expect(out).toContain("<p>hi</p>");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("drops javascript: hrefs but keeps the link text", () => {
|
|
13
|
+
const out = sanitizeRichtextHtml('<a href="javascript:alert(1)">click</a>');
|
|
14
|
+
expect(out.toLowerCase()).not.toContain("javascript:");
|
|
15
|
+
expect(out).toContain("click");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("drops data: hrefs", () => {
|
|
19
|
+
const out = sanitizeRichtextHtml(
|
|
20
|
+
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
|
|
21
|
+
);
|
|
22
|
+
expect(out.toLowerCase()).not.toContain("data:");
|
|
23
|
+
expect(out.toLowerCase()).not.toContain("<script");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("removes <img> and its onerror handler entirely", () => {
|
|
27
|
+
const out = sanitizeRichtextHtml('<img src="x" onerror="alert(1)">');
|
|
28
|
+
expect(out.toLowerCase()).not.toContain("<img");
|
|
29
|
+
expect(out.toLowerCase()).not.toContain("onerror");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("strips inline event-handler attributes", () => {
|
|
33
|
+
const out = sanitizeRichtextHtml('<p onclick="steal()">text</p>');
|
|
34
|
+
expect(out.toLowerCase()).not.toContain("onclick");
|
|
35
|
+
expect(out).toContain("text");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("preserves safe formatting, lists, and https links", () => {
|
|
39
|
+
const input =
|
|
40
|
+
'<p><strong>bold</strong> and <em>italic</em></p>' +
|
|
41
|
+
'<ul><li>one</li><li>two</li></ul>' +
|
|
42
|
+
'<a href="https://example.com">safe link</a>';
|
|
43
|
+
const out = sanitizeRichtextHtml(input);
|
|
44
|
+
expect(out).toContain("<strong>bold</strong>");
|
|
45
|
+
expect(out).toContain("<em>italic</em>");
|
|
46
|
+
expect(out).toContain("<li>one</li>");
|
|
47
|
+
expect(out).toContain('href="https://example.com"');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps relative and anchor hrefs", () => {
|
|
51
|
+
expect(sanitizeRichtextHtml('<a href="/about">a</a>')).toContain(
|
|
52
|
+
'href="/about"',
|
|
53
|
+
);
|
|
54
|
+
expect(sanitizeRichtextHtml('<a href="#section">a</a>')).toContain(
|
|
55
|
+
'href="#section"',
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildPageMetadata } from "../lib/seo-metadata";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PCC-3407: buildPageMetadata maps the head metadata inputs (client-derived
|
|
6
|
+
* title/description/canonical plus the backend-supplied siteName) onto the
|
|
7
|
+
* Next.js Metadata object that renders the per-page <head> tags. Next replaces
|
|
8
|
+
* (not deep-merges) a page's openGraph over the layout's, so og:type and the
|
|
9
|
+
* env og:site_name fallback are declared here rather than relying on the
|
|
10
|
+
* layout. A relative canonical is emitted only when NEXT_PUBLIC_SITE_URL is
|
|
11
|
+
* configured to resolve it — a wrong (localhost) canonical is worse than none.
|
|
12
|
+
*/
|
|
13
|
+
describe("buildPageMetadata", () => {
|
|
14
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
15
|
+
|
|
16
|
+
it("maps a full SeoMetadata onto title/description/canonical/OG tags", () => {
|
|
17
|
+
const meta = buildPageMetadata({
|
|
18
|
+
seo: {
|
|
19
|
+
title: "About Our Team",
|
|
20
|
+
description: "Meet the people behind the product.",
|
|
21
|
+
canonicalUrl: "https://content.public.url/about/team",
|
|
22
|
+
siteName: "Acme Docs",
|
|
23
|
+
},
|
|
24
|
+
path: "/about/team",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(meta.title).toBe("About Our Team");
|
|
28
|
+
expect(meta.description).toBe("Meet the people behind the product.");
|
|
29
|
+
// Absolute canonicalUrl is used verbatim for canonical + og:url.
|
|
30
|
+
expect(meta.alternates?.canonical).toBe(
|
|
31
|
+
"https://content.public.url/about/team",
|
|
32
|
+
);
|
|
33
|
+
expect(meta.openGraph?.url).toBe("https://content.public.url/about/team");
|
|
34
|
+
expect(meta.openGraph?.title).toBe("About Our Team");
|
|
35
|
+
expect(meta.openGraph?.description).toBe(
|
|
36
|
+
"Meet the people behind the product.",
|
|
37
|
+
);
|
|
38
|
+
expect((meta.openGraph as { siteName?: string }).siteName).toBe(
|
|
39
|
+
"Acme Docs",
|
|
40
|
+
);
|
|
41
|
+
expect((meta.openGraph as { type?: string }).type).toBe("website");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to the relative path when canonicalUrl is absent and a site URL is configured", () => {
|
|
45
|
+
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://site.example");
|
|
46
|
+
const meta = buildPageMetadata({
|
|
47
|
+
seo: { title: "No Canonical" },
|
|
48
|
+
path: "/no-canonical",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(meta.alternates?.canonical).toBe("/no-canonical");
|
|
52
|
+
expect(meta.openGraph?.url).toBe("/no-canonical");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("omits canonical and og:url when neither canonicalUrl nor a site URL exists", () => {
|
|
56
|
+
const meta = buildPageMetadata({
|
|
57
|
+
seo: { title: "No Canonical" },
|
|
58
|
+
path: "/no-canonical",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(meta.alternates).toBeUndefined();
|
|
62
|
+
expect((meta.openGraph as { url?: string }).url).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to the env site name when siteName is absent", () => {
|
|
66
|
+
vi.stubEnv("NEXT_PUBLIC_SITE_NAME", "Env Site");
|
|
67
|
+
const meta = buildPageMetadata({
|
|
68
|
+
seo: { title: "No Site Name" },
|
|
69
|
+
path: "/x",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect((meta.openGraph as { siteName?: string }).siteName).toBe(
|
|
73
|
+
"Env Site",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("omits og:site_name when siteName and the env fallback are both absent", () => {
|
|
78
|
+
const meta = buildPageMetadata({
|
|
79
|
+
seo: { title: "No Site Name" },
|
|
80
|
+
path: "/x",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect((meta.openGraph as { siteName?: string }).siteName).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("treats an empty title as absent", () => {
|
|
87
|
+
const meta = buildPageMetadata({
|
|
88
|
+
seo: { title: "" },
|
|
89
|
+
path: "/x",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(meta.title).toBeUndefined();
|
|
93
|
+
expect((meta.openGraph as { title?: string }).title).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("handles missing seo entirely (og:type still emitted, canonical omitted)", () => {
|
|
97
|
+
const meta = buildPageMetadata({ path: "/" });
|
|
98
|
+
|
|
99
|
+
expect(meta.title).toBeUndefined();
|
|
100
|
+
expect(meta.description).toBeUndefined();
|
|
101
|
+
expect(meta.alternates).toBeUndefined();
|
|
102
|
+
expect((meta.openGraph as { url?: string }).url).toBeUndefined();
|
|
103
|
+
expect((meta.openGraph as { type?: string }).type).toBe("website");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
* Catch-all route that renders user-facing pages generated by Puck.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { Metadata } from "next";
|
|
5
|
+
import type { Metadata } from "next";
|
|
7
6
|
import {
|
|
8
7
|
loadRemoteDatasourceContext,
|
|
9
8
|
extractReferencedDatasourceIds,
|
|
10
9
|
getPage,
|
|
11
10
|
listRouteTemplateKeysFromDatabase,
|
|
12
11
|
resolveDataTemplates,
|
|
13
|
-
resolveStringTemplates,
|
|
14
12
|
ensureInitialized,
|
|
15
13
|
pagePathFromCatchAllSegments,
|
|
16
14
|
} from "@pantheon-systems/puck-css/server";
|
|
17
15
|
import { REMOTE_DATASOURCE_FETCHERS } from "../../lib/remote-datasource-fetchers";
|
|
16
|
+
import { resolvePageMetadata } from "../../lib/page-seo";
|
|
17
|
+
import { Client } from "./client";
|
|
18
18
|
|
|
19
19
|
const initPromise = ensureInitialized({
|
|
20
20
|
p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
|
|
@@ -40,29 +40,8 @@ export async function generateMetadata({
|
|
|
40
40
|
return { title: "Not Found" };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const [pageData, sp
|
|
44
|
-
|
|
45
|
-
searchParams,
|
|
46
|
-
listRouteTemplateKeysFromDatabase(),
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
const rawTitle = pageData?.root.props?.title;
|
|
50
|
-
if (typeof rawTitle !== "string") {
|
|
51
|
-
return { title: rawTitle };
|
|
52
|
-
}
|
|
53
|
-
if (!rawTitle.includes("{{")) {
|
|
54
|
-
return { title: rawTitle };
|
|
55
|
-
}
|
|
56
|
-
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
57
|
-
const context = await loadRemoteDatasourceContext({
|
|
58
|
-
searchParams: sp,
|
|
59
|
-
fetchImpl: fetch,
|
|
60
|
-
pagePath: path,
|
|
61
|
-
routeTemplateKeys,
|
|
62
|
-
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
63
|
-
referencedDatasourceIds,
|
|
64
|
-
});
|
|
65
|
-
return { title: await resolveStringTemplates(rawTitle, context) };
|
|
43
|
+
const [pageData, sp] = await Promise.all([getPage(path), searchParams]);
|
|
44
|
+
return resolvePageMetadata({ pageData, path, searchParams: sp });
|
|
66
45
|
}
|
|
67
46
|
|
|
68
47
|
export default async function Page({
|
package/template/app/layout.tsx
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import "./styles.css";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
|
|
4
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
|
5
|
+
|
|
6
|
+
// A page-level openGraph replaces (not merges) this one, so buildPageMetadata
|
|
7
|
+
// re-declares og:type and the env site-name fallback; this covers routes that
|
|
8
|
+
// return no openGraph (e.g. not-found early returns).
|
|
9
|
+
export const metadata: Metadata = {
|
|
10
|
+
...(siteUrl ? { metadataBase: new URL(siteUrl) } : {}),
|
|
11
|
+
openGraph: {
|
|
12
|
+
type: "website",
|
|
13
|
+
siteName: process.env.NEXT_PUBLIC_SITE_NAME,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
2
16
|
|
|
3
17
|
export default function RootLayout({
|
|
4
18
|
children,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useCallback, useState } from "react";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
4
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
5
5
|
import { Puck } from "@puckeditor/core";
|
|
6
6
|
import {
|
|
7
7
|
P1App,
|
|
@@ -13,20 +13,22 @@ import {
|
|
|
13
13
|
P1QueryProvider,
|
|
14
14
|
editorPathHref,
|
|
15
15
|
} from "@pantheon-systems/puck-css";
|
|
16
|
-
import {
|
|
16
|
+
import { LoadingMessage } from "@pantheon-systems/puck-css/pds";
|
|
17
|
+
import { P1NextRouterProvider, editorPagePathFromUrlPath } from "@pantheon-systems/p1-next-sdk";
|
|
17
18
|
import { createAIChatPlugin } from "@pantheon-systems/p1-ai-chat";
|
|
19
|
+
import { createMediaPlugin } from "@pantheon-systems/p1-media";
|
|
18
20
|
import { useFlags } from "launchdarkly-react-client-sdk";
|
|
19
21
|
import type { Checkpoint } from "@pantheon-systems/puck-css";
|
|
20
22
|
import type { ContentRole } from "@pantheon-systems/puck-css";
|
|
21
|
-
import { P1_ASSETS } from "
|
|
23
|
+
import { P1_ASSETS } from "../../../../constants/assets";
|
|
22
24
|
|
|
23
25
|
import "@pantheon-systems/puck-css/styles.css";
|
|
24
26
|
import "@pantheon-systems/puck-css/pds/styles.css";
|
|
25
27
|
|
|
26
|
-
import { ChatbotFlagProvider } from "
|
|
27
|
-
import { P1Lockup } from "
|
|
28
|
-
import config from "
|
|
29
|
-
import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "
|
|
28
|
+
import { ChatbotFlagProvider } from "../../../../components/ChatbotFlagProvider";
|
|
29
|
+
import { P1Lockup } from "../../../../components/p1-lockup";
|
|
30
|
+
import config from "../../../../puck.config";
|
|
31
|
+
import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "../../../../lib/chatbot-flag/feature-gate";
|
|
30
32
|
|
|
31
33
|
const DEFAULT_PAGE_DATA = {
|
|
32
34
|
root: { props: { title: "New page" } },
|
|
@@ -107,7 +109,11 @@ function P1SignInPage() {
|
|
|
107
109
|
);
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
export function EditorClientWrapper(
|
|
112
|
+
export function EditorClientWrapper() {
|
|
113
|
+
// Rendered from the persistent (editor) layout, so this survives page
|
|
114
|
+
// switches; the edited page is derived from the URL instead of route params.
|
|
115
|
+
const pathname = usePathname();
|
|
116
|
+
const path = editorPagePathFromUrlPath(pathname);
|
|
111
117
|
const [userRole, setUserRole] = useState<ContentRole>('editor');
|
|
112
118
|
const lastGoodStateRef = React.useRef<{ puckKey: string; puckProps: any } | null>(null);
|
|
113
119
|
|
|
@@ -205,6 +211,7 @@ function EditorContent({
|
|
|
205
211
|
const router = useRouter();
|
|
206
212
|
const { getToken } = useP1Auth();
|
|
207
213
|
const p1Plugins = useP1Plugins(path, config);
|
|
214
|
+
const mediaPlugin = React.useMemo(() => createMediaPlugin({}), []);
|
|
208
215
|
const flags = useFlags();
|
|
209
216
|
const agentUrl = process.env.NEXT_PUBLIC_AGENT_URL;
|
|
210
217
|
const chatbotEnabled = shouldShowChatbot(flags[CHATBOT_FLAG_KEY], agentUrl);
|
|
@@ -216,9 +223,9 @@ function EditorContent({
|
|
|
216
223
|
[chatbotEnabled, agentUrl],
|
|
217
224
|
);
|
|
218
225
|
const additionalPlugins = React.useMemo(
|
|
219
|
-
() => (aiPlugin ? [...p1Plugins, aiPlugin] : p1Plugins),
|
|
220
|
-
[p1Plugins, aiPlugin],
|
|
221
|
-
)
|
|
226
|
+
() => (aiPlugin ? [...p1Plugins, mediaPlugin, aiPlugin] : [...p1Plugins, mediaPlugin]),
|
|
227
|
+
[p1Plugins, mediaPlugin, aiPlugin],
|
|
228
|
+
);
|
|
222
229
|
|
|
223
230
|
const [redirecting, setRedirecting] = React.useState(false);
|
|
224
231
|
|
|
@@ -285,20 +292,12 @@ function EditorContent({
|
|
|
285
292
|
}, [loading, error, puckKey, puckProps]);
|
|
286
293
|
|
|
287
294
|
if (redirecting) {
|
|
288
|
-
return
|
|
289
|
-
<div style={{ textAlign: "center", padding: "4rem", fontFamily: "system-ui" }}>
|
|
290
|
-
Redirecting...
|
|
291
|
-
</div>
|
|
292
|
-
);
|
|
295
|
+
return <LoadingMessage message="Redirecting" data-testid="editor-redirecting" />;
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
// Show full loading screen only on first load (no previous state)
|
|
296
299
|
if (loading && !lastGoodStateRef.current) {
|
|
297
|
-
return
|
|
298
|
-
<div style={{ textAlign: "center", padding: "4rem", fontFamily: "system-ui" }}>
|
|
299
|
-
Loading editor...
|
|
300
|
-
</div>
|
|
301
|
-
);
|
|
300
|
+
return <LoadingMessage message="Loading document" data-testid="editor-loading" />;
|
|
302
301
|
}
|
|
303
302
|
|
|
304
303
|
// Show error only if we have no previous state to fall back to
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import "@puckeditor/core/puck.css";
|
|
2
1
|
import { createP1Pages } from "@pantheon-systems/p1-next-sdk/server";
|
|
3
|
-
import config from "
|
|
2
|
+
import config from "../../../../puck.config";
|
|
4
3
|
import { EditorClientWrapper } from "./editor-client";
|
|
5
4
|
|
|
6
|
-
const pages = createP1Pages({
|
|
5
|
+
export const pages = createP1Pages({
|
|
7
6
|
config,
|
|
8
7
|
p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
|
|
9
8
|
p1ApiKey: process.env.CSS_API_KEY,
|
|
@@ -13,7 +12,3 @@ const pages = createP1Pages({
|
|
|
13
12
|
p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID ?? "main",
|
|
14
13
|
EditorClient: EditorClientWrapper,
|
|
15
14
|
});
|
|
16
|
-
|
|
17
|
-
export default pages.Page;
|
|
18
|
-
export const generateMetadata = pages.generateMetadata;
|
|
19
|
-
export const dynamic = "force-dynamic";
|