@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.
Files changed (53) hide show
  1. package/README.md +114 -0
  2. package/lib/cli.js +1 -1
  3. package/lib/messages.js +1 -1
  4. package/package.json +11 -5
  5. package/template/.env.example +27 -6
  6. package/template/__tests__/ai-generate.test.ts +74 -0
  7. package/template/__tests__/auth-route.test.ts +1 -1
  8. package/template/__tests__/chatbot-flag-wiring.test.ts +21 -1
  9. package/template/__tests__/editor-integration.test.ts +1 -1
  10. package/template/__tests__/editor-route-group.test.ts +1 -1
  11. package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
  12. package/template/__tests__/page-seo-meta.test.ts +98 -0
  13. package/template/__tests__/paragraph-block.test.ts +24 -22
  14. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  15. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  16. package/template/__tests__/puck-root-meta.test.ts +102 -0
  17. package/template/__tests__/puck-root-selects.test.ts +86 -0
  18. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  19. package/template/__tests__/seo-metadata-meta.test.ts +180 -0
  20. package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
  21. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  22. package/template/app/[...puckPath]/page.tsx +22 -4
  23. package/template/app/layout.tsx +11 -1
  24. package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
  25. package/template/app/styles.css +18 -1
  26. package/template/components/p1-lockup.tsx +6 -26
  27. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  28. package/template/components/puck/data-list-block/index.ts +1 -0
  29. package/template/components/puck/grid-block.tsx +1 -1
  30. package/template/components/puck/paragraph-block.tsx +7 -2
  31. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  32. package/template/components/puck/paragraph-markdown.tsx +15 -0
  33. package/template/components/puck/root.tsx +185 -4
  34. package/template/constants/assets.ts +1 -0
  35. package/template/eslint.config.js +20 -4
  36. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  37. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  38. package/template/lib/monsters-api.ts +14 -8
  39. package/template/lib/page-seo.ts +44 -11
  40. package/template/lib/remote-datasources.ts +26 -31
  41. package/template/lib/seo-metadata.consts.ts +21 -0
  42. package/template/lib/seo-metadata.ts +96 -15
  43. package/template/lib/swapi.ts +5 -5
  44. package/template/middleware.ts +15 -0
  45. package/template/next.config.mjs +11 -0
  46. package/template/package.json +14 -11
  47. package/template/pnpm-workspace.yaml +3 -0
  48. package/template/public/images/p1_logo.svg +5 -12
  49. package/template/public/images/p1_logo_reverse.svg +5 -0
  50. package/template/puck.config.tsx +3 -1
  51. package/template/tsconfig/nextjs.json +1 -1
  52. package/template/vitest.config.ts +20 -0
  53. package/template/next-env.d.ts +0 -6
