@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.
Files changed (34) hide show
  1. package/package.json +8 -3
  2. package/template/.env.example +6 -0
  3. package/template/CHANGELOG.md +32 -0
  4. package/template/__tests__/chatbot-flag-wiring.test.ts +1 -1
  5. package/template/__tests__/editor-integration.test.ts +1 -1
  6. package/template/__tests__/editor-route-group.test.ts +44 -0
  7. package/template/__tests__/page-seo.test.ts +127 -0
  8. package/template/__tests__/paragraph-block.test.ts +37 -0
  9. package/template/__tests__/sanitize-richtext.test.ts +58 -0
  10. package/template/__tests__/seo-metadata.test.ts +105 -0
  11. package/template/app/[...puckPath]/page.tsx +5 -26
  12. package/template/app/layout.tsx +14 -0
  13. package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +20 -21
  14. package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
  15. package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
  16. package/template/app/p1/(editor)/layout.tsx +13 -0
  17. package/template/app/page.tsx +3 -21
  18. package/template/app/styles.css +1 -0
  19. package/template/ci-examples/github-actions-sync-puck-registry.yml +57 -0
  20. package/template/components/puck/media-figure-block.tsx +12 -0
  21. package/template/components/puck/paragraph-block.tsx +11 -31
  22. package/template/components/puck/sanitize-richtext.ts +44 -0
  23. package/template/lib/page-seo.ts +79 -0
  24. package/template/lib/seo-metadata.ts +48 -0
  25. package/template/package.json +12 -5
  26. package/template/pnpm-workspace.yaml +3 -0
  27. package/template/public/images/p1_logo.svg +11 -4
  28. package/template/puck.config.tsx +3 -1
  29. package/template/scripts/__tests__/asset-stub-hooks.test.ts +177 -0
  30. package/template/scripts/__tests__/sync-puck-registry.test.ts +230 -0
  31. package/template/scripts/asset-stub-hooks.mjs +58 -0
  32. package/template/scripts/sync-puck-registry.ts +225 -0
  33. package/template/tsconfig.test.json +6 -0
  34. 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.6.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": ">=20.12.0"
33
+ "node": ">=24.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "vitest": "^4.1.5"
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",
@@ -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
@@ -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 { Client } from "./client";
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, routeTemplateKeys] = await Promise.all([
44
- getPage(path),
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({
@@ -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 { P1NextRouterProvider } from "@pantheon-systems/p1-next-sdk";
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 "../../../constants/assets";
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 "../../../components/ChatbotFlagProvider";
27
- import { P1Lockup } from "../../../components/p1-lockup";
28
- import config from "../../../puck.config";
29
- import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "../../../lib/chatbot-flag/feature-gate";
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({ path }: { path: string }) {
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
- ) as typeof p1Plugins;
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 "../../../puck.config";
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";
@@ -0,0 +1,5 @@
1
+ import { pages } from "./p1-pages";
2
+
3
+ export default pages.Page;
4
+ export const generateMetadata = pages.generateMetadata;
5
+ export const dynamic = "force-dynamic";