@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.
- package/README.md +114 -0
- package/lib/cli.js +1 -1
- package/lib/messages.js +1 -1
- package/package.json +14 -5
- package/template/.env.example +33 -6
- package/template/CHANGELOG.md +20 -0
- 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 +22 -2
- package/template/__tests__/editor-integration.test.ts +2 -2
- package/template/__tests__/editor-route-group.test.ts +44 -0
- package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
- package/template/__tests__/page-seo-meta.test.ts +98 -0
- package/template/__tests__/page-seo.test.ts +127 -0
- package/template/__tests__/paragraph-block.test.ts +39 -0
- package/template/__tests__/paragraph-editor-text.test.ts +79 -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__/sanitize-richtext.test.ts +58 -0
- package/template/__tests__/seo-metadata-meta.test.ts +180 -0
- package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
- package/template/__tests__/seo-metadata.test.ts +105 -0
- package/template/__tests__/styles-canvas-scope.test.ts +50 -0
- package/template/app/[...puckPath]/page.tsx +27 -30
- package/template/app/layout.tsx +25 -1
- package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +49 -25
- package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
- package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
- package/template/app/p1/(editor)/layout.tsx +13 -0
- package/template/app/page.tsx +3 -21
- package/template/app/styles.css +19 -1
- package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
- package/template/components/p1-lockup.tsx +6 -26
- 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/media-figure-block.tsx +12 -0
- package/template/components/puck/paragraph-block.tsx +18 -33
- 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/components/puck/sanitize-richtext.ts +44 -0
- 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 +112 -0
- package/template/lib/remote-datasources.ts +26 -31
- package/template/lib/seo-metadata.consts.ts +21 -0
- package/template/lib/seo-metadata.ts +129 -0
- package/template/lib/swapi.ts +5 -5
- package/template/middleware.ts +15 -0
- package/template/next.config.mjs +11 -0
- package/template/package.json +17 -10
- package/template/pnpm-workspace.yaml +6 -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 +6 -2
- package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
- package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
- package/template/scripts/asset-stub-hooks.mjs +25 -1
- package/template/scripts/sync-puck-registry.ts +84 -7
- package/template/tsconfig/nextjs.json +1 -1
- package/template/tsconfig.test.json +6 -0
- package/template/vitest.config.ts +25 -0
- 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 {
|
|
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 (
|
|
56
|
+
if (isInternalPath(path)) {
|
|
40
57
|
return { title: "Not Found" };
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
const [pageData, sp
|
|
44
|
-
|
|
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 (
|
|
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);
|
package/template/app/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|