@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.
Files changed (61) 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__/image-block.test.tsx +48 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +145 -0
  13. package/template/__tests__/page-seo-meta.test.ts +97 -0
  14. package/template/__tests__/page-seo.test.ts +4 -6
  15. package/template/__tests__/paragraph-block.test.ts +24 -22
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/published-page-404.test.ts +65 -0
  18. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  19. package/template/__tests__/puck-root-meta.test.ts +102 -0
  20. package/template/__tests__/puck-root-selects.test.ts +86 -0
  21. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  22. package/template/__tests__/seo-metadata-meta.test.ts +180 -0
  23. package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
  24. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  25. package/template/app/[...puckPath]/page.tsx +67 -67
  26. package/template/app/layout.tsx +11 -1
  27. package/template/app/not-found.tsx +11 -0
  28. package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
  29. package/template/app/page.tsx +20 -22
  30. package/template/app/styles.css +18 -1
  31. package/template/components/content-unavailable.tsx +22 -0
  32. package/template/components/p1-lockup.tsx +6 -26
  33. package/template/components/page-missing.tsx +49 -0
  34. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  35. package/template/components/puck/data-list-block/index.ts +1 -0
  36. package/template/components/puck/grid-block.tsx +1 -1
  37. package/template/components/puck/image-block.tsx +22 -1
  38. package/template/components/puck/paragraph-block.tsx +7 -2
  39. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  40. package/template/components/puck/paragraph-markdown.tsx +15 -0
  41. package/template/components/puck/root.tsx +185 -4
  42. package/template/constants/assets.ts +1 -0
  43. package/template/eslint.config.js +20 -4
  44. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  45. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  46. package/template/lib/monsters-api.ts +14 -8
  47. package/template/lib/page-seo.ts +46 -16
  48. package/template/lib/remote-datasources.ts +26 -31
  49. package/template/lib/seo-metadata.consts.ts +21 -0
  50. package/template/lib/seo-metadata.ts +96 -15
  51. package/template/lib/swapi.ts +5 -5
  52. package/template/middleware.ts +15 -0
  53. package/template/next.config.mjs +11 -0
  54. package/template/package.json +15 -12
  55. package/template/pnpm-workspace.yaml +3 -0
  56. package/template/public/images/p1_logo.svg +5 -12
  57. package/template/public/images/p1_logo_reverse.svg +5 -0
  58. package/template/puck.config.tsx +3 -1
  59. package/template/tsconfig/nextjs.json +1 -1
  60. package/template/vitest.config.ts +20 -0
  61. package/template/next-env.d.ts +0 -6
@@ -1,44 +1,44 @@
1
1
  import {
2
- ensureInitialized,
3
- getPage,
4
- listRouteTemplateKeysFromDatabase,
5
2
  resolveDataTemplates,
6
3
  extractReferencedDatasourceIds,
7
4
  loadRemoteDatasourceContext,
8
5
  } from "@pantheon-systems/puck-css/server";
6
+ import {
7
+ loadPublishedPage,
8
+ loadRouteTemplateKeys,
9
+ } from "@pantheon-systems/p1-next-sdk/server";
9
10
  import type { Metadata } from "next";
10
11
  import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
11
12
  import { resolvePageMetadata } from "../lib/page-seo";
12
13
  import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
13
14
  import { Client } from "./[...puckPath]/client";
14
15
 
15
- const initPromise = ensureInitialized({
16
- p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
17
- p1ApiKey: process.env.CSS_API_KEY,
18
- p1SiteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
19
- // Default to "main" when unset: server components (no user token) need a
20
- // branch to list/read documents (e.g. the /structure routes table).
21
- p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID ?? "main",
22
- });
16
+ /**
17
+ * Backstop only: publishing calls revalidatePath("/"), so the home page
18
+ * normally refreshes the moment its content changes. This bounds how long an
19
+ * edit made outside that path can stay stale.
20
+ */
21
+ export const revalidate = 300;
23
22
 
