@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
@@ -0,0 +1,50 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const appDir = resolve(__dirname, "..");
8
+
9
+ // PCC-3499 / PCC-3513: Puck's collectStyles() copies every parent
10
+ // <style>/<link> element into the canvas-preview iframe verbatim (there is
11
+ // no exclusion API), and separately its CopyHostStyles helper syncs this
12
+ // document's <body> attributes (including `class`) onto the iframe's own
13
+ // <body>. That means a bare `body {...}` rule — or even a `body.some-class
14
+ // {...}` rule scoped to a class placed directly on <body> — still matches
15
+ // inside the canvas iframe and can override the canvas's own
16
+ // design-token-based body styling (as happened with a hardcoded
17
+ // `color: #111; background: #fff;` on the Teamworks dev site).
18
+ //
19
+ // The only reset that stays out of the iframe is one keyed off a *child* of
20
+ // <body> that Puck's canvas never renders (the iframe only ever portals the
21
+ // Puck root/block tree into its own #frame-root — never this layout).
22
+ describe("app/styles.css keeps body-level resets out of Puck's canvas iframe", () => {
23
+ const rawCss = readFileSync(resolve(appDir, "app/styles.css"), "utf-8");
24
+ // Strip comments so example selectors mentioned in explanatory comments
25
+ // don't trip the regexes below.
26
+ const css = rawCss.replace(/\/\*[\s\S]*?\*\//g, "");
27
+ const layout = readFileSync(resolve(appDir, "app/layout.tsx"), "utf-8");
28
+
29
+ it("does not define a bare, unscoped `body { ... }` rule", () => {
30
+ // Matches a `body` selector not immediately followed by a combinator/
31
+ // pseudo-class condition (i.e. a plain element selector with no scoping).
32
+ const bareBodySelector = /(^|[^.\w:-])body\s*\{/m;
33
+ expect(bareBodySelector.test(css)).toBe(false);
34
+ });
35
+
36
+ it("does not scope the reset to a class/attribute placed directly on body", () => {
37
+ // e.g. `body.foo {...}` or `body[data-foo] {...}` — these get defeated
38
+ // by Puck's CopyHostStyles, which syncs body's own attributes into the
39
+ // iframe's body too.
40
+ expect(css).not.toMatch(/body(\.[\w-]+|\[[^\]]+\])\s*\{/);
41
+ });
42
+
43
+ it("scopes the margin reset via :has() of a child element", () => {
44
+ expect(css).toMatch(/body:has\(\s*>?\s*\.p1-app-shell\s*\)\s*\{[^}]*margin:\s*0/);
45
+ });
46
+
47
+ it("wraps the real app tree in .p1-app-shell, as a child of <body>", () => {
48
+ expect(layout).toMatch(/<body[^>]*>[\s\S]*<div className="p1-app-shell">/);
49
+ });
50
+ });
@@ -2,6 +2,7 @@
2
2
  * Catch-all route that renders user-facing pages generated by Puck.
3
3
  */
4
4
 
5
+ import { cache } from "react";
5
6
  import type { Metadata } from "next";
6
7
  import {
7
8
  loadRemoteDatasourceContext,
@@ -12,10 +13,26 @@ import {
12
13
  ensureInitialized,
13
14
  pagePathFromCatchAllSegments,
14
15
  } from "@pantheon-systems/puck-css/server";
16
+ import { createCssQueryFetchers } from "@pantheon-systems/p1-next-sdk/server";
15
17
  import { REMOTE_DATASOURCE_FETCHERS } from "../../lib/remote-datasource-fetchers";
16
18
  import { resolvePageMetadata } from "../../lib/page-seo";
17
19
  import { Client } from "./client";
18
20
 
21
+ const getCssQueryFetchers = cache(() => createCssQueryFetchers());
22
+
23
+ // Document namespaces that live alongside pages but are never routable.
24
+ const INTERNAL_PATH_PREFIXES = ["/_registry", "/_redirects"];
25
+
26
+ // Lowercased to match the server, which normalizes document paths to lower case
27
+ // before looking them up — so /_Redirects/x resolves the same record as
28
+ // /_redirects/x and must be refused just the same.
29
+ function isInternalPath(path: string): boolean {
30
+ const normalized = path.toLowerCase();
31
+ return INTERNAL_PATH_PREFIXES.some(
32
+ (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
33
+ );
34
+ }
35
+
19
36
  const initPromise = ensureInitialized({
20
37
  p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
21
38
  p1ApiKey: process.env.CSS_API_KEY,
@@ -36,7 +53,7 @@ export async function generateMetadata({
36
53
  const { puckPath = [] } = await params;
37
54
  const path = pagePathFromCatchAllSegments(puckPath);
38
55
 
39
- if (path.startsWith("/_registry/") || path === "/_registry") {
56
+ if (isInternalPath(path)) {
40
57
  return { title: "Not Found" };
41
58
  }
42
59
 
@@ -55,15 +72,16 @@ export default async function Page({
55
72
  const { puckPath = [] } = await params;
56
73
  const path = pagePathFromCatchAllSegments(puckPath);
57
74
 
58
- if (path.startsWith("/_registry/") || path === "/_registry") {
75
+ if (isInternalPath(path)) {
59
76
  const { notFound } = await import("next/navigation");
60
77
  notFound();
61
78
  }
62
79
 
63
- const [data, searchParamData, routeTemplateKeys] = await Promise.all([
80
+ const [data, searchParamData, routeTemplateKeys, cssQueryFetchers] = await Promise.all([
64
81
  getPage(path),
65
82
  searchParams,
66
83
  listRouteTemplateKeysFromDatabase(),
84
+ getCssQueryFetchers(),
67
85
  ]);
68
86
 
69
87
  if (!data) {
@@ -110,7 +128,7 @@ export default async function Page({
110
128
  fetchImpl: fetch,
111
129
  pagePath: path,
112
130
  routeTemplateKeys,
113
- builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
131
+ builtinFetchers: [...REMOTE_DATASOURCE_FETCHERS, ...cssQueryFetchers],
114
132
  referencedDatasourceIds,
115
133
  });
116
134
  const resolvedData = await resolveDataTemplates(data, context);
@@ -21,7 +21,17 @@ export default function RootLayout({
21
21
  }) {
22
22
  return (
23
23
  <html lang="en">
24
- <body data-rm-theme="light">{children}</body>
24
+ {/* Puck's canvas-preview iframe copies this document's <body> attributes
25
+ onto its own iframe <body> (@puckeditor/core's CopyHostStyles
26
+ syncAttributes()), so a class/attribute placed directly on <body>
27
+ cannot be used to keep a rule out of the iframe. `.p1-app-shell` is
28
+ a child of <body> instead — Puck's canvas iframe never contains it
29
+ (the iframe only ever renders the Puck root/block tree into its own
30
+ #frame-root, not this layout). See styles.css for the scoped reset
31
+ this enables. */}
32
+ <body data-rm-theme="light">
33
+ <div className="p1-app-shell">{children}</div>
34
+ </body>
25
35
  </html>
26
36
  );
27
37
  }
@@ -9,10 +9,13 @@ import {
9
9
  useP1Editor,
10
10
  useP1Plugins,
11
11
  useP1Auth,
12
+ useEditorContext,
13
+ useRemoteDatasourceContext,
12
14
  wrapConfigForEditorPreview,
13
15
  P1QueryProvider,
14
16
  editorPathHref,
15
17
  } from "@pantheon-systems/puck-css";
18
+ import { DatasourceRegistryProvider, DatasourceDataProvider } from "@pantheon-systems/puck-css/fields";
16
19
  import { LoadingMessage } from "@pantheon-systems/puck-css/pds";
17
20
  import { P1NextRouterProvider, editorPagePathFromUrlPath } from "@pantheon-systems/p1-next-sdk";
18
21
  import { createAIChatPlugin } from "@pantheon-systems/p1-ai-chat";
@@ -29,6 +32,8 @@ import { ChatbotFlagProvider } from "../../../../components/ChatbotFlagProvider"
29
32
  import { P1Lockup } from "../../../../components/p1-lockup";
30
33
  import config from "../../../../puck.config";
31
34
  import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "../../../../lib/chatbot-flag/feature-gate";
35
+ import { createGenerateWithAIHandler } from "../../../../lib/chatbot-flag/ai-generate";
36
+ import { getDraftRequestChannel } from "../../../../lib/chatbot-flag/draft-request-channel";
32
37
 
33
38
  const DEFAULT_PAGE_DATA = {
34
39
  root: { props: { title: "New page" } },
@@ -210,17 +215,31 @@ function EditorContent({
210
215
  }) {
211
216
  const router = useRouter();
212
217
  const { getToken } = useP1Auth();
218
+ const { data: editorCtx } = useEditorContext(path);
219
+ const {
220
+ context: remoteDatasourceContext,
221
+ } = useRemoteDatasourceContext(path, editorCtx?.remoteDatasourceRegistry ?? []);
213
222
  const p1Plugins = useP1Plugins(path, config);
214
223
  const mediaPlugin = React.useMemo(() => createMediaPlugin({}), []);
215
224
  const flags = useFlags();
216
225
  const agentUrl = process.env.NEXT_PUBLIC_AGENT_URL;
217
226
  const chatbotEnabled = shouldShowChatbot(flags[CHATBOT_FLAG_KEY], agentUrl);
227
+ // Singleton: survives the remount caused by navigating to the new page.
228
+ const draftRequests = getDraftRequestChannel();
229
+ // The agent creates the page it was asked for, so the editor follows it there. Also what
230
+ // keeps later turns aimed at the new page: their context is built from the open document.
231
+ const handlePageCreated = useCallback(
232
+ (createdPath: string) => {
233
+ router.push(editorPathHref(createdPath));
234
+ },
235
+ [router],
236
+ );
218
237
  const aiPlugin = React.useMemo(
219
238
  () =>
220
239
  chatbotEnabled && agentUrl
221
- ? createAIChatPlugin({ agentUrl })
240
+ ? createAIChatPlugin({ agentUrl, draftRequests, onPageCreated: handlePageCreated })
222
241
  : null,
223
- [chatbotEnabled, agentUrl],
242
+ [chatbotEnabled, agentUrl, draftRequests, handlePageCreated],
224
243
  );
225
244
  const additionalPlugins = React.useMemo(
226
245
  () => (aiPlugin ? [...p1Plugins, mediaPlugin, aiPlugin] : [...p1Plugins, mediaPlugin]),
@@ -268,6 +287,8 @@ function EditorContent({
268
287
  onDocumentNotFound: handleDocumentNotFound,
269
288
  pluginOptions: {
270
289
  onDocumentSelect: handleDocumentSelect,
290
+ onGenerateWithAI: createGenerateWithAIHandler(draftRequests, chatbotEnabled),
291
+ showAIPanelToggle: chatbotEnabled,
271
292
  selectedDocumentPath: path,
272
293
  siteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
273
294
  dashboardUrl: process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL,
@@ -357,8 +378,12 @@ function EditorContent({
357
378
  `}</style>
358
379
  </div>
359
380
  )}
360
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
361
- <Puck key={`${displayState.puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...displayState.puckProps as any} _experimentalFullScreenCanvas={true} />
381
+ <DatasourceRegistryProvider registry={editorCtx?.remoteDatasourceRegistry ?? []}>
382
+ <DatasourceDataProvider context={remoteDatasourceContext}>
383
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
384
+ <Puck key={`${displayState.puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...displayState.puckProps as any} _experimentalFullScreenCanvas={true} />
385
+ </DatasourceDataProvider>
386
+ </DatasourceRegistryProvider>
362
387
  </div>
363
388
  );
364
389
  }
@@ -1,7 +1,24 @@
1
1
  @import "tailwindcss";
2
2
  @plugin "@tailwindcss/typography";
3
+ @source "../../../packages/puck-css/src";
3
4
 
4
- body {
5
+ /* Scoped via :has() rather than a bare `body {...}` selector so this reset
6
+ stays out of Puck's canvas-preview iframe (PCC-3499 / PCC-3513).
7
+ Puck's collectStyles() copies every parent <style>/<link> into the canvas
8
+ iframe verbatim (querySelectorAll('style, link[rel="stylesheet"]'), no
9
+ exclusion API) — so a bare `body {...}` rule here would also match inside
10
+ the iframe and can override the canvas's own design-token-based body
11
+ styling (e.g. `color: var(--color-text-inherited); background:
12
+ var(--color-surface);`), as seen on the Teamworks dev site.
13
+ A class on <body> itself doesn't help: Puck's CopyHostStyles syncs this
14
+ document's <body> attributes (including class) onto the iframe's own
15
+ <body>, so `body.some-class` would still match there too. `.p1-app-shell`
16
+ (see app/layout.tsx) is a *child* of <body> instead — attributes aren't
17
+ synced onto descendants, and Puck's canvas iframe never renders this
18
+ layout's tree at all (only the Puck root/block tree, into its own
19
+ #frame-root) — so `body:has(> .p1-app-shell)` matches on every real
20
+ page/editor render but never inside the canvas iframe. */
21
+ body:has(> .p1-app-shell) {
5
22
  margin: 0;
6
23
  }
7
24
 
@@ -1,33 +1,13 @@
1
1
  "use client";
2
2
 
3
- export function PantheonMark() {
4
- return (
5
- <svg
6
- className="h-7 w-auto block"
7
- viewBox="0 0 105 230"
8
- xmlns="http://www.w3.org/2000/svg"
9
- aria-hidden="true"
10
- >
11
- <polygon fill="#FFDC28" points="17.8,13.4 35.7,56.4 13,56.4 20.5,75.4 66.6,75.4" />
12
- <polygon fill="#FFDC28" points="78.4,170.1 70.8,151.2 60.3,151.2 38.3,97.9 28.9,97.9 50.8,151.2 24,151.2 73.6,213.2 55.7,170.1" />
13
- <path fill="#23232D" d="M84.6,94.3c0.6,0,1.9-0.7,1.9-7.3s-1.3-7.3-1.9-7.3H52.8l6,14.6C58.8,94.3,84.6,94.3,84.6,94.3z" />
14
- <path fill="#23232D" d="M66.1,111.8l21.3,0c0.6,0,1.9-0.7,1.9-7.3s-1.3-7.3-1.9-7.3l-27.4,0L66.1,111.8z" />
15
- <path fill="#23232D" d="M84.6,132.2H55.9l6,14.6h22.7c0.6,0,1.9-0.7,1.9-7.3S85.1,132.2,84.6,132.2z" />
16
- <path fill="#23232D" d="M87.4,114.7H48.7l6,14.6h32.7c0.6,0,1.9-0.7,1.9-7.3S88,114.7,87.4,114.7L87.4,114.7z" />
17
- <path fill="#23232D" d="M31.1,111.9l-6.8-17.6h15.9l7.4,17.6l15.2-0.1L49.5,79.7H16.5c-2.5,0-3.9,0-5.1,3.8c-1.4,4.5-1.5,13.1-1.5,29.7s0.2,25.2,1.5,29.7c1.1,3.8,2.5,3.8,5.1,3.8l29,0l-14.4-35L31.1,111.9L31.1,111.9z" />
18
- <path fill="#23232D" d="M91.7,143h-1.2v-0.8h3.4v0.8h-1.2v3.5h-1L91.7,143L91.7,143z M96.3,146.5l-1.1-3.3v3.3h-0.9v-4.3h1.3l1.1,3.3l1.1-3.3H99v4.3h-0.9v-3.3l-1,3.3H96.3L96.3,146.5z" />
19
- </svg>
20
- );
21
- }
3
+ import { P1_ASSETS } from "../constants/assets";
22
4
 
23
5
  export function P1Lockup() {
24
6
  return (
25
- <div className="inline-flex items-center gap-2.5 mb-6 px-2 -mx-2 rounded-full">
26
- <PantheonMark />
27
- <span className="w-px h-5 bg-gray-200" />
28
- <span className="font-['Inter_Tight','Inter',system-ui,sans-serif] font-semibold text-[19px] tracking-[0.01em] text-[#1a1a2e] leading-none">
29
- P1
30
- </span>
31
- </div>
7
+ <img
8
+ src={P1_ASSETS.LOGO_URL}
9
+ alt="Pantheon P1"
10
+ className="h-7 w-auto block mb-6"
11
+ />
32
12
  );
33
13
  }
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import { createDataListBlock } from "@pantheon-systems/puck-css/fields";
4
+ import { blockPaddingClass } from "../block-padding";
5
+
6
+ export const dataListBlock = createDataListBlock({
7
+ wrapperClassName: blockPaddingClass,
8
+ });
@@ -0,0 +1 @@
1
+ export { dataListBlock } from "./data-list-block";
@@ -1,5 +1,5 @@
1
- import { blockPaddingClass } from "./block-padding";
2
1
  import { Connectable, type ConnectedItem } from "@pantheon-systems/puck-css/connectable";
2
+ import { blockPaddingClass } from "./block-padding";
3
3
 
4
4
  function CardGrid({
5
5
  title,
@@ -2,6 +2,7 @@
2
2
  import { type ReactNode, isValidElement } from "react";
3
3
  import { richtextField } from "@pantheon-systems/puck-css/fields";
4
4
  import { blockPaddingClass } from "./block-padding";
5
+ import { ParagraphEditorText } from "./paragraph-editor-text";
5
6
  import { sanitizeRichtextHtml } from "./sanitize-richtext";
6
7
 
7
8
  export const paragraphBlock = {
@@ -12,9 +13,13 @@ export const paragraphBlock = {
12
13
  defaultProps: {
13
14
  text: "Add your copy here. You can use multiple lines.",
14
15
  },
15
- render: ({ text }: { text?: string | ReactNode }) => {
16
+ render: ({ text, id }: { text?: string | ReactNode; id: string }) => {
16
17
  if (isValidElement(text)) {
17
- return <div className={blockPaddingClass}>{text}</div>;
18
+ return (
19
+ <div className={blockPaddingClass}>
20
+ <ParagraphEditorText text={text} id={id} />
21
+ </div>
22
+ );
18
23
  }
19
24
  return (
20
25
  <div
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { isValidElement, useEffect, useState, useRef, type ReactNode } from "react";
4
+ import {
5
+ useResolvedPreviewState,
6
+ getBlockPropsById,
7
+ } from "@pantheon-systems/puck-css";
8
+ import { sanitizeRichtextHtml } from "./sanitize-richtext";
9
+
10
+ const TEMPLATE_TOKEN_RE = /\{\{[^{}]+\}\}/;
11
+
12
+ function extractRawText(element: ReactNode): string | null {
13
+ if (!isValidElement(element)) return null;
14
+ const props = element.props as Record<string, unknown>;
15
+ return typeof props.value === "string" ? props.value : null;
16
+ }
17
+
18
+ export function ParagraphEditorText({
19
+ text,
20
+ id,
21
+ }: {
22
+ text: ReactNode;
23
+ id: string;
24
+ }) {
25
+ const { data: resolved } = useResolvedPreviewState();
26
+ const [isFocused, setFocused] = useState(false);
27
+ const containerRef = useRef<HTMLDivElement>(null);
28
+
29
+ useEffect(() => {
30
+ function handleMouseDown(e: MouseEvent) {
31
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
32
+ setFocused(false);
33
+ }
34
+ }
35
+ document.addEventListener("mousedown", handleMouseDown);
36
+ return () => document.removeEventListener("mousedown", handleMouseDown);
37
+ }, []);
38
+
39
+ const rawText = extractRawText(text);
40
+ const hasTemplates = rawText != null && TEMPLATE_TOKEN_RE.test(rawText);
41
+
42
+ const resolvedProps = resolved ? getBlockPropsById(resolved, id) : null;
43
+ const resolvedText =
44
+ typeof resolvedProps?.text === "string" ? resolvedProps.text : null;
45
+
46
+ const showResolved = hasTemplates && !isFocused && resolvedText != null;
47
+
48
+ if (!hasTemplates) return <>{text}</>;
49
+
50
+ return (
51
+ <div
52
+ ref={containerRef}
53
+ style={{ position: "relative" }}
54
+ onFocus={() => setFocused(true)}
55
+ onBlur={(e) => {
56
+ if (!containerRef.current?.contains(e.relatedTarget as Node)) {
57
+ setFocused(false);
58
+ }
59
+ }}
60
+ >
61
+ <div style={showResolved ? { color: "transparent" } : undefined}>
62
+ {text}
63
+ </div>
64
+ {showResolved && (
65
+ <div
66
+ style={{
67
+ position: "absolute",
68
+ top: 0,
69
+ left: 0,
70
+ right: 0,
71
+ pointerEvents: "none",
72
+ }}
73
+ >
74
+ <div
75
+ className="prose max-w-prose"
76
+ dangerouslySetInnerHTML={{
77
+ __html: sanitizeRichtextHtml(resolvedText),
78
+ }}
79
+ />
80
+ </div>
81
+ )}
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,15 @@
1
+ import type { Components } from "react-markdown";
2
+
3
+ export const markdownComponents: Components = {
4
+ p: ({ children }) => (
5
+ <p className="m-0 max-w-prose leading-relaxed">{children}</p>
6
+ ),
7
+ a: ({ href, children }) => (
8
+ <a
9
+ href={href}
10
+ className="text-blue-700 underline decoration-blue-700/40 underline-offset-2 hover:decoration-blue-700"
11
+ >
12
+ {children}
13
+ </a>
14
+ ),
15
+ };
@@ -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
- title: { type: "text" as const },
6
- description: { type: "textarea" as const },
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: "My Puck Editor",
190
+ title: DEFAULT_EDITOR_TITLE,
10
191
  },
11
192
  render: (props: { children?: ReactNode; title?: string }) => {
12
193
  const { children } = props;
@@ -1,3 +1,4 @@
1
1
  export const P1_ASSETS = {
2
2
  LOGO_URL: "/images/p1_logo.svg",
3
+ LOGO_REVERSE_URL: "/images/p1_logo_reverse.svg",
3
4
  } as const;