@pantheon-systems/create-p1-starter-kit 0.7.0 → 0.10.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 (69) 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 +14 -5
  5. package/template/.env.example +33 -6
  6. package/template/CHANGELOG.md +20 -0
  7. package/template/__tests__/ai-generate.test.ts +74 -0
  8. package/template/__tests__/auth-route.test.ts +1 -1
  9. package/template/__tests__/chatbot-flag-wiring.test.ts +22 -2
  10. package/template/__tests__/editor-integration.test.ts +2 -2
  11. package/template/__tests__/editor-route-group.test.ts +44 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
  13. package/template/__tests__/page-seo-meta.test.ts +98 -0
  14. package/template/__tests__/page-seo.test.ts +127 -0
  15. package/template/__tests__/paragraph-block.test.ts +39 -0
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  18. package/template/__tests__/puck-root-meta.test.ts +102 -0
  19. package/template/__tests__/puck-root-selects.test.ts +86 -0
  20. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  21. package/template/__tests__/sanitize-richtext.test.ts +58 -0
  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__/seo-metadata.test.ts +105 -0
  25. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  26. package/template/app/[...puckPath]/page.tsx +27 -30
  27. package/template/app/layout.tsx +25 -1
  28. package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +49 -25
  29. package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
  30. package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
  31. package/template/app/p1/(editor)/layout.tsx +13 -0
  32. package/template/app/page.tsx +3 -21
  33. package/template/app/styles.css +19 -1
  34. package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
  35. package/template/components/p1-lockup.tsx +6 -26
  36. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  37. package/template/components/puck/data-list-block/index.ts +1 -0
  38. package/template/components/puck/grid-block.tsx +1 -1
  39. package/template/components/puck/media-figure-block.tsx +12 -0
  40. package/template/components/puck/paragraph-block.tsx +18 -33
  41. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  42. package/template/components/puck/paragraph-markdown.tsx +15 -0
  43. package/template/components/puck/root.tsx +185 -4
  44. package/template/components/puck/sanitize-richtext.ts +44 -0
  45. package/template/constants/assets.ts +1 -0
  46. package/template/eslint.config.js +20 -4
  47. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  48. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  49. package/template/lib/monsters-api.ts +14 -8
  50. package/template/lib/page-seo.ts +112 -0
  51. package/template/lib/remote-datasources.ts +26 -31
  52. package/template/lib/seo-metadata.consts.ts +21 -0
  53. package/template/lib/seo-metadata.ts +129 -0
  54. package/template/lib/swapi.ts +5 -5
  55. package/template/middleware.ts +15 -0
  56. package/template/next.config.mjs +11 -0
  57. package/template/package.json +17 -10
  58. package/template/pnpm-workspace.yaml +6 -0
  59. package/template/public/images/p1_logo.svg +5 -12
  60. package/template/public/images/p1_logo_reverse.svg +5 -0
  61. package/template/puck.config.tsx +6 -2
  62. package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
  63. package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
  64. package/template/scripts/asset-stub-hooks.mjs +25 -1
  65. package/template/scripts/sync-puck-registry.ts +84 -7
  66. package/template/tsconfig/nextjs.json +1 -1
  67. package/template/tsconfig.test.json +6 -0
  68. package/template/vitest.config.ts +25 -0
  69. package/template/next-env.d.ts +0 -6
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sanitizeRichtextHtml } from "../components/puck/sanitize-richtext";
3
+
4
+ describe("sanitizeRichtextHtml", () => {
5
+ it("strips <script> tags", () => {
6
+ const out = sanitizeRichtextHtml('<p>hi</p><script>alert(1)</script>');
7
+ expect(out).not.toContain("<script");
8
+ expect(out).not.toContain("alert(1)");
9
+ expect(out).toContain("<p>hi</p>");
10
+ });
11
+
12
+ it("drops javascript: hrefs but keeps the link text", () => {
13
+ const out = sanitizeRichtextHtml('<a href="javascript:alert(1)">click</a>');
14
+ expect(out.toLowerCase()).not.toContain("javascript:");
15
+ expect(out).toContain("click");
16
+ });
17
+
18
+ it("drops data: hrefs", () => {
19
+ const out = sanitizeRichtextHtml(
20
+ '<a href="data:text/html,<script>alert(1)</script>">x</a>',
21
+ );
22
+ expect(out.toLowerCase()).not.toContain("data:");
23
+ expect(out.toLowerCase()).not.toContain("<script");
24
+ });
25
+
26
+ it("removes <img> and its onerror handler entirely", () => {
27
+ const out = sanitizeRichtextHtml('<img src="x" onerror="alert(1)">');
28
+ expect(out.toLowerCase()).not.toContain("<img");
29
+ expect(out.toLowerCase()).not.toContain("onerror");
30
+ });
31
+
32
+ it("strips inline event-handler attributes", () => {
33
+ const out = sanitizeRichtextHtml('<p onclick="steal()">text</p>');
34
+ expect(out.toLowerCase()).not.toContain("onclick");
35
+ expect(out).toContain("text");
36
+ });
37
+
38
+ it("preserves safe formatting, lists, and https links", () => {
39
+ const input =
40
+ '<p><strong>bold</strong> and <em>italic</em></p>' +
41
+ '<ul><li>one</li><li>two</li></ul>' +
42
+ '<a href="https://example.com">safe link</a>';
43
+ const out = sanitizeRichtextHtml(input);
44
+ expect(out).toContain("<strong>bold</strong>");
45
+ expect(out).toContain("<em>italic</em>");
46
+ expect(out).toContain("<li>one</li>");
47
+ expect(out).toContain('href="https://example.com"');
48
+ });
49
+
50
+ it("keeps relative and anchor hrefs", () => {
51
+ expect(sanitizeRichtextHtml('<a href="/about">a</a>')).toContain(
52
+ 'href="/about"',
53
+ );
54
+ expect(sanitizeRichtextHtml('<a href="#section">a</a>')).toContain(
55
+ 'href="#section"',
56
+ );
57
+ });
58
+ });
@@ -0,0 +1,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,105 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { buildPageMetadata } from "../lib/seo-metadata";
3
+
4
+ /**
5
+ * PCC-3407: buildPageMetadata maps the head metadata inputs (client-derived
6
+ * title/description/canonical plus the backend-supplied siteName) onto the
7
+ * Next.js Metadata object that renders the per-page <head> tags. Next replaces
8
+ * (not deep-merges) a page's openGraph over the layout's, so og:type and the
9
+ * env og:site_name fallback are declared here rather than relying on the
10
+ * layout. A relative canonical is emitted only when NEXT_PUBLIC_SITE_URL is
11
+ * configured to resolve it — a wrong (localhost) canonical is worse than none.
12
+ */
13
+ describe("buildPageMetadata", () => {
14
+ afterEach(() => vi.unstubAllEnvs());
15
+
16
+ it("maps a full SeoMetadata onto title/description/canonical/OG tags", () => {
17
+ const meta = buildPageMetadata({
18
+ seo: {
19
+ title: "About Our Team",
20
+ description: "Meet the people behind the product.",
21
+ canonicalUrl: "https://content.public.url/about/team",
22
+ siteName: "Acme Docs",
23
+ },
24
+ path: "/about/team",
25
+ });
26
+
27
+ expect(meta.title).toBe("About Our Team");
28
+ expect(meta.description).toBe("Meet the people behind the product.");
29
+ // Absolute canonicalUrl is used verbatim for canonical + og:url.
30
+ expect(meta.alternates?.canonical).toBe(
31
+ "https://content.public.url/about/team",
32
+ );
33
+ expect(meta.openGraph?.url).toBe("https://content.public.url/about/team");
34
+ expect(meta.openGraph?.title).toBe("About Our Team");
35
+ expect(meta.openGraph?.description).toBe(
36
+ "Meet the people behind the product.",
37
+ );
38
+ expect((meta.openGraph as { siteName?: string }).siteName).toBe(
39
+ "Acme Docs",
40
+ );
41
+ expect((meta.openGraph as { type?: string }).type).toBe("website");
42
+ });
43
+
44
+ it("falls back to the relative path when canonicalUrl is absent and a site URL is configured", () => {
45
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://site.example");
46
+ const meta = buildPageMetadata({
47
+ seo: { title: "No Canonical" },
48
+ path: "/no-canonical",
49
+ });
50
+
51
+ expect(meta.alternates?.canonical).toBe("/no-canonical");
52
+ expect(meta.openGraph?.url).toBe("/no-canonical");
53
+ });
54
+
55
+ it("omits canonical and og:url when neither canonicalUrl nor a site URL exists", () => {
56
+ const meta = buildPageMetadata({
57
+ seo: { title: "No Canonical" },
58
+ path: "/no-canonical",
59
+ });
60
+
61
+ expect(meta.alternates).toBeUndefined();
62
+ expect((meta.openGraph as { url?: string }).url).toBeUndefined();
63
+ });
64
+
65
+ it("falls back to the env site name when siteName is absent", () => {
66
+ vi.stubEnv("NEXT_PUBLIC_SITE_NAME", "Env Site");
67
+ const meta = buildPageMetadata({
68
+ seo: { title: "No Site Name" },
69
+ path: "/x",
70
+ });
71
+
72
+ expect((meta.openGraph as { siteName?: string }).siteName).toBe(
73
+ "Env Site",
74
+ );
75
+ });
76
+
77
+ it("omits og:site_name when siteName and the env fallback are both absent", () => {
78
+ const meta = buildPageMetadata({
79
+ seo: { title: "No Site Name" },
80
+ path: "/x",
81
+ });
82
+
83
+ expect((meta.openGraph as { siteName?: string }).siteName).toBeUndefined();
84
+ });
85
+
86
+ it("treats an empty title as absent", () => {
87
+ const meta = buildPageMetadata({
88
+ seo: { title: "" },
89
+ path: "/x",
90
+ });
91
+
92
+ expect(meta.title).toBeUndefined();
93
+ expect((meta.openGraph as { title?: string }).title).toBeUndefined();
94
+ });
95
+
96
+ it("handles missing seo entirely (og:type still emitted, canonical omitted)", () => {
97
+ const meta = buildPageMetadata({ path: "/" });
98
+
99
+ expect(meta.title).toBeUndefined();
100
+ expect(meta.description).toBeUndefined();
101
+ expect(meta.alternates).toBeUndefined();
102
+ expect((meta.openGraph as { url?: string }).url).toBeUndefined();
103
+ expect((meta.openGraph as { type?: string }).type).toBe("website");
104
+ });
105
+ });
@@ -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,19 +2,36 @@
2
2
  * Catch-all route that renders user-facing pages generated by Puck.