24
23
  export async function generateMetadata(): Promise<Metadata> {
25
- await initPromise;
26
- const pageData = await getPage("/");
27
- if (!pageData) {
24
+ const result = await loadPublishedPage("/");
25
+ if (result.status !== "ok") {
28
26
  return { title: "P1 Starter Kit" };
29
27
  }
30
- return resolvePageMetadata({ pageData, path: "/", searchParams: {} });
28
+ return resolvePageMetadata({ pageData: result.data, path: "/" });
31
29
  }
32
30
 
33
31
  export default async function HomePage() {
34
- await initPromise;
35
- const data = await getPage("/");
32
+ const result = await loadPublishedPage("/");
36
33
 
37
- if (data) {
38
- const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
34
+ // Unlike the catch-all, "/" never 404s: it is a single fixed URL rather than
35
+ // an unbounded crawler surface, and the welcome block is the correct state for
36
+ // a freshly scaffolded site — including one with no backend configured yet.
37
+ if (result.status === "ok") {
38
+ const data = result.data;
39
+ const routeTemplateKeys = await loadRouteTemplateKeys();
39
40
  const referencedDatasourceIds = extractReferencedDatasourceIds(data);
40
41
  const context = await loadRemoteDatasourceContext({
41
- searchParams: {},
42
42
  fetchImpl: fetch,
43
43
  pagePath: "/",
44
44
  routeTemplateKeys,
@@ -75,5 +75,3 @@ export default async function HomePage() {
75
75
  />
76
76
  );
77
77
  }
78
-
79
- export const dynamic = "force-dynamic";
@@ -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
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shown when the content backend could not be reached, as distinct from a path
3
+ * with no page. A published page must not 404 because of a backend blip — that
4
+ * would deindex live content — so this renders a 200 holding page instead, on a
5
+ * render the SDK has already made uncacheable via connection().
6
+ */
7
+
8
+ export function ContentUnavailable() {
9
+ return (
10
+ <main className="flex min-h-screen items-center justify-center bg-gray-50">
11
+ <div className="mx-auto max-w-lg px-6 py-16 text-center">
12
+ <h1 className="text-3xl font-bold tracking-tight text-gray-900">
13
+ This page is temporarily unavailable
14
+ </h1>
15
+ <p className="mt-4 text-gray-600">
16
+ We couldn&apos;t load this content just now. Please try again in a
17
+ moment.
18
+ </p>
19
+ </div>
20
+ </main>
21
+ );
22
+ }
@@ -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,49 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Shown for a path with no published page. Client-side because it renders from
5
+ * the not-found boundary, which receives no params — usePathname is the only
6
+ * way to point "Edit this page" at the path the visitor actually asked for.
7
+ */
8
+
9
+ import { usePathname } from "next/navigation";
10
+
11
+ export function PageMissing() {
12
+ const pathname = usePathname() || "/";
13
+
14
+ return (
15
+ <main className="flex min-h-screen items-center justify-center bg-gray-50">
16
+ <div className="mx-auto max-w-lg px-6 py-16 text-center">
17
+ <h1 className="text-3xl font-bold tracking-tight text-gray-900">
18
+ 404 &ndash; This page doesn&apos;t exist yet
19
+ </h1>
20
+ <p className="mt-4 text-gray-600">
21
+ This page hasn&apos;t been created. Use the editor to build it.
22
+ </p>
23
+
24
+ <nav className="mt-10 flex flex-col gap-3">
25
+ <a
26
+ href={`/p1${pathname}`}
27
+ className="rounded-lg bg-gray-900 px-5 py-3 text-sm font-medium text-white hover:bg-gray-700"
28
+ >
29
+ Edit this page
30
+ </a>
31
+ <a
32
+ href="/p1"
33
+ className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
34
+ >
35
+ Open the Page Editor
36
+ </a>
37
+ <a
38
+ href={`${process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL || "https://content.pantheon.io"}/dashboard/sites`}
39
+ target="_blank"
40
+ rel="noopener noreferrer"
41
+ className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-medium text-gray-900 hover:bg-gray-100"
42
+ >
43
+ P1 Dashboard &rarr;
44
+ </a>
45
+ </nav>
46
+ </div>
47
+ </main>
48
+ );
49
+ }
@@ -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,
@@ -6,18 +6,39 @@ export const imageBlock = {
6
6
  src: { type: "text" as const, label: "Image URL" },
7
7
  alt: { type: "text" as const, label: "Alt text" },
8
8
  caption: { type: "textarea" as const, label: "Caption (optional)" },
9
+ loading: {
10
+ type: "radio" as const,
11
+ label: "Loading",
12
+ options: [
13
+ { label: "Lazy", value: "lazy" },
14
+ { label: "Eager", value: "eager" },
15
+ ],
16
+ },
9
17
  },
10
18
  defaultProps: {
11
19
  src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80",
12
20
  alt: "Mountain landscape",
13
21
  caption: "",
22
+ loading: "lazy",
14
23
  },
15
- render: ({ src, alt, caption }: { src?: string; alt?: string; caption?: string }) => (
24
+ render: ({
25
+ src,
26
+ alt,
27
+ caption,
28
+ loading,
29
+ }: {
30
+ src?: string;
31
+ alt?: string;
32
+ caption?: string;
33
+ loading?: "lazy" | "eager";
34
+ }) => (
16
35
  <figure className={`m-0 ${blockPaddingClass}`}>
17
36
  {src ? (
18
37
  <img
19
38
  src={src}
20
39
  alt={alt || ""}
40
+ loading={loading === "eager" ? "eager" : "lazy"}
41
+ decoding="async"
21
42
  className="block h-auto max-h-[400px] w-full max-w-4xl rounded-lg object-contain"
22
43
  />
23
44
  ) : (
@@ -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;
@@ -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
  },