@pantheon-systems/create-p1-starter-kit 0.10.0 → 0.11.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantheon-systems/create-p1-starter-kit",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Scaffold a new P1 starter project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,15 +38,15 @@
38
38
  "isomorphic-dompurify": "^3.18.0",
39
39
  "react": "^19.2.5",
40
40
  "vitest": "^4.1.5",
41
- "@pantheon-systems/css-client": "0.10.0",
41
+ "@pantheon-systems/css-client": "0.11.1",
42
42
  "@pantheon-systems/eslint-config": "0.1.0",
43
- "@pantheon-systems/puck-css": "0.10.0"
43
+ "@pantheon-systems/puck-css": "0.11.1"
44
44
  },
45
45
  "dependencies": {
46
46
  "@clack/prompts": "^1.5.1",
47
47
  "picocolors": "^1.1.1"
48
48
  },
49
- "license": "MIT",
49
+ "license": "UNLICENSED",
50
50
  "homepage": "https://github.com/pantheon-systems/p1-platform/tree/main/packages/create-p1-starter-kit",
51
51
  "scripts": {
52
52
  "build": "node scripts/build-template.js",
@@ -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
+ });
@@ -4,8 +4,11 @@ vi.mock("../lib/remote-datasource-fetchers", () => ({
4
4
  REMOTE_DATASOURCE_FETCHERS: {},
5
5
  }));
6
6
 
7
+ vi.mock("@pantheon-systems/p1-next-sdk/server", () => ({
8
+ loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
9
+ }));
10
+
7
11
  vi.mock("@pantheon-systems/puck-css/server", () => ({
8
- listRouteTemplateKeysFromDatabase: vi.fn().mockResolvedValue([]),
9
12
  extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
10
13
  loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
11
14
  resolveStringTemplates: vi.fn(async (input: string) =>
@@ -40,7 +43,6 @@ const resolve = (props: Record<string, unknown>) =>
40
43
  resolvePageMetadata({
41
44
  pageData: pageWithRootProps(props),
42
45
  path: "/q3",
43
- searchParams: {},
44
46
  });
45
47
 
46
48
  const og = (m: Awaited<ReturnType<typeof resolvePageMetadata>>) =>
@@ -4,8 +4,11 @@ vi.mock("../lib/remote-datasource-fetchers", () => ({
4
4
  REMOTE_DATASOURCE_FETCHERS: {},
5
5
  }));
6
6
 
7
+ vi.mock("@pantheon-systems/p1-next-sdk/server", () => ({
8
+ loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
9
+ }));
10
+
7
11
  vi.mock("@pantheon-systems/puck-css/server", () => ({
8
- listRouteTemplateKeysFromDatabase: vi.fn().mockResolvedValue([]),
9
12
  extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
10
13
  loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
11
14
  resolveStringTemplates: vi.fn(async (input: string) =>
@@ -46,7 +49,6 @@ describe("resolvePageMetadata — _meta", () => {
46
49
  },
47
50
  }),
48
51
  path: "/q3",
49
- searchParams: {},
50
52
  });
51
53
 
52
54
  expect(og(meta).title).toBe("The launch, in numbers");
@@ -63,7 +65,6 @@ describe("resolvePageMetadata — _meta", () => {
63
65
  description: "How the launch went.",
64
66
  }),
65
67
  path: "/q3",
66
- searchParams: {},
67
68
  });
68
69
 
69
70
  expect(og(meta).title).toBe("Q3 Launch Recap");
@@ -78,7 +79,6 @@ describe("resolvePageMetadata — _meta", () => {
78
79
  _meta: { ogDescription: "Static description." },
79
80
  }),
80
81
  path: "/greet",
81
- searchParams: {},
82
82
  });
83
83
 
84
84
  expect(meta.title).toBe("Hello World");
@@ -89,7 +89,6 @@ describe("resolvePageMetadata — _meta", () => {
89
89
  const meta = await resolvePageMetadata({
90
90
  pageData: pageWithRootProps({ title: "My Puck Editor" }),
91
91
  path: "/new",
92
- searchParams: {},
93
92
  });
94
93
 
95
94
  expect(meta.title).toBeUndefined();
@@ -6,8 +6,11 @@ vi.mock("../lib/remote-datasource-fetchers", () => ({
6
6
  REMOTE_DATASOURCE_FETCHERS: {},
7
7
  }));
8
8
 
9
+ vi.mock("@pantheon-systems/p1-next-sdk/server", () => ({
10
+ loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
11
+ }));
12
+
9
13
  vi.mock("@pantheon-systems/puck-css/server", () => ({
10
- listRouteTemplateKeysFromDatabase: vi.fn().mockResolvedValue([]),
11
14
  extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
12
15
  loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
13
16
  resolveStringTemplates: vi.fn(async (input: string) =>
@@ -36,7 +39,6 @@ describe("resolvePageMetadata", () => {
36
39
  _seo: { siteName: "Acme Docs" },
37
40
  }),
38
41
  path: "/about/team",
39
- searchParams: {},
40
42
  });
41
43
 
42
44
  expect(meta.title).toBe("About Our Team");
@@ -62,7 +64,6 @@ describe("resolvePageMetadata", () => {
62
64
  _seo: { siteName: "Acme Docs" },
63
65
  }),
64
66
  path: "/untitled",
65
- searchParams: {},
66
67
  });
67
68
 
68
69
  expect(meta.title).toBeUndefined();
@@ -81,7 +82,6 @@ describe("resolvePageMetadata", () => {
81
82
  description: "Welcome, {{name}}!",
82
83
  }),
83
84
  path: "/greet",
84
- searchParams: {},
85
85
  });
86
86
 
87
87
  expect(meta.title).toBe("Hello World");
@@ -103,7 +103,6 @@ describe("resolvePageMetadata", () => {
103
103
  },
104
104
  }),
