@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.
- package/README.md +114 -0
- package/lib/cli.js +1 -1
- package/lib/messages.js +1 -1
- package/package.json +11 -5
- package/template/.env.example +27 -6
- 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 +21 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +1 -1
- package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
- package/template/__tests__/page-seo-meta.test.ts +98 -0
- package/template/__tests__/paragraph-block.test.ts +24 -22
- 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__/seo-metadata-meta.test.ts +180 -0
- package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
- package/template/__tests__/styles-canvas-scope.test.ts +50 -0
- package/template/app/[...puckPath]/page.tsx +22 -4
- package/template/app/layout.tsx +11 -1
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +29 -4
- package/template/app/styles.css +18 -1
- 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/paragraph-block.tsx +7 -2
- 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/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 +44 -11
- package/template/lib/remote-datasources.ts +26 -31
- package/template/lib/seo-metadata.consts.ts +21 -0
- package/template/lib/seo-metadata.ts +96 -15
- package/template/lib/swapi.ts +5 -5
- package/template/middleware.ts +15 -0
- package/template/next.config.mjs +11 -0
- package/template/package.json +14 -11
- package/template/pnpm-workspace.yaml +3 -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 +3 -1
- package/template/tsconfig/nextjs.json +1 -1
- package/template/vitest.config.ts +20 -0
- 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 (
|
|
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 (
|
|
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);
|
package/template/app/layout.tsx
CHANGED
|
@@ -21,7 +21,17 @@ export default function RootLayout({
|
|
|
21
21
|
}) {
|
|
22
22
|
return (
|
|
23
23
|
<html lang="en">
|
|
24
|
-
|
|
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
|
-
{
|
|
361
|
-
|
|
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
|
}
|
package/template/app/styles.css
CHANGED
|
@@ -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
|
-
|
|
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";
|
|
@@ -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
|
|
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
|
-
|
|
6
|
-
|
|
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:
|
|
190
|
+
title: DEFAULT_EDITOR_TITLE,
|
|
10
191
|
},
|
|
11
192
|
render: (props: { children?: ReactNode; title?: string }) => {
|
|
12
193
|
const { children } = props;
|