@pantheon-systems/create-p1-starter-kit 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/lib/cli.js +1 -1
- package/lib/messages.js +1 -1
- package/package.json +11 -5
- package/template/.env.example +27 -6
- package/template/__tests__/ai-generate.test.ts +74 -0
- package/template/__tests__/auth-route.test.ts +1 -1
- package/template/__tests__/chatbot-flag-wiring.test.ts +21 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +1 -1
- package/template/__tests__/image-block.test.tsx +48 -0
- package/template/__tests__/page-seo-meta-templates.test.ts +145 -0
- package/template/__tests__/page-seo-meta.test.ts +97 -0
- package/template/__tests__/page-seo.test.ts +4 -6
- package/template/__tests__/paragraph-block.test.ts +24 -22
- package/template/__tests__/paragraph-editor-text.test.ts +79 -0
- package/template/__tests__/published-page-404.test.ts +65 -0
- package/template/__tests__/puck-root-guidance.test.ts +109 -0
- package/template/__tests__/puck-root-meta.test.ts +102 -0
- package/template/__tests__/puck-root-selects.test.ts +86 -0
- package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
- package/template/__tests__/seo-metadata-meta.test.ts +180 -0
- package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
- package/template/__tests__/styles-canvas-scope.test.ts +50 -0
- package/template/app/[...puckPath]/page.tsx +67 -67
- package/template/app/layout.tsx +11 -1
- package/template/app/not-found.tsx +11 -0
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
- package/template/app/page.tsx +20 -22
- package/template/app/styles.css +18 -1
- package/template/components/content-unavailable.tsx +22 -0
- package/template/components/p1-lockup.tsx +6 -26
- package/template/components/page-missing.tsx +49 -0
- package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
- package/template/components/puck/data-list-block/index.ts +1 -0
- package/template/components/puck/grid-block.tsx +1 -1
- package/template/components/puck/image-block.tsx +22 -1
- package/template/components/puck/paragraph-block.tsx +7 -2
- package/template/components/puck/paragraph-editor-text.tsx +84 -0
- package/template/components/puck/paragraph-markdown.tsx +15 -0
- package/template/components/puck/root.tsx +185 -4
- package/template/constants/assets.ts +1 -0
- package/template/eslint.config.js +20 -4
- package/template/lib/chatbot-flag/ai-generate.ts +23 -0
- package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
- package/template/lib/monsters-api.ts +14 -8
- package/template/lib/page-seo.ts +46 -16
- package/template/lib/remote-datasources.ts +26 -31
- package/template/lib/seo-metadata.consts.ts +21 -0
- package/template/lib/seo-metadata.ts +96 -15
- package/template/lib/swapi.ts +5 -5
- package/template/middleware.ts +15 -0
- package/template/next.config.mjs +11 -0
- package/template/package.json +15 -12
- package/template/pnpm-workspace.yaml +3 -0
- package/template/public/images/p1_logo.svg +5 -12
- package/template/public/images/p1_logo_reverse.svg +5 -0
- package/template/puck.config.tsx +3 -1
- package/template/tsconfig/nextjs.json +1 -1
- package/template/vitest.config.ts +20 -0
- package/template/next-env.d.ts +0 -6
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildPageMetadata } from "../lib/seo-metadata";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authored page metadata (`root.props._meta`) rendered into <head>.
|
|
6
|
+
*
|
|
7
|
+
* Resolution order is: authored value → derived from title/description → omit
|
|
8
|
+
* the tag. The template and site-default tiers are not wired up yet, so an
|
|
9
|
+
* absent value must omit rather than guess.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const og = (m: ReturnType<typeof buildPageMetadata>) =>
|
|
13
|
+
(m.openGraph ?? {}) as Record<string, unknown>;
|
|
14
|
+
const tw = (m: ReturnType<typeof buildPageMetadata>) =>
|
|
15
|
+
(m.twitter ?? {}) as Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
describe("buildPageMetadata — Open Graph from _meta", () => {
|
|
18
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
19
|
+
|
|
20
|
+
it("prefers the authored og values over the page title and description", () => {
|
|
21
|
+
const meta = buildPageMetadata({
|
|
22
|
+
seo: {
|
|
23
|
+
title: "Q3 Launch Recap",
|
|
24
|
+
description: "How the launch went.",
|
|
25
|
+
meta: { ogTitle: "The launch, in numbers", ogDescription: "Charts inside." },
|
|
26
|
+
},
|
|
27
|
+
path: "/q3",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(og(meta).title).toBe("The launch, in numbers");
|
|
31
|
+
expect(og(meta).description).toBe("Charts inside.");
|
|
32
|
+
// The page's own <title> is unaffected by the social override.
|
|
33
|
+
expect(meta.title).toBe("Q3 Launch Recap");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("falls back to title and description when the og fields are empty", () => {
|
|
37
|
+
const meta = buildPageMetadata({
|
|
38
|
+
seo: {
|
|
39
|
+
title: "Q3 Launch Recap",
|
|
40
|
+
description: "How the launch went.",
|
|
41
|
+
meta: { ogTitle: "", ogDescription: "" },
|
|
42
|
+
},
|
|
43
|
+
path: "/q3",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(og(meta).title).toBe("Q3 Launch Recap");
|
|
47
|
+
expect(og(meta).description).toBe("How the launch went.");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits og:image and og:locale when authored", () => {
|
|
51
|
+
const meta = buildPageMetadata({
|
|
52
|
+
seo: {
|
|
53
|
+
title: "Q3",
|
|
54
|
+
meta: { ogImage: "https://cdn.example/hero.jpg", ogLocale: "en_US" },
|
|
55
|
+
},
|
|
56
|
+
path: "/q3",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(og(meta).images).toBe("https://cdn.example/hero.jpg");
|
|
60
|
+
expect(og(meta).locale).toBe("en_US");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("omits og:image and og:locale entirely when not authored", () => {
|
|
64
|
+
const meta = buildPageMetadata({ seo: { title: "Q3" }, path: "/q3" });
|
|
65
|
+
|
|
66
|
+
expect(og(meta)).not.toHaveProperty("images");
|
|
67
|
+
expect(og(meta)).not.toHaveProperty("locale");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("uses the authored og:type and keeps website as the default", () => {
|
|
71
|
+
expect(og(buildPageMetadata({ seo: { title: "Q3", meta: { ogType: "article" } }, path: "/q3" })).type).toBe("article");
|
|
72
|
+
expect(og(buildPageMetadata({ seo: { title: "Q3" }, path: "/q3" })).type).toBe("website");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("buildPageMetadata — Twitter card", () => {
|
|
77
|
+
it("defaults to summary_large_image when an image is available", () => {
|
|
78
|
+
const meta = buildPageMetadata({
|
|
79
|
+
seo: { title: "Q3", meta: { ogImage: "https://cdn.example/hero.jpg" } },
|
|
80
|
+
path: "/q3",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(tw(meta).card).toBe("summary_large_image");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("defaults to summary when no image is available", () => {
|
|
87
|
+
const meta = buildPageMetadata({ seo: { title: "Q3" }, path: "/q3" });
|
|
88
|
+
|
|
89
|
+
expect(tw(meta).card).toBe("summary");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("respects an authored card style", () => {
|
|
93
|
+
const meta = buildPageMetadata({
|
|
94
|
+
seo: { title: "Q3", meta: { twitterCard: "summary", ogImage: "https://cdn.example/a.jpg" } },
|
|
95
|
+
path: "/q3",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(tw(meta).card).toBe("summary");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores an unrecognised card style rather than emitting it", () => {
|
|
102
|
+
// The field is free text, so a typo must not produce an invalid tag.
|
|
103
|
+
const meta = buildPageMetadata({
|
|
104
|
+
seo: { title: "Q3", meta: { twitterCard: "summary_large" } },
|
|
105
|
+
path: "/q3",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(tw(meta).card).toBe("summary");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back through twitter → og → page for title and image", () => {
|
|
112
|
+
const meta = buildPageMetadata({
|
|
113
|
+
seo: {
|
|
114
|
+
title: "Q3 Launch Recap",
|
|
115
|
+
meta: { ogTitle: "The launch", ogImage: "https://cdn.example/og.jpg" },
|
|
116
|
+
},
|
|
117
|
+
path: "/q3",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(tw(meta).title).toBe("The launch");
|
|
121
|
+
expect(tw(meta).images).toBe("https://cdn.example/og.jpg");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("prefers authored twitter values over the og ones", () => {
|
|
125
|
+
const meta = buildPageMetadata({
|
|
126
|
+
seo: {
|
|
127
|
+
title: "Q3 Launch Recap",
|
|
128
|
+
meta: {
|
|
129
|
+
ogTitle: "The launch",
|
|
130
|
+
ogImage: "https://cdn.example/og.jpg",
|
|
131
|
+
twitterTitle: "Launch, for X",
|
|
132
|
+
twitterImage: "https://cdn.example/x.jpg",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
path: "/q3",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(tw(meta).title).toBe("Launch, for X");
|
|
139
|
+
expect(tw(meta).images).toBe("https://cdn.example/x.jpg");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("omits the twitter block entirely when there is nothing to say", () => {
|
|
143
|
+
const meta = buildPageMetadata({ seo: {}, path: "/untitled" });
|
|
144
|
+
|
|
145
|
+
expect(meta.twitter).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("buildPageMetadata — unchanged behaviour", () => {
|
|
150
|
+
it("still emits title, description and siteName with no _meta present", () => {
|
|
151
|
+
const meta = buildPageMetadata({
|
|
152
|
+
seo: { title: "About", description: "Us", siteName: "Acme" },
|
|
153
|
+
path: "/about",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(meta.title).toBe("About");
|
|
157
|
+
expect(meta.description).toBe("Us");
|
|
158
|
+
expect(og(meta).siteName).toBe("Acme");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("buildPageMetadata — free-text fields are validated", () => {
|
|
163
|
+
it("falls back to website for an unrecognised og:type", () => {
|
|
164
|
+
// Next types og:type as a union, so an arbitrary string is both a type error
|
|
165
|
+
// and an invalid tag, and the editor field is free text.
|
|
166
|
+
const meta = buildPageMetadata({
|
|
167
|
+
seo: { title: "Q3", meta: { ogType: "artical" } },
|
|
168
|
+
path: "/q3",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(og(meta).type).toBe("website");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps the recognised og:type values", () => {
|
|
175
|
+
for (const type of ["website", "article", "book", "profile"]) {
|
|
176
|
+
const meta = buildPageMetadata({ seo: { title: "Q3", meta: { ogType: type } }, path: "/q3" });
|
|
177
|
+
expect(og(meta).type).toBe(type);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildPageMetadata } from "../lib/seo-metadata";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Site-level social defaults, delivered on the content payload alongside
|
|
6
|
+
* og:site_name. They sit below the page's own values: a page that authors
|
|
7
|
+
* og:image wins, a page that leaves it empty inherits the site's.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const og = (m: ReturnType<typeof buildPageMetadata>) =>
|
|
11
|
+
(m.openGraph ?? {}) as Record<string, unknown>;
|
|
12
|
+
const tw = (m: ReturnType<typeof buildPageMetadata>) =>
|
|
13
|
+
(m.twitter ?? {}) as Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
describe("buildPageMetadata — site-level defaults", () => {
|
|
16
|
+
it("uses the site og:image when the page leaves it empty", () => {
|
|
17
|
+
const meta = buildPageMetadata({
|
|
18
|
+
seo: {
|
|
19
|
+
title: "Q3 Launch Recap",
|
|
20
|
+
siteDefaults: { ogImage: "https://cdn.example/site-social.png" },
|
|
21
|
+
meta: { ogImage: "" },
|
|
22
|
+
},
|
|
23
|
+
path: "/q3",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(og(meta).images).toBe("https://cdn.example/site-social.png");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("prefers the page's own og:image over the site default", () => {
|
|
30
|
+
const meta = buildPageMetadata({
|
|
31
|
+
seo: {
|
|
32
|
+
title: "Q3 Launch Recap",
|
|
33
|
+
siteDefaults: { ogImage: "https://cdn.example/site-social.png" },
|
|
34
|
+
meta: { ogImage: "https://cdn.example/page-hero.png" },
|
|
35
|
+
},
|
|
36
|
+
path: "/q3",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(og(meta).images).toBe("https://cdn.example/page-hero.png");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses the site og:locale when the page leaves it empty", () => {
|
|
43
|
+
const meta = buildPageMetadata({
|
|
44
|
+
seo: { title: "Q3", siteDefaults: { ogLocale: "en_US" }, meta: {} },
|
|
45
|
+
path: "/q3",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(og(meta).locale).toBe("en_US");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("prefers the page's own og:locale over the site default", () => {
|
|
52
|
+
const meta = buildPageMetadata({
|
|
53
|
+
seo: { title: "Q3", siteDefaults: { ogLocale: "en_US" }, meta: { ogLocale: "fr_FR" } },
|
|
54
|
+
path: "/q3",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(og(meta).locale).toBe("fr_FR");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lets an inherited image drive twitter:image and the card style", () => {
|
|
61
|
+
const meta = buildPageMetadata({
|
|
62
|
+
seo: {
|
|
63
|
+
title: "Q3",
|
|
64
|
+
siteDefaults: { ogImage: "https://cdn.example/site-social.png" },
|
|
65
|
+
meta: {},
|
|
66
|
+
},
|
|
67
|
+
path: "/q3",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(tw(meta).images).toBe("https://cdn.example/site-social.png");
|
|
71
|
+
expect(tw(meta).card).toBe("summary_large_image");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("omits the tags when neither tier has a value", () => {
|
|
75
|
+
const meta = buildPageMetadata({ seo: { title: "Q3", meta: {} }, path: "/q3" });
|
|
76
|
+
|
|
77
|
+
expect(og(meta).images).toBeUndefined();
|
|
78
|
+
expect(og(meta).locale).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const appDir = resolve(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
// PCC-3499 / PCC-3513: Puck's collectStyles() copies every parent
|
|
10
|
+
// <style>/<link> element into the canvas-preview iframe verbatim (there is
|
|
11
|
+
// no exclusion API), and separately its CopyHostStyles helper syncs this
|
|
12
|
+
// document's <body> attributes (including `class`) onto the iframe's own
|
|
13
|
+
// <body>. That means a bare `body {...}` rule — or even a `body.some-class
|
|
14
|
+
// {...}` rule scoped to a class placed directly on <body> — still matches
|
|
15
|
+
// inside the canvas iframe and can override the canvas's own
|
|
16
|
+
// design-token-based body styling (as happened with a hardcoded
|
|
17
|
+
// `color: #111; background: #fff;` on the Teamworks dev site).
|
|
18
|
+
//
|
|
19
|
+
// The only reset that stays out of the iframe is one keyed off a *child* of
|
|
20
|
+
// <body> that Puck's canvas never renders (the iframe only ever portals the
|
|
21
|
+
// Puck root/block tree into its own #frame-root — never this layout).
|
|
22
|
+
describe("app/styles.css keeps body-level resets out of Puck's canvas iframe", () => {
|
|
23
|
+
const rawCss = readFileSync(resolve(appDir, "app/styles.css"), "utf-8");
|
|
24
|
+
// Strip comments so example selectors mentioned in explanatory comments
|
|
25
|
+
// don't trip the regexes below.
|
|
26
|
+
const css = rawCss.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
27
|
+
const layout = readFileSync(resolve(appDir, "app/layout.tsx"), "utf-8");
|
|
28
|
+
|
|
29
|
+
it("does not define a bare, unscoped `body { ... }` rule", () => {
|
|
30
|
+
// Matches a `body` selector not immediately followed by a combinator/
|
|
31
|
+
// pseudo-class condition (i.e. a plain element selector with no scoping).
|
|
32
|
+
const bareBodySelector = /(^|[^.\w:-])body\s*\{/m;
|
|
33
|
+
expect(bareBodySelector.test(css)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not scope the reset to a class/attribute placed directly on body", () => {
|
|
37
|
+
// e.g. `body.foo {...}` or `body[data-foo] {...}` — these get defeated
|
|
38
|
+
// by Puck's CopyHostStyles, which syncs body's own attributes into the
|
|
39
|
+
// iframe's body too.
|
|
40
|
+
expect(css).not.toMatch(/body(\.[\w-]+|\[[^\]]+\])\s*\{/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("scopes the margin reset via :has() of a child element", () => {
|
|
44
|
+
expect(css).toMatch(/body:has\(\s*>?\s*\.p1-app-shell\s*\)\s*\{[^}]*margin:\s*0/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("wraps the real app tree in .p1-app-shell, as a child of <body>", () => {
|
|
48
|
+
expect(layout).toMatch(/<body[^>]*>[\s\S]*<div className="p1-app-shell">/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -2,115 +2,117 @@
|
|
|
2
2
|
* Catch-all route that renders user-facing pages generated by Puck.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { cache } from "react";
|
|
5
6
|
import type { Metadata } from "next";
|
|
7
|
+
import { notFound } from "next/navigation";
|
|
6
8
|
import {
|
|
7
9
|
loadRemoteDatasourceContext,
|
|
8
10
|
extractReferencedDatasourceIds,
|
|
9
|
-
getPage,
|
|
10
|
-
listRouteTemplateKeysFromDatabase,
|
|
11
11
|
resolveDataTemplates,
|
|
12
|
-
ensureInitialized,
|
|
13
12
|
pagePathFromCatchAllSegments,
|
|
14
13
|
} from "@pantheon-systems/puck-css/server";
|
|
14
|
+
import {
|
|
15
|
+
createCssQueryFetchers,
|
|
16
|
+
loadPublishedPage,
|
|
17
|
+
loadRouteTemplateKeys,
|
|
18
|
+
} from "@pantheon-systems/p1-next-sdk/server";
|
|
15
19
|
import { REMOTE_DATASOURCE_FETCHERS } from "../../lib/remote-datasource-fetchers";
|
|
20
|
+
import { ContentUnavailable } from "../../components/content-unavailable";
|
|
16
21
|
import { resolvePageMetadata } from "../../lib/page-seo";
|
|
17
22
|
import { Client } from "./client";
|
|
18
23
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const getCssQueryFetchers = cache(() => createCssQueryFetchers());
|
|
25
|
+
|
|
26
|
+
// Document namespaces that live alongside pages but are never routable.
|
|
27
|
+
const INTERNAL_PATH_PREFIXES = ["/_registry", "/_redirects"];
|
|
28
|
+
|
|
29
|
+
// Lowercased to match the server, which normalizes document paths to lower case
|
|
30
|
+
// before looking them up — so /_Redirects/x resolves the same record as
|
|
31
|
+
// /_redirects/x and must be refused just the same.
|
|
32
|
+
function isInternalPath(path: string): boolean {
|
|
33
|
+
const normalized = path.toLowerCase();
|
|
34
|
+
return INTERNAL_PATH_PREFIXES.some(
|
|
35
|
+
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
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
|
+
}
|
|
27
57
|
|
|
28
58
|
export async function generateMetadata({
|
|
29
59
|
params,
|
|
30
|
-
searchParams,
|
|
31
60
|
}: {
|
|
32
61
|
params: Promise<{ puckPath: string[] }>;
|
|
33
|
-
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
34
62
|
}): Promise<Metadata> {
|
|
35
|
-
await initPromise;
|
|
36
63
|
const { puckPath = [] } = await params;
|
|
37
64
|
const path = pagePathFromCatchAllSegments(puckPath);
|
|
38
65
|
|
|
39
|
-
if (
|
|
66
|
+
if (isInternalPath(path)) {
|
|
40
67
|
return { title: "Not Found" };
|
|
41
68
|
}
|
|
42
69
|
|
|
43
|
-
const
|
|
44
|
-
|
|
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 });
|
|
45
76
|
}
|
|
46
77
|
|
|
47
78
|
export default async function Page({
|
|
48
79
|
params,
|
|
49
|
-
searchParams,
|
|
50
80
|
}: {
|
|
51
81
|
params: Promise<{ puckPath: string[] }>;
|
|
52
|
-
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
53
82
|
}) {
|
|
54
|
-
await initPromise;
|
|
55
83
|
const { puckPath = [] } = await params;
|
|
56
84
|
const path = pagePathFromCatchAllSegments(puckPath);
|
|
57
85
|
|
|
58
|
-
if (
|
|
59
|
-
const { notFound } = await import("next/navigation");
|
|
86
|
+
if (isInternalPath(path)) {
|
|
60
87
|
notFound();
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
const
|
|
64
|
-
getPage(path),
|
|
65
|
-
searchParams,
|
|
66
|
-
listRouteTemplateKeysFromDatabase(),
|
|
67
|
-
]);
|
|
90
|
+
const result = await loadPublishedPage(path);
|
|
68
91
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
404 – This page doesn't exist yet
|
|
75
|
-
</h1>
|
|
76
|
-
<p className="mt-4 text-gray-600">
|
|
77
|
-
This page hasn't been created. Use the editor to build it.
|
|
78
|
-
</p>
|
|
79
|
-
|
|
80
|
-
<nav className="mt-10 flex flex-col gap-3">
|
|
81
|
-
<a
|
|
82
|
-
href={`/p1${path}`}
|
|
83
|
-
className="rounded-lg bg-gray-900 px-5 py-3 text-sm font-medium text-white hover:bg-gray-700"
|
|
84
|
-
>
|
|
85
|
-
Edit this page
|
|
86
|
-
</a>
|
|
87
|
-
<a
|
|
88
|
-
href="/p1"
|
|
89
|
-
className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
|
|
90
|
-
>
|
|
91
|
-
Open the Page Editor
|
|
92
|
-
</a>
|
|
93
|
-
<a
|
|
94
|
-
href={`${process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL || "https://content.pantheon.io"}/dashboard/sites`}
|
|
95
|
-
target="_blank"
|
|
96
|
-
rel="noopener noreferrer"
|
|
97
|
-
className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
|
|
98
|
-
>
|
|
99
|
-
P1 Dashboard →
|
|
100
|
-
</a>
|
|
101
|
-
</nav>
|
|
102
|
-
</div>
|
|
103
|
-
</main>
|
|
104
|
-
);
|
|
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();
|
|
105
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];
|
|
106
109
|
|
|
107
110
|
const referencedDatasourceIds = extractReferencedDatasourceIds(data);
|
|
108
111
|
const context = await loadRemoteDatasourceContext({
|
|
109
|
-
searchParams: searchParamData,
|
|
110
112
|
fetchImpl: fetch,
|
|
111
113
|
pagePath: path,
|
|
112
114
|
routeTemplateKeys,
|
|
113
|
-
builtinFetchers
|
|
115
|
+
builtinFetchers,
|
|
114
116
|
referencedDatasourceIds,
|
|
115
117
|
});
|
|
116
118
|
const resolvedData = await resolveDataTemplates(data, context);
|
|
@@ -126,5 +128,3 @@ export default async function Page({
|
|
|
126
128
|
/>
|
|
127
129
|
);
|
|
128
130
|
}
|
|
129
|
-
|
|
130
|
-
export const dynamic = "force-dynamic";
|
package/template/app/layout.tsx
CHANGED
|
@@ -21,7 +21,17 @@ export default function RootLayout({
|
|
|
21
21
|
}) {
|
|
22
22
|
return (
|
|
23
23
|
<html lang="en">
|
|
24
|
-
|
|
24
|
+
{/* Puck's canvas-preview iframe copies this document's <body> attributes
|
|
25
|
+
onto its own iframe <body> (@puckeditor/core's CopyHostStyles
|
|
26
|
+
syncAttributes()), so a class/attribute placed directly on <body>
|
|
27
|
+
cannot be used to keep a rule out of the iframe. `.p1-app-shell` is
|
|
28
|
+
a child of <body> instead — Puck's canvas iframe never contains it
|
|
29
|
+
(the iframe only ever renders the Puck root/block tree into its own
|
|
30
|
+
#frame-root, not this layout). See styles.css for the scoped reset
|
|
31
|
+
this enables. */}
|
|
32
|
+
<body data-rm-theme="light">
|
|
33
|
+
<div className="p1-app-shell">{children}</div>
|
|
34
|
+
</body>
|
|
25
35
|
</html>
|
|
26
36
|
);
|
|
27
37
|
}
|
|
@@ -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
|
+
}
|
|
@@ -9,10 +9,13 @@ import {
|
|
|
9
9
|
useP1Editor,
|
|
10
10
|
useP1Plugins,
|
|
11
11
|
useP1Auth,
|
|
12
|
+
useEditorContext,
|
|
13
|
+
useRemoteDatasourceContext,
|
|
12
14
|
wrapConfigForEditorPreview,
|
|
13
15
|
P1QueryProvider,
|
|
14
16
|
editorPathHref,
|
|
15
17
|
} from "@pantheon-systems/puck-css";
|
|
18
|
+
import { DatasourceRegistryProvider, DatasourceDataProvider } from "@pantheon-systems/puck-css/fields";
|
|
16
19
|
import { LoadingMessage } from "@pantheon-systems/puck-css/pds";
|
|
17
20
|
import { P1NextRouterProvider, editorPagePathFromUrlPath } from "@pantheon-systems/p1-next-sdk";
|
|
18
21
|
import { createAIChatPlugin } from "@pantheon-systems/p1-ai-chat";
|
|
@@ -29,6 +32,8 @@ import { ChatbotFlagProvider } from "../../../../components/ChatbotFlagProvider"
|
|
|
29
32
|
import { P1Lockup } from "../../../../components/p1-lockup";
|
|
30
33
|
import config from "../../../../puck.config";
|
|
31
34
|
import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "../../../../lib/chatbot-flag/feature-gate";
|
|
35
|
+
import { createGenerateWithAIHandler } from "../../../../lib/chatbot-flag/ai-generate";
|
|
36
|
+
import { getDraftRequestChannel } from "../../../../lib/chatbot-flag/draft-request-channel";
|
|
32
37
|
|
|
33
38
|
const DEFAULT_PAGE_DATA = {
|
|
34
39
|
root: { props: { title: "New page" } },
|
|
@@ -210,17 +215,31 @@ function EditorContent({
|
|
|
210
215
|
}) {
|
|
211
216
|
const router = useRouter();
|
|
212
217
|
const { getToken } = useP1Auth();
|
|
218
|
+
const { data: editorCtx } = useEditorContext(path);
|
|
219
|
+
const {
|
|
220
|
+
context: remoteDatasourceContext,
|
|
221
|
+
} = useRemoteDatasourceContext(path, editorCtx?.remoteDatasourceRegistry ?? []);
|
|
213
222
|
const p1Plugins = useP1Plugins(path, config);
|
|
214
223
|
const mediaPlugin = React.useMemo(() => createMediaPlugin({}), []);
|
|
215
224
|
const flags = useFlags();
|
|
216
225
|
const agentUrl = process.env.NEXT_PUBLIC_AGENT_URL;
|
|
217
226
|
const chatbotEnabled = shouldShowChatbot(flags[CHATBOT_FLAG_KEY], agentUrl);
|
|
227
|
+
// Singleton: survives the remount caused by navigating to the new page.
|
|
228
|
+
const draftRequests = getDraftRequestChannel();
|
|
229
|
+
// The agent creates the page it was asked for, so the editor follows it there. Also what
|
|
230
|
+
// keeps later turns aimed at the new page: their context is built from the open document.
|
|
231
|
+
const handlePageCreated = useCallback(
|
|
232
|
+
(createdPath: string) => {
|
|
233
|
+
router.push(editorPathHref(createdPath));
|
|
234
|
+
},
|
|
235
|
+
[router],
|
|
236
|
+
);
|
|
218
237
|
const aiPlugin = React.useMemo(
|
|
219
238
|
() =>
|
|
220
239
|
chatbotEnabled && agentUrl
|
|
221
|
-
? createAIChatPlugin({ agentUrl })
|
|
240
|
+
? createAIChatPlugin({ agentUrl, draftRequests, onPageCreated: handlePageCreated })
|
|
222
241
|
: null,
|
|
223
|
-
[chatbotEnabled, agentUrl],
|
|
242
|
+
[chatbotEnabled, agentUrl, draftRequests, handlePageCreated],
|
|
224
243
|
);
|
|
225
244
|
const additionalPlugins = React.useMemo(
|
|
226
245
|
() => (aiPlugin ? [...p1Plugins, mediaPlugin, aiPlugin] : [...p1Plugins, mediaPlugin]),
|
|
@@ -268,6 +287,8 @@ function EditorContent({
|
|
|
268
287
|
onDocumentNotFound: handleDocumentNotFound,
|
|
269
288
|
pluginOptions: {
|
|
270
289
|
onDocumentSelect: handleDocumentSelect,
|
|
290
|
+
onGenerateWithAI: createGenerateWithAIHandler(draftRequests, chatbotEnabled),
|
|
291
|
+
showAIPanelToggle: chatbotEnabled,
|
|
271
292
|
selectedDocumentPath: path,
|
|
272
293
|
siteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
|
|
273
294
|
dashboardUrl: process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL,
|
|
@@ -357,8 +378,12 @@ function EditorContent({
|
|
|
357
378
|
`}</style>
|
|
358
379
|
</div>
|
|
359
380
|
)}
|
|
360
|
-
{
|
|
361
|
-
|
|
381
|
+
<DatasourceRegistryProvider registry={editorCtx?.remoteDatasourceRegistry ?? []}>
|
|
382
|
+
<DatasourceDataProvider context={remoteDatasourceContext}>
|
|
383
|
+
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
384
|
+
<Puck key={`${displayState.puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...displayState.puckProps as any} _experimentalFullScreenCanvas={true} />
|
|
385
|
+
</DatasourceDataProvider>
|
|
386
|
+
</DatasourceRegistryProvider>
|
|
362
387
|
</div>
|
|
363
388
|
);
|
|
364
389
|
}
|