@pantheon-systems/create-p1-starter-kit 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/lib/cli.js +1 -1
- package/lib/messages.js +1 -1
- package/package.json +11 -5
- package/template/.env.example +27 -6
- package/template/__tests__/ai-generate.test.ts +74 -0
- package/template/__tests__/auth-route.test.ts +1 -1
- package/template/__tests__/chatbot-flag-wiring.test.ts +21 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +1 -1
- package/template/__tests__/image-block.test.tsx +48 -0
- package/template/__tests__/page-seo-meta-templates.test.ts +145 -0
- package/template/__tests__/page-seo-meta.test.ts +97 -0
- package/template/__tests__/page-seo.test.ts +4 -6
- package/template/__tests__/paragraph-block.test.ts +24 -22
- package/template/__tests__/paragraph-editor-text.test.ts +79 -0
- package/template/__tests__/published-page-404.test.ts +65 -0
- package/template/__tests__/puck-root-guidance.test.ts +109 -0
- package/template/__tests__/puck-root-meta.test.ts +102 -0
- package/template/__tests__/puck-root-selects.test.ts +86 -0
- package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
- package/template/__tests__/seo-metadata-meta.test.ts +180 -0
- package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
- package/template/__tests__/styles-canvas-scope.test.ts +50 -0
- package/template/app/[...puckPath]/page.tsx +67 -67
- package/template/app/layout.tsx +11 -1
- package/template/app/not-found.tsx +11 -0
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
- package/template/app/page.tsx +20 -22
- package/template/app/styles.css +18 -1
- package/template/components/content-unavailable.tsx +22 -0
- package/template/components/p1-lockup.tsx +6 -26
- package/template/components/page-missing.tsx +49 -0
- package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
- package/template/components/puck/data-list-block/index.ts +1 -0
- package/template/components/puck/grid-block.tsx +1 -1
- package/template/components/puck/image-block.tsx +22 -1
- package/template/components/puck/paragraph-block.tsx +7 -2
- package/template/components/puck/paragraph-editor-text.tsx +84 -0
- package/template/components/puck/paragraph-markdown.tsx +15 -0
- package/template/components/puck/root.tsx +185 -4
- package/template/constants/assets.ts +1 -0
- package/template/eslint.config.js +20 -4
- package/template/lib/chatbot-flag/ai-generate.ts +23 -0
- package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
- package/template/lib/monsters-api.ts +14 -8
- package/template/lib/page-seo.ts +46 -16
- package/template/lib/remote-datasources.ts +26 -31
- package/template/lib/seo-metadata.consts.ts +21 -0
- package/template/lib/seo-metadata.ts +96 -15
- package/template/lib/swapi.ts +5 -5
- package/template/middleware.ts +15 -0
- package/template/next.config.mjs +11 -0
- package/template/package.json +15 -12
- package/template/pnpm-workspace.yaml +3 -0
- package/template/public/images/p1_logo.svg +5 -12
- package/template/public/images/p1_logo_reverse.svg +5 -0
- package/template/puck.config.tsx +3 -1
- package/template/tsconfig/nextjs.json +1 -1
- package/template/vitest.config.ts +20 -0
- package/template/next-env.d.ts +0 -6
|
@@ -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 {
|
package/template/lib/page-seo.ts
CHANGED
|
@@ -6,11 +6,12 @@ import type {
|
|
|
6
6
|
import {
|
|
7
7
|
loadRemoteDatasourceContext,
|
|
8
8
|
extractReferencedDatasourceIds,
|
|
9
|
-
listRouteTemplateKeysFromDatabase,
|
|
10
9
|
resolveStringTemplates,
|
|
11
10
|
} from "@pantheon-systems/puck-css/server";
|
|
11
|
+
import { loadRouteTemplateKeys } from "@pantheon-systems/p1-next-sdk/server";
|
|
12
12
|
import { REMOTE_DATASOURCE_FETCHERS } from "./remote-datasource-fetchers";
|
|
13
13
|
import { buildPageMetadata } from "./seo-metadata";
|
|
14
|
+
import type { PageMetaFields } from "./seo-metadata";
|
|
14
15
|
|
|
15
16
|
type PageData = Awaited<ReturnType<typeof getPage>>;
|
|
16
17
|
|
|
@@ -19,35 +20,54 @@ type PageData = Awaited<ReturnType<typeof getPage>>;
|
|
|
19
20
|
const DEFAULT_EDITOR_TITLE = "My Puck Editor";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
24
42
|
*/
|
|
25
43
|
export async function resolvePageMetadata({
|
|
26
44
|
pageData,
|
|
27
45
|
path,
|
|
28
|
-
searchParams,
|
|
29
46
|
}: {
|
|
30
47
|
pageData: PageData;
|
|
31
48
|
path: string;
|
|
32
|
-
searchParams: Record<string, string | string[] | undefined>;
|
|
33
49
|
}): Promise<Metadata> {
|
|
34
50
|
const rootProps: Record<string, unknown> | undefined = pageData?.root.props;
|
|
35
51
|
const seo: Partial<SeoMetadata> | undefined = rootProps?._seo;
|
|
52
|
+
const authoredMeta = rootProps?._meta as PageMetaFields | undefined;
|
|
36
53
|
const rootTitle = rootProps?.title as string | undefined;
|
|
37
54
|
const rawTitle = rootTitle === DEFAULT_EDITOR_TITLE ? undefined : rootTitle;
|
|
38
55
|
const rawDescription = rootProps?.description as string | undefined;
|
|
39
56
|
|
|
57
|
+
const templatedMeta = TEMPLATED_META_FIELDS.filter((field) =>
|
|
58
|
+
carriesTemplate(authoredMeta?.[field]),
|
|
59
|
+
);
|
|
40
60
|
const needsTemplates =
|
|
41
|
-
(
|
|
42
|
-
(typeof rawDescription === "string" && rawDescription.includes("{{"));
|
|
61
|
+
carriesTemplate(rawTitle) || carriesTemplate(rawDescription) || templatedMeta.length > 0;
|
|
43
62
|
|
|
44
63
|
let title = rawTitle;
|
|
45
64
|
let description = rawDescription;
|
|
65
|
+
let meta = authoredMeta;
|
|
66
|
+
|
|
46
67
|
if (needsTemplates && pageData) {
|
|
47
|
-
const routeTemplateKeys = await
|
|
68
|
+
const routeTemplateKeys = await loadRouteTemplateKeys();
|
|
48
69
|
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
49
70
|
const context = await loadRemoteDatasourceContext({
|
|
50
|
-
searchParams,
|
|
51
71
|
fetchImpl: fetch,
|
|
52
72
|
pagePath: path,
|
|
53
73
|
routeTemplateKeys,
|
|
@@ -55,17 +75,25 @@ export async function resolvePageMetadata({
|
|
|
55
75
|
referencedDatasourceIds,
|
|
56
76
|
});
|
|
57
77
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
// One context load, then every templated value resolved against it. Only
|
|
79
|
+
// the fields that carry a template are resolved, so an ordinary page with a
|
|
80
|
+
// templated title does not pay for eight passes.
|
|
81
|
+
const resolveField = async (value: string) => resolveStringTemplates(value, context);
|
|
82
|
+
|
|
83
|
+
const [resolvedTitle, resolvedDescription, ...resolvedMeta] = await Promise.all([
|
|
84
|
+
carriesTemplate(rawTitle) ? resolveField(rawTitle) : rawTitle,
|
|
85
|
+
carriesTemplate(rawDescription) ? resolveField(rawDescription) : rawDescription,
|
|
86
|
+
...templatedMeta.map((field) => resolveField(authoredMeta?.[field] as string)),
|
|
65
87
|
]);
|
|
66
88
|
|
|
67
89
|
title = resolvedTitle;
|
|
68
90
|
description = resolvedDescription;
|
|
91
|
+
meta = {
|
|
92
|
+
...authoredMeta,
|
|
93
|
+
...Object.fromEntries(
|
|
94
|
+
templatedMeta.map((field, index) => [field, resolvedMeta[index]]),
|
|
95
|
+
),
|
|
96
|
+
};
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
return buildPageMetadata({
|
|
@@ -73,6 +101,8 @@ export async function resolvePageMetadata({
|
|
|
73
101
|
title,
|
|
74
102
|
description,
|
|
75
103
|
siteName: seo?.siteName,
|
|
104
|
+
siteDefaults: { ogImage: seo?.ogImage, ogLocale: seo?.ogLocale },
|
|
105
|
+
meta,
|
|
76
106
|
},
|
|
77
107
|
path,
|
|
78
108
|
});
|
|
@@ -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];
|
|
@@ -1,15 +1,64 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
+
import { OG_TYPES, TWITTER_CARDS } from "./seo-metadata.consts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authored page metadata, stored at `root.props._meta`. Empty means inherit: a
|
|
6
|
+
* blank field resolves from the page's own title/description at render time
|
|
7
|
+
* rather than having been copied when the page was created.
|
|
8
|
+
*/
|
|
9
|
+
export interface PageMetaFields {
|
|
10
|
+
ogTitle?: string;
|
|
11
|
+
ogDescription?: string;
|
|
12
|
+
ogType?: string;
|
|
13
|
+
ogImage?: string;
|
|
14
|
+
ogLocale?: string;
|
|
15
|
+
twitterCard?: string;
|
|
16
|
+
twitterTitle?: string;
|
|
17
|
+
twitterImage?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Site-wide fallbacks from the backend's SeoMetadata payload, for the fields a
|
|
22
|
+
* site can sensibly default. They resolve below the page's own values.
|
|
23
|
+
*/
|
|
24
|
+
export interface SiteMetaDefaults {
|
|
25
|
+
ogImage?: string;
|
|
26
|
+
ogLocale?: string;
|
|
27
|
+
}
|
|
2
28
|
|
|
3
29
|
/**
|
|
4
30
|
* Head-side metadata inputs. Title, description, and canonical are derived
|
|
5
|
-
* client-side (root props, request path);
|
|
6
|
-
* backend's SeoMetadata payload.
|
|
31
|
+
* client-side (root props, request path); siteName and the site defaults arrive
|
|
32
|
+
* from the backend's SeoMetadata payload.
|
|
7
33
|
*/
|
|
8
34
|
export interface PageHeadMetadata {
|
|
9
35
|
title?: string;
|
|
10
36
|
description?: string;
|
|
11
37
|
canonicalUrl?: string;
|
|
12
38
|
siteName?: string;
|
|
39
|
+
siteDefaults?: SiteMetaDefaults;
|
|
40
|
+
meta?: PageMetaFields;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The editor offers these as dropdowns built from the same lists, but the API
|
|
45
|
+
* and MCP write the props directly, so the validation stays.
|
|
46
|
+
*/
|
|
47
|
+
function oneOf<T extends readonly string[]>(
|
|
48
|
+
allowed: T,
|
|
49
|
+
authored: string | undefined,
|
|
50
|
+
fallback: T[number],
|
|
51
|
+
): T[number] {
|
|
52
|
+
return authored && (allowed as readonly string[]).includes(authored)
|
|
53
|
+
? (authored as T[number])
|
|
54
|
+
: fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Drops absent values so no empty tag is emitted. */
|
|
58
|
+
function compact<T extends object>(value: T): T {
|
|
59
|
+
return Object.fromEntries(
|
|
60
|
+
Object.entries(value).filter(([, v]) => v !== undefined && v !== ""),
|
|
61
|
+
) as T;
|
|
13
62
|
}
|
|
14
63
|
|
|
15
64
|
/**
|
|
@@ -19,6 +68,13 @@ export interface PageHeadMetadata {
|
|
|
19
68
|
* only when NEXT_PUBLIC_SITE_URL is configured to resolve it — otherwise Next
|
|
20
69
|
* would resolve it against a localhost default, and a wrong canonical is worse
|
|
21
70
|
* than none. An empty title is treated as absent.
|
|
71
|
+
*
|
|
72
|
+
* Social tags resolve as: page value → site default (og:image, og:locale) →
|
|
73
|
+
* derived from title/description → omit. There is no separate template tier:
|
|
74
|
+
* a template's defaults are copied into the page's own `_meta` at create time,
|
|
75
|
+
* so by the time they reach here they are page values and outrank the site
|
|
76
|
+
* default. A field the template left empty is copied in as an empty string,
|
|
77
|
+
* which is falsy, so it still falls through to the site tier.
|
|
22
78
|
*/
|
|
23
79
|
export function buildPageMetadata({
|
|
24
80
|
seo,
|
|
@@ -27,22 +83,47 @@ export function buildPageMetadata({
|
|
|
27
83
|
seo?: PageHeadMetadata;
|
|
28
84
|
path: string;
|
|
29
85
|
}): Metadata {
|
|
30
|
-
const
|
|
86
|
+
const meta = seo?.meta ?? {};
|
|
87
|
+
|
|
31
88
|
const title = seo?.title || undefined;
|
|
32
|
-
const
|
|
89
|
+
const description = seo?.description || undefined;
|
|
33
90
|
const canonical =
|
|
34
|
-
canonicalUrl ?? (process.env.NEXT_PUBLIC_SITE_URL ? path : undefined);
|
|
91
|
+
seo?.canonicalUrl ?? (process.env.NEXT_PUBLIC_SITE_URL ? path : undefined);
|
|
92
|
+
|
|
93
|
+
const siteDefaults = seo?.siteDefaults ?? {};
|
|
94
|
+
const ogImage = meta.ogImage || siteDefaults.ogImage || undefined;
|
|
95
|
+
const ogLocale = meta.ogLocale || siteDefaults.ogLocale || undefined;
|
|
96
|
+
const twitterTitle = meta.twitterTitle || meta.ogTitle || title;
|
|
97
|
+
const twitterImage = meta.twitterImage || ogImage;
|
|
35
98
|
|
|
36
|
-
return {
|
|
99
|
+
return compact({
|
|
37
100
|
title,
|
|
38
101
|
description,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
102
|
+
alternates: canonical ? { canonical } : undefined,
|
|
103
|
+
|
|
104
|
+
openGraph: compact({
|
|
105
|
+
type: oneOf(OG_TYPES, meta.ogType, "website"),
|
|
106
|
+
title: meta.ogTitle || title,
|
|
107
|
+
description: meta.ogDescription || description,
|
|
108
|
+
url: canonical,
|
|
109
|
+
siteName: seo?.siteName ?? process.env.NEXT_PUBLIC_SITE_NAME,
|
|
110
|
+
images: ogImage,
|
|
111
|
+
locale: ogLocale,
|
|
112
|
+
}),
|
|
113
|
+
|
|
114
|
+
// Without a card style X renders nothing, so it is always set when there is
|
|
115
|
+
// anything to show — but an untitled, imageless page gets no twitter tags.
|
|
116
|
+
twitter:
|
|
117
|
+
twitterTitle || twitterImage
|
|
118
|
+
? compact({
|
|
119
|
+
card: oneOf(
|
|
120
|
+
TWITTER_CARDS,
|
|
121
|
+
meta.twitterCard,
|
|
122
|
+
twitterImage ? "summary_large_image" : "summary",
|
|
123
|
+
),
|
|
124
|
+
title: twitterTitle,
|
|
125
|
+
images: twitterImage,
|
|
126
|
+
})
|
|
127
|
+
: undefined,
|
|
128
|
+
});
|
|
48
129
|
}
|
package/template/lib/swapi.ts
CHANGED
|
@@ -39,23 +39,23 @@ async function fetchSwapiPerson(
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
type SwapiItem = { id: string; name: string; url?: string } & Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
async function fetchSwapiPeopleList(fetchImpl: typeof fetch): Promise<SwapiItem[]> {
|
|
45
45
|
try {
|
|
46
46
|
const res = await fetchImpl(`${SWAPI_BASE}/people`);
|
|
47
47
|
if (!res.ok) return [];
|
|
48
48
|
const json: unknown = await res.json();
|
|
49
49
|
const results = Array.isArray(json) ? json : null;
|
|
50
50
|
if (!results) return [];
|
|
51
|
-
const out:
|
|
51
|
+
const out: SwapiItem[] = [];
|
|
52
52
|
for (const row of results) {
|
|
53
53
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
54
54
|
const r = row as Record<string, unknown>;
|
|
55
55
|
const id = swapiPersonIdFromUrl(r.url);
|
|
56
56
|
const name = typeof r.name === "string" ? r.name : "";
|
|
57
57
|
const url = typeof r.url === "string" ? r.url : undefined;
|
|
58
|
-
if (id && name) out.push({ id, name, url });
|
|
58
|
+
if (id && name) out.push({ ...r, id, name, url });
|
|
59
59
|
}
|
|
60
60
|
return out;
|
|
61
61
|
} catch {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createP1Middleware } from "@pantheon-systems/p1-next-sdk/server";
|
|
2
|
+
|
|
3
|
+
const p1Middleware = createP1Middleware({
|
|
4
|
+
cssBaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL ?? "http://localhost:8787",
|
|
5
|
+
apiToken: process.env.CSS_API_KEY ?? "",
|
|
6
|
+
siteId: process.env.NEXT_PUBLIC_CSS_SITE_ID ?? "",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export async function middleware(request: Request) {
|
|
10
|
+
return p1Middleware(request);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const config = {
|
|
14
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
15
|
+
};
|
package/template/next.config.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
1
4
|
export default {
|
|
2
5
|
reactStrictMode: true,
|
|
3
6
|
experimental: {
|
|
@@ -10,4 +13,12 @@ export default {
|
|
|
10
13
|
"@pantheon-systems/puck-css",
|
|
11
14
|
"@pantheon-systems/p1-next-sdk",
|
|
12
15
|
],
|
|
16
|
+
turbopack: {},
|
|
17
|
+
webpack: (config) => {
|
|
18
|
+
config.resolve.alias = {
|
|
19
|
+
...config.resolve.alias,
|
|
20
|
+
yjs: require.resolve("yjs"),
|
|
21
|
+
};
|
|
22
|
+
return config;
|
|
23
|
+
},
|
|
13
24
|
};
|
package/template/package.json
CHANGED
|
@@ -3,29 +3,32 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "next dev",
|
|
7
6
|
"build": "next build",
|
|
7
|
+
"clean": "rm -rf .next out *.tsbuildinfo node_modules/.vite",
|
|
8
|
+
"dev": "next dev",
|
|
9
|
+
"lint": "eslint .",
|
|
10
|
+
"lint:fix": "eslint . --fix",
|
|
8
11
|
"start": "next start",
|
|
12
|
+
"sync:registry": "tsx scripts/sync-puck-registry.ts",
|
|
9
13
|
"test": "vitest run",
|
|
10
|
-
"
|
|
11
|
-
"sync:registry": "tsx scripts/sync-puck-registry.ts"
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
12
15
|
},
|
|
13
16
|
"dependencies": {
|
|
14
17
|
"@pantheon-systems/cpub-react-sdk": "^5.2.1",
|
|
15
|
-
"@pantheon-systems/css-client": "^0.
|
|
16
|
-
"@pantheon-systems/p1-ai-chat": "^0.
|
|
17
|
-
"@pantheon-systems/p1-media": "^0.4.
|
|
18
|
-
"@pantheon-systems/p1-next-sdk": "^0.
|
|
19
|
-
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.
|
|
20
|
-
"@pantheon-systems/puck-css": "^0.
|
|
18
|
+
"@pantheon-systems/css-client": "^0.11.0",
|
|
19
|
+
"@pantheon-systems/p1-ai-chat": "^0.5.0",
|
|
20
|
+
"@pantheon-systems/p1-media": "^0.4.4",
|
|
21
|
+
"@pantheon-systems/p1-next-sdk": "^0.11.0",
|
|
22
|
+
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.66",
|
|
23
|
+
"@pantheon-systems/puck-css": "^0.11.0",
|
|
21
24
|
"@puckeditor/core": "^0.21.1",
|
|
22
25
|
"@tailwindcss/postcss": "^4.2.2",
|
|
23
26
|
"@tailwindcss/typography": "^0.5.16",
|
|
24
27
|
"classnames": "^2.5.1",
|
|
25
28
|
"isomorphic-dompurify": "^3.18.0",
|
|
26
29
|
"launchdarkly-react-client-sdk": "^3.9.2",
|
|
27
|
-
"next": "^16.2.
|
|
28
|
-
"postcss": "^8.5.
|
|
30
|
+
"next": "^16.2.12",
|
|
31
|
+
"postcss": "^8.5.25",
|
|
29
32
|
"react": "^19.2.5",
|
|
30
33
|
"react-dom": "^19.2.5",
|
|
31
34
|
"react-markdown": "^10.1.0",
|
|
@@ -35,7 +38,7 @@
|
|
|
35
38
|
"@types/node": "^20.19.30",
|
|
36
39
|
"@types/react": "^19.2.14",
|
|
37
40
|
"@types/react-dom": "^19.2.3",
|
|
38
|
-
"@vitejs/plugin-react": "^
|
|
41
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
39
42
|
"eslint": "^9.27.0",
|
|
40
43
|
"tsx": "^4.23.1",
|
|
41
44
|
"typescript": "^5.9.3",
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
<svg
|
|
2
|
-
<
|
|
3
|
-
<path d="
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
<path d="M12.4397 13.3102C12.5066 13.3102 12.7741 13.1764 12.7741 12.1063C12.7741 11.0361 12.5735 10.9023 12.4397 10.9023H7.22266L8.22593 13.3102H12.4397Z" fill="#23232D"/>
|
|
7
|
-
<path d="M9.36461 16.1862H12.8426C12.9095 16.1862 13.1771 16.0524 13.1771 14.9823C13.1771 13.9121 12.9764 13.7783 12.8426 13.7783H8.36133L9.36461 16.1862Z" fill="#23232D"/>
|
|
8
|
-
<path d="M12.4403 19.5305H7.69141L8.69468 21.9384H12.4403C12.5071 21.9384 12.7747 21.8046 12.7747 20.7345C12.7078 19.6643 12.5071 19.5305 12.4403 19.5305Z" fill="#23232D"/>
|
|
9
|
-
<path d="M12.9088 16.6545H6.55469L7.55797 19.0624H12.9088C12.9757 19.0624 13.2432 18.9286 13.2432 17.8585C13.1763 16.7883 12.9757 16.6545 12.9088 16.6545Z" fill="#23232D"/>
|
|
10
|
-
<path d="M3.6118 16.1863L2.47475 13.3102H5.08328L6.28721 16.1863H8.76196L6.55475 10.9023H1.13705C0.735737 10.9023 0.468196 10.9023 0.267541 11.5043C0.066885 12.24 0 13.6446 0 16.3869C0 19.1292 -2.59134e-07 20.5338 0.267541 21.2696C0.468196 21.8715 0.668852 21.8715 1.13705 21.8715H5.8859L3.6118 16.1863Z" fill="#23232D"/>
|
|
11
|
-
<path d="M21.0527 23.9409V9.39014H26.5117C27.6315 9.39014 28.569 9.59847 29.3242 10.0151C30.0859 10.4318 30.6621 11.0047 31.0527 11.7339C31.4434 12.4631 31.6387 13.2899 31.6387 14.2144C31.6387 15.1453 31.4401 15.9754 31.043 16.7046C30.6523 17.4272 30.0729 17.9969 29.3047 18.4136C28.5365 18.8237 27.5924 19.0288 26.4727 19.0288H22.8594V16.8608H26.1113C26.7689 16.8608 27.306 16.7502 27.7227 16.5288C28.1458 16.3009 28.4551 15.9884 28.6504 15.5913C28.8522 15.1877 28.9531 14.7287 28.9531 14.2144C28.9531 13.6935 28.8522 13.2378 28.6504 12.8472C28.4551 12.45 28.1458 12.144 27.7227 11.9292C27.306 11.7078 26.7656 11.5972 26.1016 11.5972H23.6895V23.9409H21.0527ZM38.7676 9.39014V23.9409H36.1504V11.9585H36.0625L32.6641 14.1362V11.7144L36.2773 9.39014H38.7676Z" fill="#23232D"/>
|
|
12
|
-
</svg>
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1066 986 2401 1944">
|
|
2
|
+
<g transform="translate(0,4750) scale(0.1,-0.1)" fill="currentColor">
|
|
3
|
+
<path d="M31555 37353 c-105 -343 -302 -965 -332 -1048 -87 -241 -173 -419 -284 -590 -322 -495 -787 -772 -1484 -885 -323 -53 -316 -53 -1507 -57 l-1118 -4 0 -1695 0 -1694 1925 0 1925 0 0 -5490 0 -5490 1900 0 1900 0 0 8520 0 8520 -1449 0 -1450 0 -26 -87z M10860 35245 l0 -2135 5130 0 5130 0 0 -2890 0 -2890 -5130 0 -5130 0 0 -2338 0 -2337 2135 -2135 2135 -2135 0 2332 0 2333 2993 2 2992 3 3 2138 2 2137 2135 0 2135 0 0 2794 0 2795 -157 158 c-845 849 -3390 3431 -3963 4020 l-275 283 -5067 0 -5068 0 0 -2135z"/>
|
|
4
|
+
</g>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1066 986 2401 1944">
|
|
2
|
+
<g transform="translate(0,4750) scale(0.1,-0.1)" fill="#ffffff">
|
|
3
|
+
<path d="M31555 37353 c-105 -343 -302 -965 -332 -1048 -87 -241 -173 -419 -284 -590 -322 -495 -787 -772 -1484 -885 -323 -53 -316 -53 -1507 -57 l-1118 -4 0 -1695 0 -1694 1925 0 1925 0 0 -5490 0 -5490 1900 0 1900 0 0 8520 0 8520 -1449 0 -1450 0 -26 -87z M10860 35245 l0 -2135 5130 0 5130 0 0 -2890 0 -2890 -5130 0 -5130 0 0 -2338 0 -2337 2135 -2135 2135 -2135 0 2332 0 2333 2993 2 2992 3 3 2138 2 2137 2135 0 2135 0 0 2794 0 2795 -157 158 c-845 849 -3390 3431 -3963 4020 l-275 283 -5067 0 -5068 0 0 -2135z"/>
|
|
4
|
+
</g>
|
|
5
|
+
</svg>
|