@pantheon-systems/create-p1-starter-kit 0.10.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/package.json +3 -3
- package/template/__tests__/image-block.test.tsx +48 -0
- package/template/__tests__/page-seo-meta-templates.test.ts +4 -2
- package/template/__tests__/page-seo-meta.test.ts +4 -5
- package/template/__tests__/page-seo.test.ts +4 -6
- package/template/__tests__/published-page-404.test.ts +65 -0
- package/template/app/[...puckPath]/page.tsx +49 -67
- package/template/app/not-found.tsx +11 -0
- package/template/app/page.tsx +20 -22
- package/template/components/content-unavailable.tsx +22 -0
- package/template/components/page-missing.tsx +49 -0
- package/template/components/puck/image-block.tsx +22 -1
- package/template/lib/page-seo.ts +2 -5
- package/template/package.json +6 -6
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": {
|
|
@@ -38,9 +38,9 @@
|
|
|
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.
|
|
41
|
+
"@pantheon-systems/css-client": "0.11.0",
|
|
42
42
|
"@pantheon-systems/eslint-config": "0.1.0",
|
|
43
|
-
"@pantheon-systems/puck-css": "0.
|
|
43
|
+
"@pantheon-systems/puck-css": "0.11.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@clack/prompts": "^1.5.1",
|
|
@@ -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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
61
|
-
|
|
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
|
|
81
|
-
getPage(path),
|
|
82
|
-
searchParams,
|
|
83
|
-
listRouteTemplateKeysFromDatabase(),
|
|
84
|
-
getCssQueryFetchers(),
|
|
85
|
-
]);
|
|
90
|
+
const result = await loadPublishedPage(path);
|
|
86
91
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
404 – This page doesn't exist yet
|
|
93
|
-
</h1>
|
|
94
|
-
<p className="mt-4 text-gray-600">
|
|
95
|
-
This page hasn'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 →
|
|
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
|
|
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
|
+
}
|
package/template/app/page.tsx
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
26
|
-
|
|
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: "/"
|
|
28
|
+
return resolvePageMetadata({ pageData: result.data, path: "/" });
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
export default async function HomePage() {
|
|
34
|
-
await
|
|
35
|
-
const data = await getPage("/");
|
|
32
|
+
const result = await loadPublishedPage("/");
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
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'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 – This page doesn't exist yet
|
|
19
|
+
</h1>
|
|
20
|
+
<p className="mt-4 text-gray-600">
|
|
21
|
+
This page hasn'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 →
|
|
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: ({
|
|
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
|
) : (
|
package/template/lib/page-seo.ts
CHANGED
|
@@ -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
|
|
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,
|
package/template/package.json
CHANGED
|
@@ -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.
|
|
19
|
-
"@pantheon-systems/p1-ai-chat": "
|
|
20
|
-
"@pantheon-systems/p1-media": "
|
|
21
|
-
"@pantheon-systems/p1-next-sdk": "^0.
|
|
22
|
-
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.
|
|
23
|
-
"@pantheon-systems/puck-css": "^0.
|
|
18
|
+
"@pantheon-systems/css-client": "^0.11.0",
|
|
19
|
+
"@pantheon-systems/p1-ai-chat": "^0.5.0",
|
|
20
|
+
"@pantheon-systems/p1-media": "^0.4.4",
|
|
21
|
+
"@pantheon-systems/p1-next-sdk": "^0.11.0",
|
|
22
|
+
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.66",
|
|
23
|
+
"@pantheon-systems/puck-css": "^0.11.0",
|
|
24
24
|
"@puckeditor/core": "^0.21.1",
|
|
25
25
|
"@tailwindcss/postcss": "^4.2.2",
|
|
26
26
|
"@tailwindcss/typography": "^0.5.16",
|