@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
|
@@ -6,8 +6,11 @@ vi.mock("../lib/remote-datasource-fetchers", () => ({
|
|
|
6
6
|
REMOTE_DATASOURCE_FETCHERS: {},
|
|
7
7
|
}));
|
|
8
8
|
|
|
9
|
+
vi.mock("@pantheon-systems/p1-next-sdk/server", () => ({
|
|
10
|
+
loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
|
|
11
|
+
}));
|
|
12
|
+
|
|
9
13
|
vi.mock("@pantheon-systems/puck-css/server", () => ({
|
|
10
|
-
listRouteTemplateKeysFromDatabase: vi.fn().mockResolvedValue([]),
|
|
11
14
|
extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
|
|
12
15
|
loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
|
|
13
16
|
resolveStringTemplates: vi.fn(async (input: string) =>
|
|
@@ -36,7 +39,6 @@ describe("resolvePageMetadata", () => {
|
|
|
36
39
|
_seo: { siteName: "Acme Docs" },
|
|
37
40
|
}),
|
|
38
41
|
path: "/about/team",
|
|
39
|
-
searchParams: {},
|
|
40
42
|
});
|
|
41
43
|
|
|
42
44
|
expect(meta.title).toBe("About Our Team");
|
|
@@ -62,7 +64,6 @@ describe("resolvePageMetadata", () => {
|
|
|
62
64
|
_seo: { siteName: "Acme Docs" },
|
|
63
65
|
}),
|
|
64
66
|
path: "/untitled",
|
|
65
|
-
searchParams: {},
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
expect(meta.title).toBeUndefined();
|
|
@@ -81,7 +82,6 @@ describe("resolvePageMetadata", () => {
|
|
|
81
82
|
description: "Welcome, {{name}}!",
|
|
82
83
|
}),
|
|
83
84
|
path: "/greet",
|
|
84
|
-
searchParams: {},
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
expect(meta.title).toBe("Hello World");
|
|
@@ -103,7 +103,6 @@ describe("resolvePageMetadata", () => {
|
|
|
103
103
|
},
|
|
104
104
|
}),
|
|
105
105
|
path: "/untitled",
|
|
106
|
-
searchParams: {},
|
|
107
106
|
});
|
|
108
107
|
|
|
109
108
|
expect(meta.title).toBe("Root Title");
|
|
@@ -116,7 +115,6 @@ describe("resolvePageMetadata", () => {
|
|
|
116
115
|
const meta = await resolvePageMetadata({
|
|
117
116
|
pageData: null,
|
|
118
117
|
path: "/",
|
|
119
|
-
searchParams: {},
|
|
120
118
|
});
|
|
121
119
|
|
|
122
120
|
expect(meta.title).toBeUndefined();
|
|
@@ -1,37 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
1
4
|
import { describe, expect, it } from "vitest";
|
|
2
5
|
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const appDir = resolve(__dirname, "..");
|
|
8
|
+
const content = readFileSync(
|
|
9
|
+
resolve(appDir, "components/puck/paragraph-block.tsx"),
|
|
10
|
+
"utf-8",
|
|
11
|
+
);
|
|
12
|
+
|
|
3
13
|
describe("paragraphBlock", () => {
|
|
4
|
-
it("
|
|
5
|
-
|
|
6
|
-
expect(
|
|
14
|
+
it("uses richtextField for the text field", () => {
|
|
15
|
+
expect(content).toContain("richtextField");
|
|
16
|
+
expect(content).toMatch(/text:\s*richtextField/);
|
|
7
17
|
});
|
|
8
18
|
|
|
9
|
-
it("
|
|
10
|
-
const
|
|
11
|
-
expect(
|
|
19
|
+
it("exports paragraphBlock with defaultProps", () => {
|
|
20
|
+
expect(content).toContain("export const paragraphBlock");
|
|
21
|
+
expect(content).toContain("defaultProps");
|
|
12
22
|
});
|
|
13
23
|
|
|
14
|
-
it("
|
|
15
|
-
|
|
16
|
-
const ai = (paragraphBlock.fields.text as any).ai;
|
|
17
|
-
expect(ai).toBeDefined();
|
|
18
|
-
expect(typeof ai?.instructions).toBe("string");
|
|
19
|
-
expect(ai?.instructions.length).toBeGreaterThan(0);
|
|
24
|
+
it("provides a render function", () => {
|
|
25
|
+
expect(content).toMatch(/render:\s*\(/);
|
|
20
26
|
});
|
|
21
27
|
|
|
22
|
-
it("
|
|
23
|
-
|
|
24
|
-
expect(typeof (paragraphBlock.fields.text as any).renderMenu).toBe("function");
|
|
28
|
+
it("wraps editor text in ParagraphEditorText for template preview", () => {
|
|
29
|
+
expect(content).toContain("ParagraphEditorText");
|
|
25
30
|
});
|
|
26
31
|
|
|
27
|
-
it("
|
|
28
|
-
|
|
29
|
-
expect(typeof paragraphBlock.defaultProps.text).toBe("string");
|
|
30
|
-
expect(paragraphBlock.defaultProps.text.length).toBeGreaterThan(0);
|
|
32
|
+
it("sanitizes richtext HTML for published rendering", () => {
|
|
33
|
+
expect(content).toContain("sanitizeRichtextHtml");
|
|
31
34
|
});
|
|
32
35
|
|
|
33
|
-
it("
|
|
34
|
-
|
|
35
|
-
expect(typeof paragraphBlock.render).toBe("function");
|
|
36
|
+
it("has a default text prop", () => {
|
|
37
|
+
expect(content).toMatch(/text:\s*["']/);
|
|
36
38
|
});
|
|
37
39
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, existsSync } 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
|
+
const componentPath = resolve(
|
|
9
|
+
appDir,
|
|
10
|
+
"components/puck/paragraph-editor-text.tsx",
|
|
11
|
+
);
|
|
12
|
+
const paragraphPath = resolve(appDir, "components/puck/paragraph-block.tsx");
|
|
13
|
+
|
|
14
|
+
describe("ParagraphEditorText component", () => {
|
|
15
|
+
it("exists as a separate file", () => {
|
|
16
|
+
expect(existsSync(componentPath)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is a client component", () => {
|
|
20
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
21
|
+
expect(content).toMatch(/^["']use client["']/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("imports useResolvedPreviewState from puck-css", () => {
|
|
25
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
26
|
+
expect(content).toContain("useResolvedPreviewState");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("imports getBlockPropsById from puck-css", () => {
|
|
30
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
31
|
+
expect(content).toContain("getBlockPropsById");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("extracts raw text from element props to detect template tokens", () => {
|
|
35
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
36
|
+
expect(content).toContain("extractRawText");
|
|
37
|
+
expect(content).toMatch(/\{\{/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("tracks focus state via onFocus, onBlur, and a mousedown document listener for click-outside", () => {
|
|
41
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
42
|
+
expect(content).toContain("onFocus");
|
|
43
|
+
expect(content).toContain("onBlur");
|
|
44
|
+
expect(content).toContain('addEventListener("mousedown"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses transparent text and pointer-events overlay instead of hiding the InlineTextField", () => {
|
|
48
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
49
|
+
expect(content).toContain("color: \"transparent\"");
|
|
50
|
+
expect(content).toContain("pointerEvents: \"none\"");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders resolved text as sanitized HTML, not Markdown", () => {
|
|
54
|
+
const content = readFileSync(componentPath, "utf-8");
|
|
55
|
+
expect(content).toContain("sanitizeRichtextHtml");
|
|
56
|
+
expect(content).toContain("dangerouslySetInnerHTML");
|
|
57
|
+
expect(content).not.toContain("ReactMarkdown");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("paragraph-block uses ParagraphEditorText", () => {
|
|
62
|
+
const content = readFileSync(paragraphPath, "utf-8");
|
|
63
|
+
|
|
64
|
+
it("imports ParagraphEditorText", () => {
|
|
65
|
+
expect(content).toContain("ParagraphEditorText");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("destructures id from render props", () => {
|
|
69
|
+
expect(content).toMatch(/\bid\b.*:/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("wraps isValidElement branch with ParagraphEditorText", () => {
|
|
73
|
+
const reactElementBranch = content.slice(
|
|
74
|
+
content.indexOf("isValidElement(text)"),
|
|
75
|
+
content.indexOf("isValidElement(text)") + 200,
|
|
76
|
+
);
|
|
77
|
+
expect(reactElementBranch).toContain("ParagraphEditorText");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { loadPublishedPage, notFound } = vi.hoisted(() => ({
|
|
4
|
+
loadPublishedPage: vi.fn(),
|
|
5
|
+
notFound: vi.fn(() => {
|
|
6
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
7
|
+
}),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@pantheon-systems/p1-next-sdk/server", () => ({
|
|
11
|
+
loadPublishedPage,
|
|
12
|
+
loadRouteTemplateKeys: vi.fn().mockResolvedValue([]),
|
|
13
|
+
createCssQueryFetchers: vi.fn().mockReturnValue([]),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("next/navigation", () => ({ notFound }));
|
|
17
|
+
|
|
18
|
+
vi.mock("@pantheon-systems/puck-css/server", () => ({
|
|
19
|
+
pagePathFromCatchAllSegments: (segments: string[]) => `/${segments.join("/")}`,
|
|
20
|
+
loadRemoteDatasourceContext: vi.fn().mockResolvedValue({}),
|
|
21
|
+
extractReferencedDatasourceIds: vi.fn().mockReturnValue([]),
|
|
22
|
+
resolveDataTemplates: vi.fn(async (d: unknown) => d),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("../lib/remote-datasource-fetchers", () => ({
|
|
26
|
+
REMOTE_DATASOURCE_FETCHERS: [],
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../app/[...puckPath]/client", () => ({ Client: () => null }));
|
|
30
|
+
vi.mock("../components/content-unavailable", () => ({
|
|
31
|
+
ContentUnavailable: () => null,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import Page from "../app/[...puckPath]/page";
|
|
35
|
+
|
|
36
|
+
const render = (...segments: string[]) =>
|
|
37
|
+
Page({ params: Promise.resolve({ puckPath: segments }) });
|
|
38
|
+
|
|
39
|
+
describe("catch-all route — missing vs unavailable", () => {
|
|
40
|
+
beforeEach(() => vi.clearAllMocks());
|
|
41
|
+
|
|
42
|
+
// The route is statically renderable, so a 200 here would write every junk URL
|
|
43
|
+
// a crawler probes into the response cache as a successful page.
|
|
44
|
+
it("404s a path with no published page", async () => {
|
|
45
|
+
loadPublishedPage.mockResolvedValue({ status: "missing" });
|
|
46
|
+
await expect(render("nope")).rejects.toThrow("NEXT_NOT_FOUND");
|
|
47
|
+
expect(notFound).toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 404ing here would deindex a live page over a transient backend blip.
|
|
51
|
+
it("does not 404 when the backend is unreachable", async () => {
|
|
52
|
+
loadPublishedPage.mockResolvedValue({ status: "unavailable" });
|
|
53
|
+
await render("real-page");
|
|
54
|
+
expect(notFound).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders published content", async () => {
|
|
58
|
+
loadPublishedPage.mockResolvedValue({
|
|
59
|
+
status: "ok",
|
|
60
|
+
data: { root: { props: { title: "Hi" } }, content: [] },
|
|
61
|
+
});
|
|
62
|
+
await render("blog");
|
|
63
|
+
expect(notFound).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { puckRoot } from "../components/puck/root";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guidance on the page-metadata fields: help text under every field, and a
|
|
6
|
+
* placeholder showing what an empty field will inherit.
|
|
7
|
+
*
|
|
8
|
+
* The placeholder comes from `resolveFields` reading the live root props — the
|
|
9
|
+
* only tiers that exist today are the page's own title and description. It is a
|
|
10
|
+
* placeholder rather than a value on purpose: autosave persists the whole
|
|
11
|
+
* snapshot, so a derived value written into the field would be saved and the
|
|
12
|
+
* field would stop inheriting for good.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Field = {
|
|
16
|
+
type: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
metadata?: { help?: string; helpWhenEmpty?: string };
|
|
20
|
+
};
|
|
21
|
+
type ObjectField = { type: string; objectFields: Record<string, Field> };
|
|
22
|
+
|
|
23
|
+
const staticMeta = (puckRoot.fields as Record<string, unknown>)._meta as ObjectField;
|
|
24
|
+
|
|
25
|
+
const resolve = (props: Record<string, unknown>) => {
|
|
26
|
+
const resolveFields = puckRoot.resolveFields as (
|
|
27
|
+
data: { props: Record<string, unknown> },
|
|
28
|
+
) => Record<string, unknown>;
|
|
29
|
+
const fields = resolveFields({ props });
|
|
30
|
+
return (fields._meta as ObjectField).objectFields;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const INHERITS_FROM: Record<string, string> = {
|
|
34
|
+
ogTitle: "title",
|
|
35
|
+
ogDescription: "description",
|
|
36
|
+
twitterTitle: "title",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("page-metadata field guidance", () => {
|
|
40
|
+
it("gives every metadata field help text", () => {
|
|
41
|
+
for (const [name, field] of Object.entries(staticMeta.objectFields)) {
|
|
42
|
+
expect(
|
|
43
|
+
field.metadata?.help ?? field.metadata?.helpWhenEmpty,
|
|
44
|
+
`${name} has no help text`,
|
|
45
|
+
).toBeTruthy();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("tells an empty inheriting field where its value comes from", () => {
|
|
50
|
+
for (const name of Object.keys(INHERITS_FROM)) {
|
|
51
|
+
expect(staticMeta.objectFields[name]?.metadata?.helpWhenEmpty).toMatch(/inherit/i);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("puckRoot.resolveFields", () => {
|
|
57
|
+
it("shows the inherited value as the placeholder", () => {
|
|
58
|
+
const fields = resolve({ title: "Q3 Launch Recap", description: "How it went" });
|
|
59
|
+
|
|
60
|
+
expect(fields.ogTitle?.placeholder).toBe("Q3 Launch Recap");
|
|
61
|
+
expect(fields.twitterTitle?.placeholder).toBe("Q3 Launch Recap");
|
|
62
|
+
expect(fields.ogDescription?.placeholder).toBe("How it went");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("follows the same fallback chains the head tags use", () => {
|
|
66
|
+
// buildPageMetadata resolves twitter:title from ogTitle before title, and
|
|
67
|
+
// twitter:image from ogImage. A placeholder that disagreed would mislead.
|
|
68
|
+
const fields = resolve({
|
|
69
|
+
title: "Q3 Launch Recap",
|
|
70
|
+
_meta: { ogTitle: "Read the Q3 recap", ogImage: "https://cdn.example/card.png" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(fields.twitterTitle?.placeholder).toBe("Read the Q3 recap");
|
|
74
|
+
expect(fields.twitterImage?.placeholder).toBe("https://cdn.example/card.png");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("omits the placeholder when there is nothing to inherit", () => {
|
|
78
|
+
const fields = resolve({});
|
|
79
|
+
|
|
80
|
+
expect(fields.ogTitle?.placeholder).toBeUndefined();
|
|
81
|
+
expect(fields.ogDescription?.placeholder).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not offer the editor's boilerplate title as an inherited value", () => {
|
|
85
|
+
// Matches the head-tag side, which refuses to ship defaultProps.title.
|
|
86
|
+
const fields = resolve({ title: "My Puck Editor" });
|
|
87
|
+
|
|
88
|
+
expect(fields.ogTitle?.placeholder).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resolves the same field set it declares statically", () => {
|
|
92
|
+
expect(Object.keys(resolve({ title: "x" })).sort()).toEqual(
|
|
93
|
+
Object.keys(staticMeta.objectFields).sort(),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("leaves the declared fields unmutated, so a resolve cannot leak into the next", () => {
|
|
98
|
+
resolve({ title: "Q3 Launch Recap" });
|
|
99
|
+
|
|
100
|
+
expect(staticMeta.objectFields.ogTitle?.placeholder).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keeps the placeholder out of the document by never touching props", () => {
|
|
104
|
+
const props = { title: "Q3 Launch Recap", _meta: { ogTitle: "" } };
|
|
105
|
+
resolve(props);
|
|
106
|
+
|
|
107
|
+
expect(props._meta).toEqual({ ogTitle: "" });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { puckRoot } from "../components/puck/root";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The fixed page-metadata field set on the Puck root config.
|
|
6
|
+
*
|
|
7
|
+
* Values live at `root.props._meta` in the page snapshot — branch-scoped,
|
|
8
|
+
* versioned and autosaved for free. The field set itself is fixed: no
|
|
9
|
+
* admin-defined fields, no template or site tiers. `resolveFields` varies only
|
|
10
|
+
* the placeholders (see puck-root-guidance), never which fields exist.
|
|
11
|
+
*
|
|
12
|
+
* Fields are declared flat inside one `_meta` object field. Grouping them
|
|
13
|
+
* (SEO / Open Graph / Twitter) would mean nesting object fields, which deepens
|
|
14
|
+
* the prop paths the root-prop migration applier has to handle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type ObjectField = {
|
|
18
|
+
type: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
objectFields: Record<string, { type: string; label?: string }>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const metaField = (puckRoot.fields as Record<string, unknown>)._meta as ObjectField;
|
|
24
|
+
|
|
25
|
+
// ogType and twitterCard are selects: their vocabulary is fixed by what Next
|
|
26
|
+
// accepts for the tag. See puck-root-selects.
|
|
27
|
+
const EXPECTED_FIELDS: Record<string, string> = {
|
|
28
|
+
ogTitle: "text",
|
|
29
|
+
ogDescription: "textarea",
|
|
30
|
+
ogType: "select",
|
|
31
|
+
ogImage: "text",
|
|
32
|
+
ogLocale: "text",
|
|
33
|
+
twitterCard: "select",
|
|
34
|
+
twitterTitle: "text",
|
|
35
|
+
twitterImage: "text",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("puckRoot._meta field set", () => {
|
|
39
|
+
it("is a single object field", () => {
|
|
40
|
+
expect(metaField).toBeDefined();
|
|
41
|
+
expect(metaField.type).toBe("object");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("declares exactly the eight fixed fields, flat", () => {
|
|
45
|
+
expect(Object.keys(metaField.objectFields).sort()).toEqual(
|
|
46
|
+
Object.keys(EXPECTED_FIELDS).sort(),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("gives each field the expected type", () => {
|
|
51
|
+
for (const [name, type] of Object.entries(EXPECTED_FIELDS)) {
|
|
52
|
+
expect(metaField.objectFields[name]?.type).toBe(type);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("labels every field with the tag it writes", () => {
|
|
57
|
+
// Not friendly rewrites: someone editing these is working from a checklist
|
|
58
|
+
// that names the tags, and "Social title" makes them guess which one it is.
|
|
59
|
+
expect(
|
|
60
|
+
Object.fromEntries(
|
|
61
|
+
Object.keys(EXPECTED_FIELDS).map((name) => [
|
|
62
|
+
name,
|
|
63
|
+
metaField.objectFields[name]?.label,
|
|
64
|
+
]),
|
|
65
|
+
),
|
|
66
|
+
).toEqual({
|
|
67
|
+
ogTitle: "og:title",
|
|
68
|
+
ogDescription: "og:description",
|
|
69
|
+
ogType: "og:type",
|
|
70
|
+
ogImage: "og:image",
|
|
71
|
+
ogLocale: "og:locale",
|
|
72
|
+
twitterCard: "twitter:card",
|
|
73
|
+
twitterTitle: "twitter:title",
|
|
74
|
+
twitterImage: "twitter:image",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("names the group for what it holds, matching the prototype", () => {
|
|
79
|
+
expect((metaField as { label?: string }).label).toBe("Social & sharing");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("omits ogUrl — the canonical URL is derived from the request", () => {
|
|
83
|
+
expect(metaField.objectFields).not.toHaveProperty("ogUrl");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("includes twitterCard, without which twitter:title and twitter:image are inert", () => {
|
|
87
|
+
expect(metaField.objectFields.twitterCard).toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the existing title and description fields", () => {
|
|
91
|
+
const fields = puckRoot.fields as Record<string, { type: string }>;
|
|
92
|
+
expect(fields.title?.type).toBe("text");
|
|
93
|
+
expect(fields.description?.type).toBe("textarea");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("adds no _meta default, so no page is seeded with boilerplate metadata", () => {
|
|
97
|
+
// Empty-means-inherit (Q1): an unset field falls back at render time. A
|
|
98
|
+
// default here would freeze a value into every new page's snapshot, and
|
|
99
|
+
// would need adding to the DEFAULT_EDITOR_TITLE-style boilerplate filter.
|
|
100
|
+
expect(puckRoot.defaultProps).not.toHaveProperty("_meta");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { puckRoot } from "../components/puck/root";
|
|
3
|
+
import { OG_TYPES, TWITTER_CARDS } from "../lib/seo-metadata.consts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The two metadata fields with a fixed vocabulary are dropdowns.
|
|
7
|
+
*
|
|
8
|
+
* Their options come from the same lists `buildPageMetadata` validates against,
|
|
9
|
+
* so an option cannot drift from what actually reaches the tag.
|
|
10
|
+
*
|
|
11
|
+
* The default option's value is empty rather than the default itself. Storing
|
|
12
|
+
* `website` on every page would freeze it there, and a page holding an explicit
|
|
13
|
+
* value can never pick up a template default later — so the label states the
|
|
14
|
+
* outcome while the data stays uncommitted.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Field = {
|
|
18
|
+
type: string;
|
|
19
|
+
options?: { label: string; value: string }[];
|
|
20
|
+
};
|
|
21
|
+
type ObjectField = { type: string; objectFields: Record<string, Field> };
|
|
22
|
+
|
|
23
|
+
const staticMeta = (puckRoot.fields as Record<string, unknown>)._meta as ObjectField;
|
|
24
|
+
|
|
25
|
+
const resolve = (props: Record<string, unknown>) => {
|
|
26
|
+
const resolveFields = puckRoot.resolveFields as (
|
|
27
|
+
data: { props: Record<string, unknown> },
|
|
28
|
+
) => Record<string, unknown>;
|
|
29
|
+
return (resolveFields({ props })._meta as ObjectField).objectFields;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const values = (field?: Field) => field?.options?.map((option) => option.value) ?? [];
|
|
33
|
+
|
|
34
|
+
describe("fixed-vocabulary metadata fields", () => {
|
|
35
|
+
it("renders as selects rather than free text", () => {
|
|
36
|
+
expect(staticMeta.objectFields.ogType?.type).toBe("select");
|
|
37
|
+
expect(staticMeta.objectFields.twitterCard?.type).toBe("select");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("offers exactly the values the head tags accept", () => {
|
|
41
|
+
expect(values(staticMeta.objectFields.ogType).filter(Boolean)).toEqual([...OG_TYPES]);
|
|
42
|
+
expect(values(staticMeta.objectFields.twitterCard).filter(Boolean)).toEqual([
|
|
43
|
+
...TWITTER_CARDS,
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps an empty first option, so a page stays uncommitted", () => {
|
|
48
|
+
for (const name of ["ogType", "twitterCard"]) {
|
|
49
|
+
expect(values(staticMeta.objectFields[name])[0]).toBe("");
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("labels every option, since the tag values are not user-facing", () => {
|
|
54
|
+
for (const name of ["ogType", "twitterCard"]) {
|
|
55
|
+
const options = staticMeta.objectFields[name]?.options ?? [];
|
|
56
|
+
expect(options.length).toBeGreaterThan(1);
|
|
57
|
+
for (const option of options) {
|
|
58
|
+
expect(option.label).toBeTruthy();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("names the og:type default, which is always website", () => {
|
|
64
|
+
expect(staticMeta.objectFields.ogType?.options?.[0]?.label).toMatch(/website/i);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("names the default card style, which depends on whether there is an image", () => {
|
|
68
|
+
// buildPageMetadata picks summary_large_image when an image is present and
|
|
69
|
+
// summary when it is not, so the label has to follow the image field.
|
|
70
|
+
const withImage = resolve({ _meta: { ogImage: "https://cdn.example/card.png" } });
|
|
71
|
+
const without = resolve({});
|
|
72
|
+
|
|
73
|
+
expect(withImage.twitterCard?.options?.[0]?.label).toMatch(/large image/i);
|
|
74
|
+
expect(without.twitterCard?.options?.[0]?.label).toMatch(/summary/i);
|
|
75
|
+
expect(without.twitterCard?.options?.[0]?.label).not.toMatch(/large image/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("varies only the default label, never the option values", () => {
|
|
79
|
+
const resolved = resolve({ _meta: { ogImage: "https://cdn.example/card.png" } });
|
|
80
|
+
|
|
81
|
+
expect(values(resolved.ogType)).toEqual(values(staticMeta.objectFields.ogType));
|
|
82
|
+
expect(values(resolved.twitterCard)).toEqual(
|
|
83
|
+
values(staticMeta.objectFields.twitterCard),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -13,8 +13,8 @@ vi.mock("@pantheon-systems/puck-css/server", async (importOriginal) => {
|
|
|
13
13
|
return actual;
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
|
|
17
16
|
import type { RemoteDatasourceFetcherParams } from "@pantheon-systems/puck-css/server";
|
|
17
|
+
import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
|
|
18
18
|
|
|
19
19
|
const { PCCConvenienceFunctions } = await import(
|
|
20
20
|
"@pantheon-systems/cpub-react-sdk/server"
|
|
@@ -177,7 +177,7 @@ describe("monster_list fetcher", () => {
|
|
|
177
177
|
});
|
|
178
178
|
const result = await fetcher.fetch(makeFetcherParams({ fetchImpl }));
|
|
179
179
|
expect(result).toEqual({
|
|
180
|
-
items: [{ index: "bulbasaur", name: "Bulbasaur", url: "/pokemon/bulbasaur" }],
|
|
180
|
+
items: [{ key: "bulbasaur", species: "Bulbasaur", index: "bulbasaur", name: "Bulbasaur", url: "/pokemon/bulbasaur" }],
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
183
|
});
|