@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
@@ -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("has a richtext field for text", async () => {
5
- const { paragraphBlock } = await import("../components/puck/paragraph-block");
6
- expect(paragraphBlock.fields.text.type).toBe("richtext");
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("enables inline canvas editing", async () => {
10
- const { paragraphBlock } = await import("../components/puck/paragraph-block");
11
- expect((paragraphBlock.fields.text as any).contentEditable).toBe(true);
19
+ it("exports paragraphBlock with defaultProps", () => {
20
+ expect(content).toContain("export const paragraphBlock");
21
+ expect(content).toContain("defaultProps");
12
22
  });
13
23
 
14
- it("includes ai instructions on the text field", async () => {
15
- const { paragraphBlock } = await import("../components/puck/paragraph-block");
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("provides a renderMenu function", async () => {
23
- const { paragraphBlock } = await import("../components/puck/paragraph-block");
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("has a default text prop", async () => {
28
- const { paragraphBlock } = await import("../components/puck/paragraph-block");
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("provides a render function", async () => {
34
- const { paragraphBlock } = await import("../components/puck/paragraph-block");
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
  });