@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.
- package/README.md +114 -0
- package/lib/cli.js +1 -1
- package/lib/messages.js +1 -1
- package/package.json +14 -5
- package/template/.env.example +33 -6
- package/template/CHANGELOG.md +20 -0
- package/template/__tests__/ai-generate.test.ts +74 -0
- package/template/__tests__/auth-route.test.ts +1 -1
- package/template/__tests__/chatbot-flag-wiring.test.ts +22 -2
- package/template/__tests__/editor-integration.test.ts +2 -2
- package/template/__tests__/editor-route-group.test.ts +44 -0
- package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
- package/template/__tests__/page-seo-meta.test.ts +98 -0
- package/template/__tests__/page-seo.test.ts +127 -0
- package/template/__tests__/paragraph-block.test.ts +39 -0
- package/template/__tests__/paragraph-editor-text.test.ts +79 -0
- package/template/__tests__/puck-root-guidance.test.ts +109 -0
- package/template/__tests__/puck-root-meta.test.ts +102 -0
- package/template/__tests__/puck-root-selects.test.ts +86 -0
- package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
- package/template/__tests__/sanitize-richtext.test.ts +58 -0
- package/template/__tests__/seo-metadata-meta.test.ts +180 -0
- package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
- package/template/__tests__/seo-metadata.test.ts +105 -0
- package/template/__tests__/styles-canvas-scope.test.ts +50 -0
- package/template/app/[...puckPath]/page.tsx +27 -30
- package/template/app/layout.tsx +25 -1
- package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +49 -25
- package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
- package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
- package/template/app/p1/(editor)/layout.tsx +13 -0
- package/template/app/page.tsx +3 -21
- package/template/app/styles.css +19 -1
- package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
- package/template/components/p1-lockup.tsx +6 -26
- package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
- package/template/components/puck/data-list-block/index.ts +1 -0
- package/template/components/puck/grid-block.tsx +1 -1
- package/template/components/puck/media-figure-block.tsx +12 -0
- package/template/components/puck/paragraph-block.tsx +18 -33
- package/template/components/puck/paragraph-editor-text.tsx +84 -0
- package/template/components/puck/paragraph-markdown.tsx +15 -0
- package/template/components/puck/root.tsx +185 -4
- package/template/components/puck/sanitize-richtext.ts +44 -0
- package/template/constants/assets.ts +1 -0
- package/template/eslint.config.js +20 -4
- package/template/lib/chatbot-flag/ai-generate.ts +23 -0
- package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
- package/template/lib/monsters-api.ts +14 -8
- package/template/lib/page-seo.ts +112 -0
- package/template/lib/remote-datasources.ts +26 -31
- package/template/lib/seo-metadata.consts.ts +21 -0
- package/template/lib/seo-metadata.ts +129 -0
- package/template/lib/swapi.ts +5 -5
- package/template/middleware.ts +15 -0
- package/template/next.config.mjs +11 -0
- package/template/package.json +17 -10
- package/template/pnpm-workspace.yaml +6 -0
- package/template/public/images/p1_logo.svg +5 -12
- package/template/public/images/p1_logo_reverse.svg +5 -0
- package/template/puck.config.tsx +6 -2
- package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
- package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
- package/template/scripts/asset-stub-hooks.mjs +25 -1
- package/template/scripts/sync-puck-registry.ts +84 -7
- package/template/tsconfig/nextjs.json +1 -1
- package/template/tsconfig.test.json +6 -0
- package/template/vitest.config.ts +25 -0
- 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 {
|
|
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 "
|
|
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 "
|
|
27
|
-
import { P1Lockup } from "
|
|
28
|
-
import config from "
|
|
29
|
-
import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "
|
|
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(
|
|
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
|
-
)
|
|
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
|
-
{
|
|
362
|
-
|
|
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 "
|
|
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,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;
|
package/template/app/page.tsx
CHANGED
|
@@ -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() {
|
package/template/app/styles.css
CHANGED
|
@@ -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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 @@
|
|
|
1
|
+
export { dataListBlock } from "./data-list-block";
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
};
|