@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.
Files changed (61) hide show
  1. package/README.md +114 -0
  2. package/lib/cli.js +1 -1
  3. package/lib/messages.js +1 -1
  4. package/package.json +11 -5
  5. package/template/.env.example +27 -6
  6. package/template/__tests__/ai-generate.test.ts +74 -0
  7. package/template/__tests__/auth-route.test.ts +1 -1
  8. package/template/__tests__/chatbot-flag-wiring.test.ts +21 -1
  9. package/template/__tests__/editor-integration.test.ts +1 -1
  10. package/template/__tests__/editor-route-group.test.ts +1 -1
  11. package/template/__tests__/image-block.test.tsx +48 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +145 -0
  13. package/template/__tests__/page-seo-meta.test.ts +97 -0
  14. package/template/__tests__/page-seo.test.ts +4 -6
  15. package/template/__tests__/paragraph-block.test.ts +24 -22
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/published-page-404.test.ts +65 -0
  18. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  19. package/template/__tests__/puck-root-meta.test.ts +102 -0
  20. package/template/__tests__/puck-root-selects.test.ts +86 -0
  21. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  22. package/template/__tests__/seo-metadata-meta.test.ts +180 -0
  23. package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
  24. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  25. package/template/app/[...puckPath]/page.tsx +67 -67
  26. package/template/app/layout.tsx +11 -1
  27. package/template/app/not-found.tsx +11 -0
  28. package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
  29. package/template/app/page.tsx +20 -22
  30. package/template/app/styles.css +18 -1
  31. package/template/components/content-unavailable.tsx +22 -0
  32. package/template/components/p1-lockup.tsx +6 -26
  33. package/template/components/page-missing.tsx +49 -0
  34. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  35. package/template/components/puck/data-list-block/index.ts +1 -0
  36. package/template/components/puck/grid-block.tsx +1 -1
  37. package/template/components/puck/image-block.tsx +22 -1
  38. package/template/components/puck/paragraph-block.tsx +7 -2
  39. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  40. package/template/components/puck/paragraph-markdown.tsx +15 -0
  41. package/template/components/puck/root.tsx +185 -4
  42. package/template/constants/assets.ts +1 -0
  43. package/template/eslint.config.js +20 -4
  44. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  45. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  46. package/template/lib/monsters-api.ts +14 -8
  47. package/template/lib/page-seo.ts +46 -16
  48. package/template/lib/remote-datasources.ts +26 -31
  49. package/template/lib/seo-metadata.consts.ts +21 -0
  50. package/template/lib/seo-metadata.ts +96 -15
  51. package/template/lib/swapi.ts +5 -5
  52. package/template/middleware.ts +15 -0
  53. package/template/next.config.mjs +11 -0
  54. package/template/package.json +15 -12
  55. package/template/pnpm-workspace.yaml +3 -0
  56. package/template/public/images/p1_logo.svg +5 -12
  57. package/template/public/images/p1_logo_reverse.svg +5 -0
  58. package/template/puck.config.tsx +3 -1
  59. package/template/tsconfig/nextjs.json +1 -1
  60. package/template/vitest.config.ts +20 -0
  61. 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 initPromise = ensureInitialized({
20
- p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
21
- p1ApiKey: process.env.CSS_API_KEY,
22
- p1SiteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
23
- // Default to "main" when unset: server components (no user token) need a
24
- // branch to list/read documents (e.g. the /structure routes table).
25
- p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID ?? "main",
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 (path.startsWith("/_registry/") || path === "/_registry") {
66
+ if (isInternalPath(path)) {
40
67
  return { title: "Not Found" };
41
68
  }
42
69
 
43
- const [pageData, sp] = await Promise.all([getPage(path), searchParams]);
44
- 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 });
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 (path.startsWith("/_registry/") || path === "/_registry") {
59
- const { notFound } = await import("next/navigation");
86
+ if (isInternalPath(path)) {
60
87
  notFound();
61
88
  }
62
89
 
63
- const [data, searchParamData, routeTemplateKeys] = await Promise.all([
64
- getPage(path),
65
- searchParams,
66
- listRouteTemplateKeysFromDatabase(),
67
- ]);
90
+ const result = await loadPublishedPage(path);
68
91
 
69
- if (!data) {
70
- return (
71
- <main className="flex min-h-screen items-center justify-center bg-gray-50">
72
- <div className="mx-auto max-w-lg px-6 py-16 text-center">
73
- <h1 className="text-3xl font-bold tracking-tight text-gray-900">
74
- 404 &ndash; This page doesn&apos;t exist yet
75
- </h1>
76
- <p className="mt-4 text-gray-600">
77
- This page hasn&apos;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 &rarr;
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: REMOTE_DATASOURCE_FETCHERS,
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";
@@ -21,7 +21,17 @@ export default function RootLayout({
21
21
  }) {
22
22
  return (
23
23
  <html lang="en">
24
- <body data-rm-theme="light">{children}</body>
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
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
361
- <Puck key={`${displayState.puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...displayState.puckProps as any} _experimentalFullScreenCanvas={true} />
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
  }