3
3
  */
4
4
 
5
- import { Client } from "./client";
6
- import { Metadata } from "next";
5
+ import { cache } from "react";
6
+ import type { Metadata } from "next";
7
7
  import {
8
8
  loadRemoteDatasourceContext,
9
9
  extractReferencedDatasourceIds,
10
10
  getPage,
11
11
  listRouteTemplateKeysFromDatabase,
12
12
  resolveDataTemplates,
13
- resolveStringTemplates,
14
13
  ensureInitialized,
15
14
  pagePathFromCatchAllSegments,
16
15
  } from "@pantheon-systems/puck-css/server";
16
+ import { createCssQueryFetchers } from "@pantheon-systems/p1-next-sdk/server";
17
17
  import { REMOTE_DATASOURCE_FETCHERS } from "../../lib/remote-datasource-fetchers";
18
+ import { resolvePageMetadata } from "../../lib/page-seo";
19
+ import { Client } from "./client";
20
+
21
+ const getCssQueryFetchers = cache(() => createCssQueryFetchers());
22
+
23
+ // Document namespaces that live alongside pages but are never routable.
24
+ const INTERNAL_PATH_PREFIXES = ["/_registry", "/_redirects"];
25
+
26
+ // Lowercased to match the server, which normalizes document paths to lower case
27
+ // before looking them up — so /_Redirects/x resolves the same record as
28
+ // /_redirects/x and must be refused just the same.
29
+ function isInternalPath(path: string): boolean {
30
+ const normalized = path.toLowerCase();
31
+ return INTERNAL_PATH_PREFIXES.some(
32
+ (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
33
+ );
34
+ }
18
35
 
19
36
  const initPromise = ensureInitialized({
20
37
  p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
@@ -36,33 +53,12 @@ export async function generateMetadata({
36
53
  const { puckPath = [] } = await params;
37
54
  const path = pagePathFromCatchAllSegments(puckPath);
38
55
 
39
- if (path.startsWith("/_registry/") || path === "/_registry") {
56
+ if (isInternalPath(path)) {
40
57
  return { title: "Not Found" };
41
58
  }
42
59
 
43
- const [pageData, sp, routeTemplateKeys] = await Promise.all([
44
- getPage(path),
45
- searchParams,
46
- listRouteTemplateKeysFromDatabase(),
47
- ]);
48
-
49
- const rawTitle = pageData?.root.props?.title;
50
- if (typeof rawTitle !== "string") {
51
- return { title: rawTitle };
52
- }
53
- if (!rawTitle.includes("{{")) {
54
- return { title: rawTitle };
55
- }
56
- const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
57
- const context = await loadRemoteDatasourceContext({
58
- searchParams: sp,
59
- fetchImpl: fetch,
60
- pagePath: path,
61
- routeTemplateKeys,
62
- builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
63
- referencedDatasourceIds,
64
- });
65
- return { title: await resolveStringTemplates(rawTitle, context) };
60
+ const [pageData, sp] = await Promise.all([getPage(path), searchParams]);
61
+ return resolvePageMetadata({ pageData, path, searchParams: sp });
66
62
  }
67
63
 
68
64
  export default async function Page({
@@ -76,15 +72,16 @@ export default async function Page({
76
72
  const { puckPath = [] } = await params;
77
73
  const path = pagePathFromCatchAllSegments(puckPath);
78
74
 
79
- if (path.startsWith("/_registry/") || path === "/_registry") {
75
+ if (isInternalPath(path)) {
80
76
  const { notFound } = await import("next/navigation");
81
77
  notFound();
82
78
  }
83
79
 
84
- const [data, searchParamData, routeTemplateKeys] = await Promise.all([
80
+ const [data, searchParamData, routeTemplateKeys, cssQueryFetchers] = await Promise.all([
85
81
  getPage(path),
86
82
  searchParams,
87
83
  listRouteTemplateKeysFromDatabase(),
84
+ getCssQueryFetchers(),
88
85
  ]);
89
86
 
90
87
  if (!data) {
@@ -131,7 +128,7 @@ export default async function Page({
131
128
  fetchImpl: fetch,
132
129
  pagePath: path,
133
130
  routeTemplateKeys,
134
- builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
131
+ builtinFetchers: [...REMOTE_DATASOURCE_FETCHERS, ...cssQueryFetchers],
135
132
  referencedDatasourceIds,
136
133
  });
137
134
  const resolvedData = await resolveDataTemplates(data, context);
@@ -1,4 +1,18 @@
1
1
  import "./styles.css";
2
+ import type { Metadata } from "next";
3
+
4
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
5
+
6
+ // A page-level openGraph replaces (not merges) this one, so buildPageMetadata
7
+ // re-declares og:type and the env site-name fallback; this covers routes that
8
+ // return no openGraph (e.g. not-found early returns).
9
+ export const metadata: Metadata = {
10
+ ...(siteUrl ? { metadataBase: new URL(siteUrl) } : {}),
11
+ openGraph: {
12
+ type: "website",
13
+ siteName: process.env.NEXT_PUBLIC_SITE_NAME,
14
+ },
15
+ };
2
16
 
3
17
  export default function RootLayout({
4
18
  children,
@@ -7,7 +21,17 @@ export default function RootLayout({
7
21
  }) {
8
22
  return (
9
23
  <html lang="en">
10
- <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>
11
35
  </html>
12
36
  );
13
37
  }