@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
|
@@ -1,12 +1,193 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { OG_TYPES, TWITTER_CARDS } from "../../lib/seo-metadata.consts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fixed-vocabulary fields are dropdowns built from the lists buildPageMetadata
|
|
6
|
+
* validates against, so an option cannot drift from what reaches the tag.
|
|
7
|
+
*
|
|
8
|
+
* The default option's value is empty rather than the default itself. Storing it
|
|
9
|
+
* would freeze a value into every page, and a page carrying an explicit `website`
|
|
10
|
+
* can never pick up a template default later — so the label states the outcome
|
|
11
|
+
* while the data stays uncommitted.
|
|
12
|
+
*/
|
|
13
|
+
const OG_TYPE_LABELS: Record<(typeof OG_TYPES)[number], string> = {
|
|
14
|
+
website: "Website",
|
|
15
|
+
article: "Article",
|
|
16
|
+
book: "Book",
|
|
17
|
+
profile: "Profile",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const TWITTER_CARD_LABELS: Record<(typeof TWITTER_CARDS)[number], string> = {
|
|
21
|
+
summary: "Summary",
|
|
22
|
+
summary_large_image: "Summary with large image",
|
|
23
|
+
player: "Player",
|
|
24
|
+
app: "App",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const optionsWithDefault = <T extends string>(
|
|
28
|
+
values: readonly T[],
|
|
29
|
+
labels: Record<T, string>,
|
|
30
|
+
defaultLabel: string,
|
|
31
|
+
) => [
|
|
32
|
+
{ label: `${defaultLabel} (default)`, value: "" },
|
|
33
|
+
...values.map((value) => ({ label: labels[value], value: value as string })),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// buildPageMetadata picks the large card when there is an image, so the default
|
|
37
|
+
// option names whichever one an empty field will actually get.
|
|
38
|
+
const twitterCardOptions = (defaultLabel: string) =>
|
|
39
|
+
optionsWithDefault(TWITTER_CARDS, TWITTER_CARD_LABELS, defaultLabel);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Page metadata values are stored at `root.props._meta`, so they are
|
|
43
|
+
* branch-scoped and versioned like any other authored content. A page created
|
|
44
|
+
* from a template starts with that template's `_meta` copied in, the same way
|
|
45
|
+
* its content is; anything still empty after that inherits at render time.
|
|
46
|
+
*
|
|
47
|
+
* Labels are the tag names themselves, not friendly rewrites. Someone editing
|
|
48
|
+
* these is working from an SEO or social checklist that names the tags, and a
|
|
49
|
+
* label like "Social title" makes them guess which tag it writes.
|
|
50
|
+
*/
|
|
51
|
+
const metadataFields = {
|
|
52
|
+
ogTitle: {
|
|
53
|
+
type: "text" as const,
|
|
54
|
+
label: "og:title",
|
|
55
|
+
metadata: {
|
|
56
|
+
help: "The headline shown when this page is shared.",
|
|
57
|
+
helpWhenEmpty: "Inherited from title. Edit to override.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
ogDescription: {
|
|
61
|
+
type: "textarea" as const,
|
|
62
|
+
label: "og:description",
|
|
63
|
+
metadata: {
|
|
64
|
+
help: "The summary shown beneath the headline when this page is shared.",
|
|
65
|
+
helpWhenEmpty: "Inherited from description. Edit to override.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
ogType: {
|
|
69
|
+
type: "select" as const,
|
|
70
|
+
label: "og:type",
|
|
71
|
+
options: optionsWithDefault(OG_TYPES, OG_TYPE_LABELS, "Website"),
|
|
72
|
+
metadata: { help: "How this page is described when shared. Most pages are a website." },
|
|
73
|
+
},
|
|
74
|
+
ogImage: {
|
|
75
|
+
type: "text" as const,
|
|
76
|
+
label: "og:image",
|
|
77
|
+
metadata: {
|
|
78
|
+
help: "Full URL to the preview image. A relative path resolves against the site URL.",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
ogLocale: {
|
|
82
|
+
type: "text" as const,
|
|
83
|
+
label: "og:locale",
|
|
84
|
+
metadata: { help: "Language and region of this page, such as en_US." },
|
|
85
|
+
},
|
|
86
|
+
// Without twitter:card, X renders no card at all and the two fields below are inert.
|
|
87
|
+
twitterCard: {
|
|
88
|
+
type: "select" as const,
|
|
89
|
+
label: "twitter:card",
|
|
90
|
+
options: twitterCardOptions("Summary"),
|
|
91
|
+
metadata: {
|
|
92
|
+
help: "How the card is laid out on X. Player and app cards need tags this site does not render.",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
twitterTitle: {
|
|
96
|
+
type: "text" as const,
|
|
97
|
+
label: "twitter:title",
|
|
98
|
+
metadata: {
|
|
99
|
+
help: "The headline shown on X.",
|
|
100
|
+
helpWhenEmpty: "Inherited from og:title, then title. Edit to override.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
twitterImage: {
|
|
104
|
+
type: "text" as const,
|
|
105
|
+
label: "twitter:image",
|
|
106
|
+
metadata: {
|
|
107
|
+
help: "Full URL to the image shown on X.",
|
|
108
|
+
helpWhenEmpty: "Inherited from og:image. Edit to override.",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Boilerplate from defaultProps: never offered as an inherited value, matching
|
|
114
|
+
// the head tags, which refuse to ship it.
|
|
115
|
+
const DEFAULT_EDITOR_TITLE = "My Puck Editor";
|
|
116
|
+
|
|
117
|
+
const inheritedFrom = (value: unknown): string | undefined => {
|
|
118
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
119
|
+
return text && text !== DEFAULT_EDITOR_TITLE ? text : undefined;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const withPlaceholder = <T extends object>(field: T, placeholder?: string): T =>
|
|
123
|
+
placeholder ? { ...field, placeholder } : field;
|
|
124
|
+
|
|
125
|
+
interface InheritedValues {
|
|
126
|
+
ogTitle?: string;
|
|
127
|
+
ogDescription?: string;
|
|
128
|
+
twitterTitle?: string;
|
|
129
|
+
twitterImage?: string;
|
|
130
|
+
twitterCardDefault?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const buildFields = (inherited: InheritedValues = {}) => ({
|
|
134
|
+
title: { type: "text" as const },
|
|
135
|
+
description: { type: "textarea" as const },
|
|
136
|
+
_meta: {
|
|
137
|
+
type: "object" as const,
|
|
138
|
+
label: "Social & sharing",
|
|
139
|
+
metadata: { collapsible: true, defaultCollapsed: true },
|
|
140
|
+
objectFields: {
|
|
141
|
+
...metadataFields,
|
|
142
|
+
ogTitle: withPlaceholder(metadataFields.ogTitle, inherited.ogTitle),
|
|
143
|
+
ogDescription: withPlaceholder(metadataFields.ogDescription, inherited.ogDescription),
|
|
144
|
+
twitterTitle: withPlaceholder(metadataFields.twitterTitle, inherited.twitterTitle),
|
|
145
|
+
twitterImage: withPlaceholder(metadataFields.twitterImage, inherited.twitterImage),
|
|
146
|
+
twitterCard: inherited.twitterCardDefault
|
|
147
|
+
? {
|
|
148
|
+
...metadataFields.twitterCard,
|
|
149
|
+
options: twitterCardOptions(inherited.twitterCardDefault),
|
|
150
|
+
}
|
|
151
|
+
: metadataFields.twitterCard,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
2
155
|
|
|
3
156
|
export const puckRoot = {
|
|
4
|
-
fields:
|
|
5
|
-
|
|
6
|
-
|
|
157
|
+
fields: buildFields(),
|
|
158
|
+
/**
|
|
159
|
+
* Shows what an empty field will inherit, as a placeholder. It has to be a
|
|
160
|
+
* placeholder and not a value: autosave persists the whole snapshot, so a
|
|
161
|
+
* derived value written into the field would be saved and the field would stop
|
|
162
|
+
* inheriting for good.
|
|
163
|
+
*
|
|
164
|
+
* Reading the source from root props is what makes this track edits — the
|
|
165
|
+
* fields slice subscribes to the root node, so changing the title re-resolves.
|
|
166
|
+
* The same is not true of Puck's `metadata`, which is why the site and
|
|
167
|
+
* template default tiers cannot be shown this way.
|
|
168
|
+
*
|
|
169
|
+
* The chains mirror buildPageMetadata in lib/seo-metadata.ts. A placeholder
|
|
170
|
+
* that disagreed with what the head actually emits would be worse than none.
|
|
171
|
+
*/
|
|
172
|
+
resolveFields: (data: { props?: Record<string, unknown> }) => {
|
|
173
|
+
const props = data.props ?? {};
|
|
174
|
+
const meta = (props._meta ?? {}) as Record<string, unknown>;
|
|
175
|
+
const title = inheritedFrom(props.title);
|
|
176
|
+
const ogTitle = inheritedFrom(meta.ogTitle) ?? title;
|
|
177
|
+
const image = inheritedFrom(meta.twitterImage) ?? inheritedFrom(meta.ogImage);
|
|
178
|
+
|
|
179
|
+
return buildFields({
|
|
180
|
+
ogTitle: title,
|
|
181
|
+
ogDescription: inheritedFrom(props.description),
|
|
182
|
+
twitterTitle: ogTitle,
|
|
183
|
+
twitterImage: inheritedFrom(meta.ogImage),
|
|
184
|
+
twitterCardDefault: image
|
|
185
|
+
? TWITTER_CARD_LABELS.summary_large_image
|
|
186
|
+
: TWITTER_CARD_LABELS.summary,
|
|
187
|
+
});
|
|
7
188
|
},
|
|
8
189
|
defaultProps: {
|
|
9
|
-
title:
|
|
190
|
+
title: DEFAULT_EDITOR_TITLE,
|
|
10
191
|
},
|
|
11
192
|
render: (props: { children?: ReactNode; title?: string }) => {
|
|
12
193
|
const { children } = props;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize richtext HTML before it is rendered via `dangerouslySetInnerHTML`.
|
|
5
|
+
*
|
|
6
|
+
* Blocks render editor-authored richtext as an HTML string on the public,
|
|
7
|
+
* server-rendered surface. This is defense-in-depth at the render boundary:
|
|
8
|
+
* it does not rely on the richtext editor's schema or on TipTap's default
|
|
9
|
+
* link-protocol allowlist to be the only thing standing between stored content
|
|
10
|
+
* and the DOM. The allowlist below matches what the richtext toolbar can
|
|
11
|
+
* actually produce (inline formatting + lists + links); anything else —
|
|
12
|
+
* `<script>`, `<img onerror>`, `javascript:`/`data:` hrefs — is stripped.
|
|
13
|
+
*
|
|
14
|
+
* Runs in both Node (SSR) and the browser via isomorphic-dompurify.
|
|
15
|
+
*/
|
|
16
|
+
const ALLOWED_TAGS = [
|
|
17
|
+
"p",
|
|
18
|
+
"br",
|
|
19
|
+
"strong",
|
|
20
|
+
"b",
|
|
21
|
+
"em",
|
|
22
|
+
"i",
|
|
23
|
+
"u",
|
|
24
|
+
"s",
|
|
25
|
+
"ul",
|
|
26
|
+
"ol",
|
|
27
|
+
"li",
|
|
28
|
+
"a",
|
|
29
|
+
"code",
|
|
30
|
+
"span",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const ALLOWED_ATTR = ["href", "target", "rel"];
|
|
34
|
+
|
|
35
|
+
export function sanitizeRichtextHtml(html: string): string {
|
|
36
|
+
return DOMPurify.sanitize(html, {
|
|
37
|
+
ALLOWED_TAGS,
|
|
38
|
+
ALLOWED_ATTR,
|
|
39
|
+
// Explicit protocol allowlist (defense-in-depth over DOMPurify's default,
|
|
40
|
+
// which already rejects javascript:/unknown schemes): only safe link
|
|
41
|
+
// protocols, plus relative/anchor hrefs.
|
|
42
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|ftp:|#|\/|\.)/i,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -81,7 +81,11 @@ export default tseslint.config(
|
|
|
81
81
|
'@typescript-eslint/no-shadow': 'warn',
|
|
82
82
|
'@typescript-eslint/no-require-imports': 'warn',
|
|
83
83
|
'no-async-promise-executor': 'warn',
|
|
84
|
-
'
|
|
84
|
+
// Off deliberately. The rule's `interface` default is unsafe: interfaces have
|
|
85
|
+
// no implicit index signature, so autofixing `type X = {...}` to an interface
|
|
86
|
+
// silently breaks assignability to Record<string, unknown>. Both spellings are
|
|
87
|
+
// fine; this is not worth a footgun.
|
|
88
|
+
'@typescript-eslint/consistent-type-definitions': 'off',
|
|
85
89
|
'no-useless-escape': 'warn',
|
|
86
90
|
'no-useless-catch': 'warn',
|
|
87
91
|
'@typescript-eslint/no-inferrable-types': 'warn',
|
|
@@ -90,7 +94,10 @@ export default tseslint.config(
|
|
|
90
94
|
'@typescript-eslint/ban-ts-comment': 'warn',
|
|
91
95
|
'@typescript-eslint/no-unused-expressions': 'warn',
|
|
92
96
|
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
|
|
93
|
-
|
|
97
|
+
// A no-op default for an optional callback is idiomatic here
|
|
98
|
+
// (`onLogout ?? (() => {})`), and the rule cannot tell it from an
|
|
99
|
+
// accidentally empty body. Still catches empty function declarations.
|
|
100
|
+
'@typescript-eslint/no-empty-function': ['warn', { allow: ['arrowFunctions', 'methods'] }],
|
|
94
101
|
'@typescript-eslint/no-empty-object-type': 'warn',
|
|
95
102
|
'@typescript-eslint/no-wrapper-object-types': 'warn',
|
|
96
103
|
'no-constant-binary-expression': 'warn',
|
|
@@ -99,14 +106,20 @@ export default tseslint.config(
|
|
|
99
106
|
'@typescript-eslint/no-redeclare': 'warn',
|
|
100
107
|
'no-case-declarations': 'warn',
|
|
101
108
|
'no-empty': 'warn',
|
|
102
|
-
|
|
109
|
+
// Off: `delete record[computedKey]` is the normal way to drop a cache
|
|
110
|
+
// entry or a patch path segment. The rule's alternative is switching the
|
|
111
|
+
// structure to a Map, which is a design change, not a lint fix.
|
|
112
|
+
'@typescript-eslint/no-dynamic-delete': 'off',
|
|
103
113
|
'no-constant-condition': 'warn',
|
|
104
114
|
'no-empty-pattern': 'warn',
|
|
105
115
|
'no-var': 'warn',
|
|
106
116
|
'@typescript-eslint/no-namespace': 'warn',
|
|
107
117
|
'@typescript-eslint/no-extraneous-class': 'warn',
|
|
108
118
|
'@typescript-eslint/unified-signatures': 'warn',
|
|
109
|
-
|
|
119
|
+
// Off: `request<void>(...)` for an endpoint that returns no body is the
|
|
120
|
+
// normal spelling in css-client, and that is a generic type argument —
|
|
121
|
+
// which the rule's own message calls valid.
|
|
122
|
+
'@typescript-eslint/no-invalid-void-type': 'off',
|
|
110
123
|
'@typescript-eslint/no-this-alias': 'warn',
|
|
111
124
|
},
|
|
112
125
|
},
|
|
@@ -127,6 +140,9 @@ export default tseslint.config(
|
|
|
127
140
|
'coverage/**',
|
|
128
141
|
'**/*.d.ts',
|
|
129
142
|
'**/generated/**',
|
|
143
|
+
// Vendored snapshots and test data. Linting these rewrites them, which
|
|
144
|
+
// silently breaks any test asserting byte-identical output against them.
|
|
145
|
+
'**/fixtures/**',
|
|
130
146
|
'**/.puppeteerrc.cjs',
|
|
131
147
|
],
|
|
132
148
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DraftRequestChannel } from "@pantheon-systems/p1-ai-chat";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the Create Page modal's "Generate with AI" handler, or `undefined` when the
|
|
5
|
+
* chatbot is disabled (which leaves the modal tile a placeholder).
|
|
6
|
+
*
|
|
7
|
+
* The page does not exist yet: the chat proposes the page template it should start from and
|
|
8
|
+
* creates it once the user agrees, because a document's template can only be set as it is
|
|
9
|
+
* created (PCC-3555). Until then the request carries the title and path the dialog collected.
|
|
10
|
+
*/
|
|
11
|
+
export function createGenerateWithAIHandler(
|
|
12
|
+
draftRequests: DraftRequestChannel,
|
|
13
|
+
enabled: boolean,
|
|
14
|
+
): ((brief: string, page: { path: string; title?: string }) => void) | undefined {
|
|
15
|
+
if (!enabled) return undefined;
|
|
16
|
+
return (brief, page) => {
|
|
17
|
+
draftRequests.publish({
|
|
18
|
+
kind: "create-page",
|
|
19
|
+
brief: brief.trim(),
|
|
20
|
+
page: { path: page.path, title: page.title?.trim() ?? "" },
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createDraftRequestChannel, type DraftRequestChannel } from "@pantheon-systems/p1-ai-chat";
|
|
2
|
+
|
|
3
|
+
let channel: DraftRequestChannel | null = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module-level singleton, not component state: the publish and the consume happen either
|
|
7
|
+
* side of a navigation that remounts the editor tree, so a per-mount ref would drop it.
|
|
8
|
+
*/
|
|
9
|
+
export function getDraftRequestChannel(): DraftRequestChannel {
|
|
10
|
+
if (!channel) channel = createDraftRequestChannel();
|
|
11
|
+
return channel;
|
|
12
|
+
}
|
|
@@ -35,6 +35,8 @@ function resolveMonsterIndex(params: RemoteDatasourceFetcherParams): string | un
|
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
type MonsterItem = { index: string; name: string; url: string } & Record<string, unknown>;
|
|
39
|
+
|
|
38
40
|
async function fetchMonster(
|
|
39
41
|
index: string | undefined,
|
|
40
42
|
fetchImpl: typeof fetch,
|
|
@@ -50,9 +52,10 @@ async function fetchMonster(
|
|
|
50
52
|
key
|
|
51
53
|
num
|
|
52
54
|
species
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
sprite
|
|
56
|
+
types { name }
|
|
57
|
+
abilities { first { name } second { name } hidden { name } }
|
|
58
|
+
baseStats { hp attack defense specialattack specialdefense speed }
|
|
56
59
|
}
|
|
57
60
|
}`,
|
|
58
61
|
variables: { pokemon: index },
|
|
@@ -74,9 +77,7 @@ async function fetchMonster(
|
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
async function fetchMonsterList(
|
|
78
|
-
fetchImpl: typeof fetch,
|
|
79
|
-
): Promise<Array<{ index: string; name: string; url?: string }>> {
|
|
80
|
+
async function fetchMonsterList(fetchImpl: typeof fetch): Promise<MonsterItem[]> {
|
|
80
81
|
try {
|
|
81
82
|
const res = await fetchImpl(GRAPHQL_POKEMON_ENDPOINT, {
|
|
82
83
|
method: "POST",
|
|
@@ -85,7 +86,12 @@ async function fetchMonsterList(
|
|
|
85
86
|
query: `query GetAllPokemon($take: Int!, $offset: Int!) {
|
|
86
87
|
getAllPokemon(take: $take, offset: $offset) {
|
|
87
88
|
key
|
|
89
|
+
num
|
|
88
90
|
species
|
|
91
|
+
sprite
|
|
92
|
+
types { name }
|
|
93
|
+
abilities { first { name } second { name } hidden { name } }
|
|
94
|
+
baseStats { hp attack defense specialattack specialdefense speed }
|
|
89
95
|
}
|
|
90
96
|
}`,
|
|
91
97
|
variables: { take: 200, offset: 89 },
|
|
@@ -98,14 +104,14 @@ async function fetchMonsterList(
|
|
|
98
104
|
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
|
99
105
|
const results = (data as { getAllPokemon?: unknown }).getAllPokemon;
|
|
100
106
|
if (!Array.isArray(results)) return [];
|
|
101
|
-
const out:
|
|
107
|
+
const out: MonsterItem[] = [];
|
|
102
108
|
for (const row of results) {
|
|
103
109
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
104
110
|
const r = row as Record<string, unknown>;
|
|
105
111
|
const index = typeof r.key === "string" ? r.key : "";
|
|
106
112
|
const name = typeof r.species === "string" ? r.species : "";
|
|
107
113
|
const url = index ? `/pokemon/${index}` : undefined;
|
|
108
|
-
if (index && name) out.push({ index, name, url });
|
|
114
|
+
if (index && name && url) out.push({ ...r, index, name, url });
|
|
109
115
|
}
|
|
110
116
|
return out;
|
|
111
117
|
} catch {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type {
|
|
3
|
+
SeoMetadata,
|
|
4
|
+
getPage,
|
|
5
|
+
} from "@pantheon-systems/puck-css/server";
|
|
6
|
+
import {
|
|
7
|
+
loadRemoteDatasourceContext,
|
|
8
|
+
extractReferencedDatasourceIds,
|
|
9
|
+
listRouteTemplateKeysFromDatabase,
|
|
10
|
+
resolveStringTemplates,
|
|
11
|
+
} from "@pantheon-systems/puck-css/server";
|
|
12
|
+
import { REMOTE_DATASOURCE_FETCHERS } from "./remote-datasource-fetchers";
|
|
13
|
+
import { buildPageMetadata } from "./seo-metadata";
|
|
14
|
+
import type { PageMetaFields } from "./seo-metadata";
|
|
15
|
+
|
|
16
|
+
type PageData = Awaited<ReturnType<typeof getPage>>;
|
|
17
|
+
|
|
18
|
+
// Untitled pages carry the editor's defaultProps.title boilerplate
|
|
19
|
+
// (components/puck/root.tsx); never ship it as <title>/og:title.
|
|
20
|
+
const DEFAULT_EDITOR_TITLE = "My Puck Editor";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Metadata fields that accept `{{ }}`. ogType and twitterCard are absent by
|
|
24
|
+
* design: their values are checked against a union, so a template could only
|
|
25
|
+
* ever resolve to something the renderer rejects.
|
|
26
|
+
*/
|
|
27
|
+
const TEMPLATED_META_FIELDS = [
|
|
28
|
+
"ogTitle",
|
|
29
|
+
"ogDescription",
|
|
30
|
+
"ogImage",
|
|
31
|
+
"ogLocale",
|
|
32
|
+
"twitterTitle",
|
|
33
|
+
"twitterImage",
|
|
34
|
+
] as const satisfies readonly (keyof PageMetaFields)[];
|
|
35
|
+
|
|
36
|
+
const carriesTemplate = (value: unknown): value is string =>
|
|
37
|
+
typeof value === "string" && value.includes("{{");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Produces the per-page <head> Metadata for a route (PCC-3407). Title,
|
|
41
|
+
* description and the free-text metadata fields are template-allowed.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolvePageMetadata({
|
|
44
|
+
pageData,
|
|
45
|
+
path,
|
|
46
|
+
searchParams,
|
|
47
|
+
}: {
|
|
48
|
+
pageData: PageData;
|
|
49
|
+
path: string;
|
|
50
|
+
searchParams: Record<string, string | string[] | undefined>;
|
|
51
|
+
}): Promise<Metadata> {
|
|
52
|
+
const rootProps: Record<string, unknown> | undefined = pageData?.root.props;
|
|
53
|
+
const seo: Partial<SeoMetadata> | undefined = rootProps?._seo;
|
|
54
|
+
const authoredMeta = rootProps?._meta as PageMetaFields | undefined;
|
|
55
|
+
const rootTitle = rootProps?.title as string | undefined;
|
|
56
|
+
const rawTitle = rootTitle === DEFAULT_EDITOR_TITLE ? undefined : rootTitle;
|
|
57
|
+
const rawDescription = rootProps?.description as string | undefined;
|
|
58
|
+
|
|
59
|
+
const templatedMeta = TEMPLATED_META_FIELDS.filter((field) =>
|
|
60
|
+
carriesTemplate(authoredMeta?.[field]),
|
|
61
|
+
);
|
|
62
|
+
const needsTemplates =
|
|
63
|
+
carriesTemplate(rawTitle) || carriesTemplate(rawDescription) || templatedMeta.length > 0;
|
|
64
|
+
|
|
65
|
+
let title = rawTitle;
|
|
66
|
+
let description = rawDescription;
|
|
67
|
+
let meta = authoredMeta;
|
|
68
|
+
|
|
69
|
+
if (needsTemplates && pageData) {
|
|
70
|
+
const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
|
|
71
|
+
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
72
|
+
const context = await loadRemoteDatasourceContext({
|
|
73
|
+
searchParams,
|
|
74
|
+
fetchImpl: fetch,
|
|
75
|
+
pagePath: path,
|
|
76
|
+
routeTemplateKeys,
|
|
77
|
+
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
78
|
+
referencedDatasourceIds,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// One context load, then every templated value resolved against it. Only
|
|
82
|
+
// the fields that carry a template are resolved, so an ordinary page with a
|
|
83
|
+
// templated title does not pay for eight passes.
|
|
84
|
+
const resolveField = async (value: string) => resolveStringTemplates(value, context);
|
|
85
|
+
|
|
86
|
+
const [resolvedTitle, resolvedDescription, ...resolvedMeta] = await Promise.all([
|
|
87
|
+
carriesTemplate(rawTitle) ? resolveField(rawTitle) : rawTitle,
|
|
88
|
+
carriesTemplate(rawDescription) ? resolveField(rawDescription) : rawDescription,
|
|
89
|
+
...templatedMeta.map((field) => resolveField(authoredMeta?.[field] as string)),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
title = resolvedTitle;
|
|
93
|
+
description = resolvedDescription;
|
|
94
|
+
meta = {
|
|
95
|
+
...authoredMeta,
|
|
96
|
+
...Object.fromEntries(
|
|
97
|
+
templatedMeta.map((field, index) => [field, resolvedMeta[index]]),
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return buildPageMetadata({
|
|
103
|
+
seo: {
|
|
104
|
+
title,
|
|
105
|
+
description,
|
|
106
|
+
siteName: seo?.siteName,
|
|
107
|
+
siteDefaults: { ogImage: seo?.ogImage, ogLocale: seo?.ogLocale },
|
|
108
|
+
meta,
|
|
109
|
+
},
|
|
110
|
+
path,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { RemoteDatasourceDefinition } from "@pantheon-systems/puck-css/serv
|
|
|
3
3
|
export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
4
4
|
{
|
|
5
5
|
id: "swapi",
|
|
6
|
-
label: "Star Wars
|
|
6
|
+
label: "Star Wars character detail",
|
|
7
7
|
description:
|
|
8
8
|
"Person record from SWAPI. Used when editing pages that resolve a numeric person id (see resolution).",
|
|
9
9
|
resolution:
|
|
@@ -29,7 +29,7 @@ export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
id: "monster",
|
|
32
|
-
label: "Pokemon
|
|
32
|
+
label: "Pokemon detail",
|
|
33
33
|
description: "Single pokemon record from GraphQL Pokemon (`getPokemon`).",
|
|
34
34
|
resolution:
|
|
35
35
|
"Resolved from query params (`?monster=...`, `?monsterIndex=...`, `?index=...`, then `?id=...`), preview params, or route params (`:monster`, `:monsterIndex`, `:index`, `:id`).",
|
|
@@ -61,7 +61,7 @@ export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
id: "swapi_list",
|
|
64
|
-
label: "Star Wars
|
|
64
|
+
label: "Star Wars characters list",
|
|
65
65
|
description:
|
|
66
66
|
"First page of SWAPI `/people/` (loaded every request). Use with a List block and `markdownLinks` token.",
|
|
67
67
|
resolution:
|
|
@@ -70,23 +70,28 @@ export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
|
70
70
|
{
|
|
71
71
|
path: "items",
|
|
72
72
|
description:
|
|
73
|
-
"Array of `{ id, name }` rows from SWAPI `/people/`. In an Array field, set value to `{{ swapi_list.items }}` to hydrate cards/rows.",
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
path: "items.0.url",
|
|
77
|
-
description:
|
|
78
|
-
"Canonical SWAPI URL for each item (available as `item.url` in per-card templates).",
|
|
73
|
+
"Array of `{ id, name, url }` rows from SWAPI `/people/`. In an Array field, set value to `{{ swapi_list.items }}` to hydrate cards/rows.",
|
|
79
74
|
},
|
|
80
75
|
{
|
|
81
76
|
path: "markdownLinks",
|
|
82
77
|
description:
|
|
83
78
|
'In a List block\'s items field: `{{ swapi_list.markdownLinks "/jedi/{id}" }}` (or bare `{{ swapi_list.markdownLinks }}`) -- expands to one `[name](/jedi/n)` line per person.',
|
|
84
79
|
},
|
|
80
|
+
{ path: "items[].id", description: "Person ID (extracted from URL)" },
|
|
81
|
+
{ path: "items[].name", description: "Character name" },
|
|
82
|
+
{ path: "items[].height", description: "Height" },
|
|
83
|
+
{ path: "items[].mass", description: "Mass" },
|
|
84
|
+
{ path: "items[].hair_color", description: "Hair color" },
|
|
85
|
+
{ path: "items[].skin_color", description: "Skin color" },
|
|
86
|
+
{ path: "items[].eye_color", description: "Eye color" },
|
|
87
|
+
{ path: "items[].birth_year", description: "Birth year" },
|
|
88
|
+
{ path: "items[].gender", description: "Gender" },
|
|
89
|
+
{ path: "items[].url", description: "Canonical person URL" },
|
|
85
90
|
],
|
|
86
91
|
},
|
|
87
92
|
{
|
|
88
93
|
id: "monster_list",
|
|
89
|
-
label: "Pokemon
|
|
94
|
+
label: "Pokemon list",
|
|
90
95
|
description:
|
|
91
96
|
"Pokemon list from GraphQL Pokemon `getAllPokemon` (loaded every request).",
|
|
92
97
|
resolution:
|
|
@@ -97,23 +102,16 @@ export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
|
97
102
|
description:
|
|
98
103
|
"Array of `{ index, name, url }` rows from `getAllPokemon` (mapped from `key` + `species`). In an Array field, set `{{ monster_list.items }}` to hydrate cards/rows.",
|
|
99
104
|
},
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
path: "items.0.name",
|
|
106
|
-
description: "Display name (for example `Bulbasaur`).",
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
path: "items.0.url",
|
|
110
|
-
description: "Synthetic URL path for per-item links.",
|
|
111
|
-
},
|
|
105
|
+
{ path: "items[].index", description: "Pokemon slug (from key)" },
|
|
106
|
+
{ path: "items[].name", description: "Pokemon name (from species)" },
|
|
107
|
+
{ path: "items[].num", description: "Pokedex number" },
|
|
108
|
+
{ path: "items[].sprite", description: "Sprite URL" },
|
|
109
|
+
{ path: "items[].url", description: "URL path for this pokemon" },
|
|
112
110
|
],
|
|
113
111
|
},
|
|
114
112
|
{
|
|
115
113
|
id: "article",
|
|
116
|
-
label: "
|
|
114
|
+
label: "Article detail",
|
|
117
115
|
description:
|
|
118
116
|
"Single Content Publisher article resolved from query/preview/route params.",
|
|
119
117
|
resolution:
|
|
@@ -131,7 +129,7 @@ export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
|
131
129
|
},
|
|
132
130
|
{
|
|
133
131
|
id: "article_list",
|
|
134
|
-
label: "
|
|
132
|
+
label: "Articles list",
|
|
135
133
|
description:
|
|
136
134
|
"Article list from Content Publisher, loaded every request for list rendering.",
|
|
137
135
|
resolution:
|
|
@@ -142,13 +140,10 @@ export const REMOTE_DATASOURCE_REGISTRY: RemoteDatasourceDefinition[] = [
|
|
|
142
140
|
description:
|
|
143
141
|
"Array of normalized `{ id, title, slug?, url? }` rows. Use `{{ article_list.items }}` to hydrate array/list blocks.",
|
|
144
142
|
},
|
|
145
|
-
{ path: "items.
|
|
146
|
-
{ path: "items.
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
description: "Article slug/path alias if present.",
|
|
150
|
-
},
|
|
151
|
-
{ path: "items.0.url", description: "Canonical URL if present." },
|
|
143
|
+
{ path: "items[].id", description: "Article identifier" },
|
|
144
|
+
{ path: "items[].title", description: "Article title" },
|
|
145
|
+
{ path: "items[].slug", description: "Slug/path alias" },
|
|
146
|
+
{ path: "items[].url", description: "Canonical article URL" },
|
|
152
147
|
],
|
|
153
148
|
},
|
|
154
149
|
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed vocabularies shared by the renderer and the editor.
|
|
3
|
+
*
|
|
4
|
+
* Next types `og:type` and `twitter:card` as unions rather than strings, so
|
|
5
|
+
* buildPageMetadata validates against these lists and falls back rather than
|
|
6
|
+
* letting an unrecognised value reach the tag. The Puck root config builds its
|
|
7
|
+
* dropdown options from the same lists, so an option cannot offer a value the
|
|
8
|
+
* renderer will reject.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const OG_TYPES = ["website", "article", "book", "profile"] as const;
|
|
12
|
+
|
|
13
|
+
export const TWITTER_CARDS = [
|
|
14
|
+
"summary",
|
|
15
|
+
"summary_large_image",
|
|
16
|
+
"player",
|
|
17
|
+
"app",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type OgType = (typeof OG_TYPES)[number];
|
|
21
|
+
export type TwitterCard = (typeof TWITTER_CARDS)[number];
|