@@ -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
- '@typescript-eslint/consistent-type-definitions': 'warn',
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
- '@typescript-eslint/no-empty-function': 'warn',
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
- '@typescript-eslint/no-dynamic-delete': 'warn',
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
- '@typescript-eslint/no-invalid-void-type': 'warn',
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
- types
54
- abilities { first second hidden special }
55
- hp attack defense spAtk spDef speed sprite
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: Array<{ index: string; name: string; url?: string }> = [];
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 {
@@ -11,6 +11,7 @@ import {
11
11
  } from "@pantheon-systems/puck-css/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,8 +20,25 @@ type PageData = Awaited<ReturnType<typeof getPage>>;
19
20
  const DEFAULT_EDITOR_TITLE = "My Puck Editor";
20
21
 
21
22
  /**
22
- * Produces the per-page <head> Metadata for a route (PCC-3407). Title and
23
- * description are template-allowed properties.
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,
@@ -33,16 +51,21 @@ export async function resolvePageMetadata({
33
51
  }): Promise<Metadata> {
34
52
  const rootProps: Record<string, unknown> | undefined = pageData?.root.props;
35
53
  const seo: Partial<SeoMetadata> | undefined = rootProps?._seo;
54
+ const authoredMeta = rootProps?._meta as PageMetaFields | undefined;
36
55
  const rootTitle = rootProps?.title as string | undefined;
37
56
  const rawTitle = rootTitle === DEFAULT_EDITOR_TITLE ? undefined : rootTitle;
38
57
  const rawDescription = rootProps?.description as string | undefined;
39
58
 
59
+ const templatedMeta = TEMPLATED_META_FIELDS.filter((field) =>
60
+ carriesTemplate(authoredMeta?.[field]),
61
+ );
40
62
  const needsTemplates =
41
- (typeof rawTitle === "string" && rawTitle.includes("{{")) ||
42
- (typeof rawDescription === "string" && rawDescription.includes("{{"));
63
+ carriesTemplate(rawTitle) || carriesTemplate(rawDescription) || templatedMeta.length > 0;
43
64
 
44
65
  let title = rawTitle;
45
66
  let description = rawDescription;
67
+ let meta = authoredMeta;
68
+
46
69
  if (needsTemplates && pageData) {
47
70
  const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
48
71
  const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
@@ -55,17 +78,25 @@ export async function resolvePageMetadata({
55
78
  referencedDatasourceIds,
56
79
  });
57
80
 
58
- const [resolvedTitle, resolvedDescription] = await Promise.all([
59
- typeof rawTitle === "string"
60
- ? resolveStringTemplates(rawTitle, context)
61
- : rawTitle,
62
- typeof rawDescription === "string"
63
- ? resolveStringTemplates(rawDescription, context)
64
- : rawDescription,
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)),
65
90
  ]);
66
91
 
67
92
  title = resolvedTitle;
68
93
  description = resolvedDescription;
94
+ meta = {
95
+ ...authoredMeta,
96
+ ...Object.fromEntries(
97
+ templatedMeta.map((field, index) => [field, resolvedMeta[index]]),
98
+ ),
99
+ };
69
100
  }
70
101
 
71
102
  return buildPageMetadata({
@@ -73,6 +104,8 @@ export async function resolvePageMetadata({
73
104
  title,
74
105
  description,
75
106
  siteName: seo?.siteName,
107
+ siteDefaults: { ogImage: seo?.ogImage, ogLocale: seo?.ogLocale },
108
+ meta,
76
109
  },
77
110
  path,
78
111
  });
@@ -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 API (person)",
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 GraphQL (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 API (people index)",
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 GraphQL (pokemon index)",
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
- path: "items.0.index",
102
- description: "Pokemon slug/index (for example `bulbasaur`).",
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: "Content Publisher (article)",
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: "Content Publisher (articles index)",
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.0.id", description: "Article identifier." },
146
- { path: "items.0.title", description: "Article title." },
147
- {
148
- path: "items.0.slug",
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); only siteName arrives from the
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 { description, canonicalUrl } = seo ?? {};
86
+ const meta = seo?.meta ?? {};
87
+
31
88
  const title = seo?.title || undefined;
32
- const siteName = seo?.siteName ?? process.env.NEXT_PUBLIC_SITE_NAME;
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
- ...(canonical ? { alternates: { canonical } } : {}),
40
- openGraph: {
41
- type: "website",
42
- title,
43
- description,
44
- ...(canonical ? { url: canonical } : {}),
45
- ...(siteName ? { siteName } : {}),
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
  }
@@ -39,23 +39,23 @@ async function fetchSwapiPerson(
39
39
  }
40
40
  }
41
41
 
42
- async function fetchSwapiPeopleList(
43
- fetchImpl: typeof fetch,
44
- ): Promise<Array<{ id: string; name: string; url?: string }>> {
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: Array<{ id: string; name: string; url?: string }> = [];
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
+ };
@@ -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
  };
@@ -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
- "lint": "eslint .",
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.8.0",
16
- "@pantheon-systems/p1-ai-chat": "^0.1.2",
17
- "@pantheon-systems/p1-media": "^0.4.2",
18
- "@pantheon-systems/p1-next-sdk": "^0.8.0",
18
+ "@pantheon-systems/css-client": "^0.10.0",
19
+ "@pantheon-systems/p1-ai-chat": "workspace:*",
20
+ "@pantheon-systems/p1-media": "workspace:*",
21
+ "@pantheon-systems/p1-next-sdk": "^0.10.0",
19
22
  "@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.51",
20
- "@pantheon-systems/puck-css": "^0.8.0",
23
+ "@pantheon-systems/puck-css": "^0.10.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.6",
28
- "postcss": "^8.5.12",
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": "^4.7.0",
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",