@pantheon-systems/create-p1-starter-kit 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) 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 +14 -5
  5. package/template/.env.example +33 -6
  6. package/template/CHANGELOG.md +20 -0
  7. package/template/__tests__/ai-generate.test.ts +74 -0
  8. package/template/__tests__/auth-route.test.ts +1 -1
  9. package/template/__tests__/chatbot-flag-wiring.test.ts +22 -2
  10. package/template/__tests__/editor-integration.test.ts +2 -2
  11. package/template/__tests__/editor-route-group.test.ts +44 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
  13. package/template/__tests__/page-seo-meta.test.ts +98 -0
  14. package/template/__tests__/page-seo.test.ts +127 -0
  15. package/template/__tests__/paragraph-block.test.ts +39 -0
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  18. package/template/__tests__/puck-root-meta.test.ts +102 -0
  19. package/template/__tests__/puck-root-selects.test.ts +86 -0
  20. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  21. package/template/__tests__/sanitize-richtext.test.ts +58 -0
  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__/seo-metadata.test.ts +105 -0
  25. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  26. package/template/app/[...puckPath]/page.tsx +27 -30
  27. package/template/app/layout.tsx +25 -1
  28. package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +49 -25
  29. package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
  30. package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
  31. package/template/app/p1/(editor)/layout.tsx +13 -0
  32. package/template/app/page.tsx +3 -21
  33. package/template/app/styles.css +19 -1
  34. package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
  35. package/template/components/p1-lockup.tsx +6 -26
  36. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  37. package/template/components/puck/data-list-block/index.ts +1 -0
  38. package/template/components/puck/grid-block.tsx +1 -1
  39. package/template/components/puck/media-figure-block.tsx +12 -0
  40. package/template/components/puck/paragraph-block.tsx +18 -33
  41. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  42. package/template/components/puck/paragraph-markdown.tsx +15 -0
  43. package/template/components/puck/root.tsx +185 -4
  44. package/template/components/puck/sanitize-richtext.ts +44 -0
  45. package/template/constants/assets.ts +1 -0
  46. package/template/eslint.config.js +20 -4
  47. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  48. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  49. package/template/lib/monsters-api.ts +14 -8
  50. package/template/lib/page-seo.ts +112 -0
  51. package/template/lib/remote-datasources.ts +26 -31
  52. package/template/lib/seo-metadata.consts.ts +21 -0
  53. package/template/lib/seo-metadata.ts +129 -0
  54. package/template/lib/swapi.ts +5 -5
  55. package/template/middleware.ts +15 -0
  56. package/template/next.config.mjs +11 -0
  57. package/template/package.json +17 -10
  58. package/template/pnpm-workspace.yaml +6 -0
  59. package/template/public/images/p1_logo.svg +5 -12
  60. package/template/public/images/p1_logo_reverse.svg +5 -0
  61. package/template/puck.config.tsx +6 -2
  62. package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
  63. package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
  64. package/template/scripts/asset-stub-hooks.mjs +25 -1
  65. package/template/scripts/sync-puck-registry.ts +84 -7
  66. package/template/tsconfig/nextjs.json +1 -1
  67. package/template/tsconfig.test.json +6 -0
  68. package/template/vitest.config.ts +25 -0
  69. package/template/next-env.d.ts +0 -6
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useCallback, useState } from "react";
4
- import { useRouter } from "next/navigation";
4
+ import { usePathname, useRouter } from "next/navigation";
5
5
  import { Puck } from "@puckeditor/core";