105
105
  path: "/untitled",
106
- searchParams: {},
107
106
  });
108
107
 
109
108
  expect(meta.title).toBe("Root Title");
@@ -116,7 +115,6 @@ describe("resolvePageMetadata", () => {
116
115
  const meta = await resolvePageMetadata({
117
116
  pageData: null,
118
117
  path: "/",
119
- searchParams: {},
120
118
  });
121
119
 
122
120
  expect(meta.title).toBeUndefined();
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { loadPublishedPage, notFound } = vi.hoisted(() => ({
4
+ loadPublishedPage: vi.fn(),
5
+ notFound: vi.fn(() => {
6
+ throw new Error("NEXT_NOT_FOUND");
7
+ }),
8
+ }));
9
+
10
+ vi.mock("@pantheon-systems/p1-next-sdk/server", () => ({
11
+ loadPublishedPage,
12
+ loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
13
+ createCssQueryFetchers: vi.fn().mockReturnValue([]),
14
+ }));
15
+
16
+ vi.mock("next/navigation", () => ({ notFound }));
17
+
18
+ vi.mock("@pantheon-systems/puck-css/server", () => ({
19
+ pagePathFromCatchAllSegments: (segments: string[]) => `/${segments.join("/")}`,
20
+ loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
21
+ extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
22
+ resolveDataTemplates: vi.fn(async (d: unknown) => d),
23
+ }));
24
+
25
+ vi.mock("../lib/remote-datasource-fetchers", () => ({
26
+ REMOTE_DATASOURCE_FETCHERS: [],
27
+ }));
28
+
29
+ vi.mock("../app/[...puckPath]/client", () => ({ Client: () => null }));
30
+ vi.mock("../components/content-unavailable", () => ({
31
+ ContentUnavailable: () => null,
32
+ }));
33
+
34
+ import Page from "../app/[...puckPath]/page";
35
+
36
+ const render = (...segments: string[]) =>
37
+ Page({ params: Promise.resolve({ puckPath: segments }) });
38
+
39
+ describe("catch-all route — missing vs unavailable", () => {
40
+ beforeEach(() => vi.clearAllMocks());
41
+
42
+ // The route is statically renderable, so a 200 here would write every junk URL
43
+ // a crawler probes into the response cache as a successful page.
44
+ it("404s a path with no published page", async () => {
45
+ loadPublishedPage.mockResolvedValue({ status: "missing" });
46
+ await expect(render("nope")).rejects.toThrow("NEXT_NOT_FOUND");
47
+ expect(notFound).toHaveBeenCalled();
48
+ });
49
+
50
+ // 404ing here would deindex a live page over a transient backend blip.
51
+ it("does not 404 when the backend is unreachable", async () => {
52
+ loadPublishedPage.mockResolvedValue({ status: "unavailable" });
53
+ await render("real-page");
54
+ expect(notFound).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it("renders published content", async () => {
58
+ loadPublishedPage.mockResolvedValue({
59
+ status: "ok",
60
+ data: { root: { props: { title: "Hi" } }, content: [] },
61
+ });
62
+ await render("blog");
63
+ expect(notFound).not.toHaveBeenCalled();
64
+ });
65
+ });
@@ -4,17 +4,20 @@
4
4
 
5
5
  import { cache } from "react";
6
6
  import type { Metadata } from "next";
7
+ import { notFound } from "next/navigation";
7
8
  import {
8
9
  loadRemoteDatasourceContext,
9
10
  extractReferencedDatasourceIds,
10
- getPage,
11
- listRouteTemplateKeysFromDatabase,
12
11
  resolveDataTemplates,
13
- ensureInitialized,
14
12
  pagePathFromCatchAllSegments,
15
13
  } from "@pantheon-systems/puck-css/server";
16
- import { createCssQueryFetchers } from "@pantheon-systems/p1-next-sdk/server";
14
+ import {
15
+ createCssQueryFetchers,
16
+ loadPublishedPage,
17
+ loadRouteTemplateKeys,
18
+ } from "@pantheon-systems/p1-next-sdk/server";
17
19
  import { REMOTE_DATASOURCE_FETCHERS } from "../../lib/remote-datasource-fetchers";
20
+ import { ContentUnavailable } from "../../components/content-unavailable";
18
21
  import { resolvePageMetadata } from "../../lib/page-seo";
19
22
  import { Client } from "./client";
20
23
 
@@ -33,23 +36,30 @@ function isInternalPath(path: string): boolean {
33
36
  );
34
37
  }
35
38
 
36
- const initPromise = ensureInitialized({
37
- p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
38
- p1ApiKey: process.env.CSS_API_KEY,
39
- p1SiteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
40
- // Default to "main" when unset: server components (no user token) need a
41
- // branch to list/read documents (e.g. the /structure routes table).
42
- p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID ?? "main",
43
- });
39
+ /**
40
+ * Backstop only: publishing calls revalidatePath for the affected routes, so
41
+ * cached pages normally refresh the moment their content changes. This bounds
42
+ * how long an edit made outside that path (a direct API write, a restored
43
+ * branch) can stay stale.
44
+ */
45
+ export const revalidate = 300;
46
+
47
+ /**
48
+ * Empty on purpose. Routes are authored in P1, so there is nothing to enumerate
49
+ * at build time — but declaring this is what marks the segment statically
50
+ * renderable at all; without it every path here renders fully dynamically and
51
+ * no response is ever cacheable. Unlisted paths render on first request and are
52
+ * cached from then on (dynamicParams defaults to true).
53
+ */
54
+ export function generateStaticParams(): { puckPath: string[] }[] {
55
+ return [];
56
+ }
44
57
 
45
58
  export async function generateMetadata({
46
59
  params,
47
- searchParams,
48
60
  }: {
49
61
  params: Promise<{ puckPath: string[] }>;
50
- searchParams: Promise<Record<string, string | string[] | undefined>>;
51
62
  }): Promise<Metadata> {
52
- await initPromise;
53
63
  const { puckPath = [] } = await params;
54
64
  const path = pagePathFromCatchAllSegments(puckPath);
55
65
 
@@ -57,78 +67,52 @@ export async function generateMetadata({
57
67
  return { title: "Not Found" };
58
68
  }
59
69
 
60
- const [pageData, sp] = await Promise.all([getPage(path), searchParams]);
61
- return resolvePageMetadata({ pageData, path, searchParams: sp });
70
+ const result = await loadPublishedPage(path);
71
+ if (result.status === "missing") return { title: "Not Found" };
72
+ if (result.status === "unavailable") {
73
+ return { title: "Temporarily unavailable" };
74
+ }
75
+ return resolvePageMetadata({ pageData: result.data, path });
62
76
  }
63
77
 
64
78
  export default async function Page({
65
79
  params,
66
- searchParams,
67
80
  }: {
68
81
  params: Promise<{ puckPath: string[] }>;
69
- searchParams: Promise<Record<string, string | string[] | undefined>>;
70
82
  }) {
71
- await initPromise;
72
83
  const { puckPath = [] } = await params;
73
84
  const path = pagePathFromCatchAllSegments(puckPath);
74
85
 
75
86
  if (isInternalPath(path)) {
76
- const { notFound } = await import("next/navigation");
77
87
  notFound();
78
88
  }
79
89
 
80
- const [data, searchParamData, routeTemplateKeys, cssQueryFetchers] = await Promise.all([
81
- getPage(path),
82
- searchParams,
83
- listRouteTemplateKeysFromDatabase(),
84
- getCssQueryFetchers(),
85
- ]);
90
+ const result = await loadPublishedPage(path);
86
91
 
87
- if (!data) {
88
- return (
89
- <main className="flex min-h-screen items-center justify-center bg-gray-50">
90
- <div className="mx-auto max-w-lg px-6 py-16 text-center">
91
- <h1 className="text-3xl font-bold tracking-tight text-gray-900">
92
- 404 &ndash; This page doesn&apos;t exist yet
93
- </h1>
94
- <p className="mt-4 text-gray-600">
95
- This page hasn&apos;t been created. Use the editor to build it.
96
- </p>
97
-
98
- <nav className="mt-10 flex flex-col gap-3">
99
- <a
100
- href={`/p1${path}`}
101
- className="rounded-lg bg-gray-900 px-5 py-3 text-sm font-medium text-white hover:bg-gray-700"
102
- >
103
- Edit this page
104
- </a>
105
- <a
106
- href="/p1"
107
- className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
108
- >
109
- Open the Page Editor
110
- </a>
111
- <a
112
- href={`${process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL || "https://content.pantheon.io"}/dashboard/sites`}
113
- target="_blank"
114
- rel="noopener noreferrer"
115
- className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
116
- >
117
- P1 Dashboard &rarr;
118
- </a>
119
- </nav>
120
- </div>
121
- </main>
122
- );
92
+ // A real 404, so misses are not cached as successes now that this route is
93
+ // statically renderable. An outage is deliberately not a 404 — that would
94
+ // deindex published pages over a transient blip.
95
+ if (result.status === "missing") {
96
+ notFound();
123
97
  }
98
+ if (result.status === "unavailable") {
99
+ return <ContentUnavailable />;
100
+ }
101
+
102
+ const data = result.data;
103
+
104
+ const [routeTemplateKeys, cssQueryFetchers] = await Promise.all([
105
+ loadRouteTemplateKeys(),
106
+ getCssQueryFetchers(),
107
+ ]);
108
+ const builtinFetchers = [...REMOTE_DATASOURCE_FETCHERS, ...cssQueryFetchers];
124
109
 
125
110
  const referencedDatasourceIds = extractReferencedDatasourceIds(data);
126
111
  const context = await loadRemoteDatasourceContext({
127
- searchParams: searchParamData,
128
112
  fetchImpl: fetch,
129
113
  pagePath: path,
130
114
  routeTemplateKeys,
131
- builtinFetchers: [...REMOTE_DATASOURCE_FETCHERS, ...cssQueryFetchers],
115
+ builtinFetchers,
132
116
  referencedDatasourceIds,
133
117
  });
134
118
  const resolvedData = await resolveDataTemplates(data, context);
@@ -144,5 +128,3 @@ export default async function Page({
144
128
  />
145
129
  );
146
130
  }
147
-
148
- export const dynamic = "force-dynamic";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Rendered by notFound() when a path has no published page. Being a real 404
3
+ * rather than a 200 is what keeps crawler traffic from filling the response
4
+ * cache with "doesn't exist yet" pages and getting them indexed.
5
+ */
6
+
7
+ import { PageMissing } from "../components/page-missing";
8
+
9
+ export default function NotFound() {
10
+ return <PageMissing />;
11
+ }
@@ -1,44 +1,44 @@
1
1
  import {
2
- ensureInitialized,
3
- getPage,
4
- listRouteTemplateKeysFromDatabase,
5
2
  resolveDataTemplates,
6
3
  extractReferencedDatasourceIds,
7
4
  loadRemoteDatasourceContext,
8
5
  } from "@pantheon-systems/puck-css/server";
6
+ import {
7
+ loadPublishedPage,
8
+ loadRouteTemplateKeys,
9
+ } from "@pantheon-systems/p1-next-sdk/server";
9
10
  import type { Metadata } from "next";
10
11
  import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
11
12
  import { resolvePageMetadata } from "../lib/page-seo";
12
13
  import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
13
14
  import { Client } from "./[...puckPath]/client";
14
15
 
15
- const initPromise = ensureInitialized({
16
- p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
17
- p1ApiKey: process.env.CSS_API_KEY,
18
- p1SiteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
19
- // Default to "main" when unset: server components (no user token) need a
20
- // branch to list/read documents (e.g. the /structure routes table).
21
- p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID ?? "main",
22
- });
16
+ /**
17
+ * Backstop only: publishing calls revalidatePath("/"), so the home page
18
+ * normally refreshes the moment its content changes. This bounds how long an
19
+ * edit made outside that path can stay stale.
20
+ */
21
+ export const revalidate = 300;
23
22
 
24
23
  export async function generateMetadata(): Promise<Metadata> {
25
- await initPromise;
26
- const pageData = await getPage("/");
27
- if (!pageData) {
24
+ const result = await loadPublishedPage("/");
25
+ if (result.status !== "ok") {
28
26
  return { title: "P1 Starter Kit" };
29
27
  }
30
- return resolvePageMetadata({ pageData, path: "/", searchParams: {} });
28
+ return resolvePageMetadata({ pageData: result.data, path: "/" });
31
29
  }
32
30
 
33
31
  export default async function HomePage() {
34
- await initPromise;
35
- const data = await getPage("/");
32
+ const result = await loadPublishedPage("/");
36
33
 
37
- if (data) {
38
- const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
34
+ // Unlike the catch-all, "/" never 404s: it is a single fixed URL rather than
35
+ // an unbounded crawler surface, and the welcome block is the correct state for
36
+ // a freshly scaffolded site — including one with no backend configured yet.
37
+ if (result.status === "ok") {
38
+ const data = result.data;
39
+ const routeTemplateKeys = await loadRouteTemplateKeys();
39
40
  const referencedDatasourceIds = extractReferencedDatasourceIds(data);
40
41
  const context = await loadRemoteDatasourceContext({
41
- searchParams: {},
42
42
  fetchImpl: fetch,
43
43
  pagePath: "/",
44
44
  routeTemplateKeys,
@@ -75,5 +75,3 @@ export default async function HomePage() {
75
75
  />
76
76
  );
77
77
  }
78
-
79
- export const dynamic = "force-dynamic";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shown when the content backend could not be reached, as distinct from a path
3
+ * with no page. A published page must not 404 because of a backend blip — that
4
+ * would deindex live content — so this renders a 200 holding page instead, on a
5
+ * render the SDK has already made uncacheable via connection().
6
+ */
7
+
8
+ export function ContentUnavailable() {
9
+ return (
10
+ <main className="flex min-h-screen items-center justify-center bg-gray-50">
11
+ <div className="mx-auto max-w-lg px-6 py-16 text-center">
12
+ <h1 className="text-3xl font-bold tracking-tight text-gray-900">
13
+ This page is temporarily unavailable
14
+ </h1>
15
+ <p className="mt-4 text-gray-600">
16
+ We couldn&apos;t load this content just now. Please try again in a
17
+ moment.
18
+ </p>
19
+ </div>
20
+ </main>
21
+ );
22
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Shown for a path with no published page. Client-side because it renders from
5
+ * the not-found boundary, which receives no params — usePathname is the only
6
+ * way to point "Edit this page" at the path the visitor actually asked for.
7
+ */
8
+
9
+ import { usePathname } from "next/navigation";
10
+
11
+ export function PageMissing() {
12
+ const pathname = usePathname() || "/";
13
+
14
+ return (
15
+ <main className="flex min-h-screen items-center justify-center bg-gray-50">
16
+ <div className="mx-auto max-w-lg px-6 py-16 text-center">
17
+ <h1 className="text-3xl font-bold tracking-tight text-gray-900">
18
+ 404 &ndash; This page doesn&apos;t exist yet
19
+ </h1>
20
+ <p className="mt-4 text-gray-600">
21
+ This page hasn&apos;t been created. Use the editor to build it.
22
+ </p>
23
+
24
+ <nav className="mt-10 flex flex-col gap-3">
25
+ <a
26
+ href={`/p1${pathname}`}
27
+ className="rounded-lg bg-gray-900 px-5 py-3 text-sm font-medium text-white hover:bg-gray-700"
28
+ >
29
+ Edit this page
30
+ </a>
31
+ <a
32
+ href="/p1"
33
+ className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
34
+ >
35
+ Open the Page Editor
36
+ </a>
37
+ <a
38
+ href={`${process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL || "https://content.pantheon.io"}/dashboard/sites`}
39
+ target="_blank"
40
+ rel="noopener noreferrer"
41
+ className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
42
+ >
43
+ P1 Dashboard &rarr;
44
+ </a>
45
+ </nav>
46
+ </div>
47
+ </main>
48
+ );
49
+ }
@@ -6,18 +6,39 @@ export const imageBlock = {
6
6
  src: { type: "text" as const, label: "Image URL" },
7
7
  alt: { type: "text" as const, label: "Alt text" },
8
8
  caption: { type: "textarea" as const, label: "Caption (optional)" },
9
+ loading: {
10
+ type: "radio" as const,
11
+ label: "Loading",
12
+ options: [
13
+ { label: "Lazy", value: "lazy" },
14
+ { label: "Eager", value: "eager" },
15
+ ],
16
+ },
9
17
  },
10
18
  defaultProps: {
11
19
  src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80",
12
20
  alt: "Mountain landscape",
13
21
  caption: "",
22
+ loading: "lazy",
14
23
  },
15
- render: ({ src, alt, caption }: { src?: string; alt?: string; caption?: string }) => (
24
+ render: ({
25
+ src,
26
+ alt,
27
+ caption,
28
+ loading,
29
+ }: {
30
+ src?: string;
31
+ alt?: string;
32
+ caption?: string;
33
+ loading?: "lazy" | "eager";
34
+ }) => (
16
35
  <figure className={`m-0 ${blockPaddingClass}`}>
17
36
  {src ? (
18
37
  <img
19
38
  src={src}
20
39
  alt={alt || ""}
40
+ loading={loading === "eager" ? "eager" : "lazy"}
41
+ decoding="async"
21
42
  className="block h-auto max-h-[400px] w-full max-w-4xl rounded-lg object-contain"
22
43
  />
23
44
  ) : (
@@ -6,9 +6,9 @@ import type {
6
6
  import {
7
7
  loadRemoteDatasourceContext,
8
8
  extractReferencedDatasourceIds,
9
- listRouteTemplateKeysFromDatabase,
10
9
  resolveStringTemplates,
11
10
  } from "@pantheon-systems/puck-css/server";
11
+ import { loadRouteTemplateKeys } from "@pantheon-systems/p1-next-sdk/server";
12
12
  import { REMOTE_DATASOURCE_FETCHERS } from "./remote-datasource-fetchers";
13
13
  import { buildPageMetadata } from "./seo-metadata";
14
14
  import type { PageMetaFields } from "./seo-metadata";
@@ -43,11 +43,9 @@ const carriesTemplate = (value: unknown): value is string =>
43
43
  export async function resolvePageMetadata({
44
44
  pageData,
45
45
  path,
46
- searchParams,
47
46
  }: {
48
47
  pageData: PageData;
49
48
  path: string;
50
- searchParams: Record<string, string | string[] | undefined>;
51
49
  }): Promise<Metadata> {
52
50
  const rootProps: Record<string, unknown> | undefined = pageData?.root.props;
53
51
  const seo: Partial<SeoMetadata> | undefined = rootProps?._seo;
@@ -67,10 +65,9 @@ export async function resolvePageMetadata({
67
65
  let meta = authoredMeta;
68
66
 
69
67
  if (needsTemplates && pageData) {
70
- const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
68
+ const routeTemplateKeys = await loadRouteTemplateKeys();
71
69
  const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
72
70
  const context = await loadRemoteDatasourceContext({
73
- searchParams,
74
71
  fetchImpl: fetch,
75
72
  pagePath: path,
76
73
  routeTemplateKeys,
@@ -15,12 +15,12 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@pantheon-systems/cpub-react-sdk": "^5.2.1",
18
- "@pantheon-systems/css-client": "^0.10.0",
19
- "@pantheon-systems/p1-ai-chat": "workspace:*",
20
- "@pantheon-systems/p1-media": "workspace:*",
21
- "@pantheon-systems/p1-next-sdk": "^0.10.0",
22
- "@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.51",
23
- "@pantheon-systems/puck-css": "^0.10.0",
18
+ "@pantheon-systems/css-client": "^0.11.1",
19
+ "@pantheon-systems/p1-ai-chat": "^0.5.1",
20
+ "@pantheon-systems/p1-media": "^0.4.4",
21
+ "@pantheon-systems/p1-next-sdk": "^0.11.1",
22
+ "@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.66",
23
+ "@pantheon-systems/puck-css": "^0.11.1",
24
24
  "@puckeditor/core": "^0.21.1",
25
25
  "@tailwindcss/postcss": "^4.2.2",
26
26
  "@tailwindcss/typography": "^0.5.16",