@pantheon-systems/create-p1-starter-kit 0.8.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 +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__/page-seo-meta-templates.test.ts +143 -0
- package/template/__tests__/page-seo-meta.test.ts +98 -0
- package/template/__tests__/paragraph-block.test.ts +24 -22
- 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__/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 +22 -4
- package/template/app/layout.tsx +11 -1
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
- package/template/app/styles.css +18 -1
- 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/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 +44 -11
- 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 +14 -11
- 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
|
@@ -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,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
|
});
|
|
@@ -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
|
+
});
|