6
6
  import {
7
7
  P1App,
@@ -9,24 +9,31 @@ 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";
16
- import { P1NextRouterProvider } from "@pantheon-systems/p1-next-sdk";
18
+ import { DatasourceRegistryProvider, DatasourceDataProvider } from "@pantheon-systems/puck-css/fields";
19
+ import { LoadingMessage } from "@pantheon-systems/puck-css/pds";
20
+ import { P1NextRouterProvider, editorPagePathFromUrlPath } from "@pantheon-systems/p1-next-sdk";
17
21
  import { createAIChatPlugin } from "@pantheon-systems/p1-ai-chat";
22
+ import { createMediaPlugin } from "@pantheon-systems/p1-media";
18
23
  import { useFlags } from "launchdarkly-react-client-sdk";
19
24
  import type { Checkpoint } from "@pantheon-systems/puck-css";
20
25
  import type { ContentRole } from "@pantheon-systems/puck-css";
21
- import { P1_ASSETS } from "../../../constants/assets";
26
+ import { P1_ASSETS } from "../../../../constants/assets";
22
27
 
23
28
  import "@pantheon-systems/puck-css/styles.css";
24
29
  import "@pantheon-systems/puck-css/pds/styles.css";
25
30
 
26
- import { ChatbotFlagProvider } from "../../../components/ChatbotFlagProvider";
27
- import { P1Lockup } from "../../../components/p1-lockup";
28
- import config from "../../../puck.config";
29
- import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "../../../lib/chatbot-flag/feature-gate";
31
+ import { ChatbotFlagProvider } from "../../../../components/ChatbotFlagProvider";
32
+ import { P1Lockup } from "../../../../components/p1-lockup";
33
+ import config from "../../../../puck.config";
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";
30
37
 
31
38
  const DEFAULT_PAGE_DATA = {
32
39
  root: { props: { title: "New page" } },
@@ -107,7 +114,11 @@ function P1SignInPage() {
107
114
  );
108
115
  }
109
116
 
110
- export function EditorClientWrapper({ path }: { path: string }) {
117
+ export function EditorClientWrapper() {
118
+ // Rendered from the persistent (editor) layout, so this survives page
119
+ // switches; the edited page is derived from the URL instead of route params.
120
+ const pathname = usePathname();
121
+ const path = editorPagePathFromUrlPath(pathname);
111
122
  const [userRole, setUserRole] = useState<ContentRole>('editor');
112
123
  const lastGoodStateRef = React.useRef<{ puckKey: string; puckProps: any } | null>(null);
113
124
 
@@ -204,21 +215,36 @@ function EditorContent({
204
215
  }) {
205
216
  const router = useRouter();
206
217
  const { getToken } = useP1Auth();
218
+ const { data: editorCtx } = useEditorContext(path);
219
+ const {
220
+ context: remoteDatasourceContext,
221
+ } = useRemoteDatasourceContext(path, editorCtx?.remoteDatasourceRegistry ?? []);
207
222
  const p1Plugins = useP1Plugins(path, config);
223
+ const mediaPlugin = React.useMemo(() => createMediaPlugin({}), []);
208
224
  const flags = useFlags();
209
225
  const agentUrl = process.env.NEXT_PUBLIC_AGENT_URL;
210
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
+ );
211
237
  const aiPlugin = React.useMemo(
212
238
  () =>
213
239
  chatbotEnabled && agentUrl
214
- ? createAIChatPlugin({ agentUrl })
240
+ ? createAIChatPlugin({ agentUrl, draftRequests, onPageCreated: handlePageCreated })
215
241
  : null,
216
- [chatbotEnabled, agentUrl],
242
+ [chatbotEnabled, agentUrl, draftRequests, handlePageCreated],
217
243
  );
218
244
  const additionalPlugins = React.useMemo(
219
- () => (aiPlugin ? [...p1Plugins, aiPlugin] : p1Plugins),
220
- [p1Plugins, aiPlugin],
221
- ) as typeof p1Plugins;
245
+ () => (aiPlugin ? [...p1Plugins, mediaPlugin, aiPlugin] : [...p1Plugins, mediaPlugin]),
246
+ [p1Plugins, mediaPlugin, aiPlugin],
247
+ );
222
248
 
223
249
  const [redirecting, setRedirecting] = React.useState(false);
224
250
 
@@ -261,6 +287,8 @@ function EditorContent({
261
287
  onDocumentNotFound: handleDocumentNotFound,
262
288
  pluginOptions: {
263
289
  onDocumentSelect: handleDocumentSelect,
290
+ onGenerateWithAI: createGenerateWithAIHandler(draftRequests, chatbotEnabled),
291
+ showAIPanelToggle: chatbotEnabled,
264
292
  selectedDocumentPath: path,
265
293
  siteId: process.env.NEXT_PUBLIC_CSS_SITE_ID,
266
294
  dashboardUrl: process.env.NEXT_PUBLIC_P1_ADMIN_DASHBOARD_URL,
@@ -285,20 +313,12 @@ function EditorContent({
285
313
  }, [loading, error, puckKey, puckProps]);
286
314
 
287
315
  if (redirecting) {
288
- return (
289
- <div style={{ textAlign: "center", padding: "4rem", fontFamily: "system-ui" }}>
290
- Redirecting...
291
- </div>
292
- );
316
+ return <LoadingMessage message="Redirecting" data-testid="editor-redirecting" />;
293
317
  }
294
318
 
295
319
  // Show full loading screen only on first load (no previous state)
296
320
  if (loading && !lastGoodStateRef.current) {
297
- return (
298
- <div style={{ textAlign: "center", padding: "4rem", fontFamily: "system-ui" }}>
299
- Loading editor...
300
- </div>
301
- );
321
+ return <LoadingMessage message="Loading document" data-testid="editor-loading" />;
302
322
  }
303
323
 
304
324
  // Show error only if we have no previous state to fall back to
@@ -358,8 +378,12 @@ function EditorContent({
358
378
  `}</style>
359
379
  </div>
360
380
  )}
361
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
362
- <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>
363
387
  </div>
364
388
  );
365
389
  }
@@ -1,9 +1,8 @@
1
- import "@puckeditor/core/puck.css";
2
1
  import { createP1Pages } from "@pantheon-systems/p1-next-sdk/server";
3
- import config from "../../../puck.config";
2
+ import config from "../../../../puck.config";
4
3
  import { EditorClientWrapper } from "./editor-client";
5
4
 
6
- const pages = createP1Pages({
5
+ export const pages = createP1Pages({
7
6
  config,
8
7
  p1BaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL,
9
8
  p1ApiKey: process.env.CSS_API_KEY,
@@ -13,7 +12,3 @@ const pages = createP1Pages({
13
12
  p1BranchId: process.env.NEXT_PUBLIC_CSS_BRANCH_ID ?? "main",
14
13
  EditorClient: EditorClientWrapper,
15
14
  });
16
-
17
- export default pages.Page;
18
- export const generateMetadata = pages.generateMetadata;
19
- export const dynamic = "force-dynamic";
@@ -0,0 +1,5 @@
1
+ import { pages } from "./p1-pages";
2
+
3
+ export default pages.Page;
4
+ export const generateMetadata = pages.generateMetadata;
5
+ export const dynamic = "force-dynamic";
@@ -0,0 +1,13 @@
1
+ import "@puckeditor/core/puck.css";
2
+ import { pages } from "./[[...p1]]/p1-pages";
3
+
4
+ // The editor renders from this layout, NOT the page. The (editor) group is a
5
+ // static segment, so this layout survives navigation between /p1/<pageA> and
6
+ // /p1/<pageB> — a layout inside [[...p1]] would remount on every switch, since
7
+ // Next keys segment cache nodes by param value.
8
+ //
9
+ // Scoping the layout to the (editor) group (instead of app/p1/layout.tsx) is
10
+ // what keeps the editor off sibling routes: /p1/merge and future pages like
11
+ // /p1/settings live outside the group and never render the editor. Add such
12
+ // pages as siblings of (editor), not inside it.
13
+ export default pages.Layout;
@@ -1,14 +1,14 @@
1
- import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
2
1
  import {
3
2
  ensureInitialized,
4
3
  getPage,
5
4
  listRouteTemplateKeysFromDatabase,
6
5
  resolveDataTemplates,
7
- resolveStringTemplates,
8
6
  extractReferencedDatasourceIds,
9
7
  loadRemoteDatasourceContext,
10
8
  } from "@pantheon-systems/puck-css/server";
11
9
  import type { Metadata } from "next";
10
+ import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
11
+ import { resolvePageMetadata } from "../lib/page-seo";
12
12
  import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
13
13
  import { Client } from "./[...puckPath]/client";
14
14
 
@@ -27,25 +27,7 @@ export async function generateMetadata(): Promise<Metadata> {
27
27
  if (!pageData) {
28
28
  return { title: "P1 Starter Kit" };
29
29
  }
30
-
31
- const rawTitle = pageData.root.props?.title;
32
- if (typeof rawTitle !== "string") {
33
- return { title: rawTitle };
34
- }
35
- if (!rawTitle.includes("{{")) {
36
- return { title: rawTitle };
37
- }
38
- const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
39
- const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
40
- const context = await loadRemoteDatasourceContext({
41
- searchParams: {},
42
- fetchImpl: fetch,
43
- pagePath: "/",
44
- routeTemplateKeys,
45
- builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
46
- referencedDatasourceIds,
47
- });
48
- return { title: await resolveStringTemplates(rawTitle, context) };
30
+ return resolvePageMetadata({ pageData, path: "/", searchParams: {} });
49
31
  }
50
32
 
51
33
  export default async function HomePage() {
@@ -1,6 +1,24 @@
1
1
  @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
3
+ @source "../../../packages/puck-css/src";
2
4
 
3
- 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) {
4
22
  margin: 0;
5
23
  }
6
24
 
@@ -12,10 +12,15 @@
12
12
  # 3. Copy this file to .github/workflows/sync-puck-registry.yml.
13
13
  #
14
14
  # Triggers on push to any branch that touches puck.config.tsx or
15
- # components/puck/** the sync script resolves the CSS branch by matching
16
- # the pushed git branch's name (falling back to the site's main branch when
17
- # no CSS_BRANCH_ID is given). A push on a branch with no matching CSS branch
15
+ # components/puck/**. The sync script resolves the CSS branch from the pushed
16
+ # git branch's name: the repo's default branch always targets the site's main
17
+ # CSS branch (whatever the git branch is called), any other ref matches a CSS
18
+ # branch by name. A push on a non-default branch with no matching CSS branch
18
19
  # is not an error: the script logs a skip and exits 0.
20
+ #
21
+ # CSS_DEFAULT_BRANCH defaults to "main" if omitted — repos whose default
22
+ # branch has another name (master, trunk) need the line below (or must set
23
+ # the variable themselves) for default-branch pushes to sync at all.
19
24
  name: Sync Puck Component Registry
20
25
 
21
26
  on:
@@ -49,3 +54,4 @@ jobs:
49
54
  CSS_SITE_ID: ${{ secrets.CSS_SITE_ID }}
50
55
  CSS_REGISTRY_API_KEY: ${{ secrets.CSS_REGISTRY_API_KEY }}
51
56
  CSS_BRANCH_ID: ${{ github.event.inputs.branch_id || github.ref_name }}
57
+ CSS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
@@ -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,
@@ -0,0 +1,12 @@
1
+ import { createMediaFigureBlock } from "@pantheon-systems/p1-media/server";
2
+ import { blockPaddingClass } from "./block-padding";
3
+
4
+ // CDN origin, not the Worker API URL; defaults to production when unset.
5
+ const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE_URL;
6
+
7
+ export const mediaFigureBlock = createMediaFigureBlock({
8
+ mediaBaseUrl: MEDIA_BASE,
9
+ transform: { width: 1200, height: 630, format: "webp" },
10
+ className: `m-0 ${blockPaddingClass} [&>img]:block [&>img]:h-auto [&>img]:max-h-[400px] [&>img]:w-full [&>img]:max-w-4xl [&>img]:rounded-lg [&>img]:object-contain`,
11
+ captionClassName: "mt-3 max-w-4xl text-sm text-neutral-600",
12
+ });
@@ -1,48 +1,33 @@
1
- import { isValidElement, type ReactNode } from "react";
1
+ "use client";
2
+ import { type ReactNode, isValidElement } from "react";
3
+ import { richtextField } from "@pantheon-systems/puck-css/fields";
2
4
  import { blockPaddingClass } from "./block-padding";
3
- import ReactMarkdown from "react-markdown";
4
-
5
- function asMarkdownText(value: unknown): string {
6
- if (typeof value === "string") return value;
7
- if (typeof value === "number" || typeof value === "boolean") return String(value);
8
- return "";
9
- }
5
+ import { ParagraphEditorText } from "./paragraph-editor-text";
6
+ import { sanitizeRichtextHtml } from "./sanitize-richtext";
10
7
 
11
8
  export const paragraphBlock = {
12
9
  label: "Paragraph",
13
10
  fields: {
14
- text: {
15
- type: "textarea" as const,
16
- label: "Text",
17
- contentEditable: true,
18
- },
11
+ text: richtextField,
19
12
  },
20
13
  defaultProps: {
21
14
  text: "Add your copy here. You can use multiple lines.",
22
15
  },
23
- render: ({ text }: { text?: string | ReactNode }) => {
16
+ render: ({ text, id }: { text?: string | ReactNode; id: string }) => {
24
17
  if (isValidElement(text)) {
25
- return <div className={blockPaddingClass}>{text}</div>;
18
+ return (
19
+ <div className={blockPaddingClass}>
20
+ <ParagraphEditorText text={text} id={id} />
21
+ </div>
22
+ );
26
23
  }
27
- const markdown = asMarkdownText(text);
28
24
  return (
29
- <div className={blockPaddingClass}>
30
- <ReactMarkdown
31
- components={{
32
- p: ({ children }) => <p className="m-0 max-w-prose leading-relaxed">{children}</p>,
33
- a: ({ href, children }) => (
34
- <a
35
- href={href}
36
- className="text-blue-700 underline decoration-blue-700/40 underline-offset-2 hover:decoration-blue-700"
37
- >
38
- {children}
39
- </a>
40
- ),
41
- }}
42
- >
43
- {markdown}
44
- </ReactMarkdown>
45
- </div>
25
+ <div
26
+ className={`${blockPaddingClass} prose max-w-prose`}
27
+ dangerouslySetInnerHTML={{
28
+ __html: typeof text === "string" ? sanitizeRichtextHtml(text) : "",
29
+ }}
30
+ />
46
31
  );
47
32
  },
48
33
  };
@@ -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
+ };