@pantheon-systems/create-p1-starter-kit 0.6.0 → 0.8.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/package.json +8 -3
- package/template/.env.example +6 -0
- package/template/CHANGELOG.md +32 -0
- package/template/__tests__/chatbot-flag-wiring.test.ts +1 -1
- package/template/__tests__/editor-integration.test.ts +1 -1
- package/template/__tests__/editor-route-group.test.ts +44 -0
- package/template/__tests__/page-seo.test.ts +127 -0
- package/template/__tests__/paragraph-block.test.ts +37 -0
- package/template/__tests__/sanitize-richtext.test.ts +58 -0
- package/template/__tests__/seo-metadata.test.ts +105 -0
- package/template/app/[...puckPath]/page.tsx +5 -26
- package/template/app/layout.tsx +14 -0
- package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +20 -21
- 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 +1 -0
- package/template/ci-examples/github-actions-sync-puck-registry.yml +57 -0
- package/template/components/puck/media-figure-block.tsx +12 -0
- package/template/components/puck/paragraph-block.tsx +11 -31
- package/template/components/puck/sanitize-richtext.ts +44 -0
- package/template/lib/page-seo.ts +79 -0
- package/template/lib/seo-metadata.ts +48 -0
- package/template/package.json +12 -5
- package/template/pnpm-workspace.yaml +3 -0
- package/template/public/images/p1_logo.svg +11 -4
- package/template/puck.config.tsx +3 -1
- package/template/scripts/__tests__/asset-stub-hooks.test.ts +177 -0
- package/template/scripts/__tests__/sync-puck-registry.test.ts +230 -0
- package/template/scripts/asset-stub-hooks.mjs +58 -0
- package/template/scripts/sync-puck-registry.ts +225 -0
- package/template/tsconfig.test.json +6 -0
- package/template/vitest.config.ts +5 -0
|
@@ -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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Optional: syncs this site's Puck component registry to the CSS backend
|
|
2
|
+
# headlessly on every push, without anyone needing to open the editor.
|
|
3
|
+
#
|
|
4
|
+
# This file is NOT active until you copy it into .github/workflows/ yourself
|
|
5
|
+
# — it deliberately lives outside that directory in the scaffolded project so
|
|
6
|
+
# it can never auto-run before you've provisioned secrets.
|
|
7
|
+
#
|
|
8
|
+
# Setup:
|
|
9
|
+
# 1. Create a sat_ site token scoped to write:registry ONLY (do not reuse
|
|
10
|
+
# your read-scoped P1_CSS_API_KEY / SSR token for this).
|
|
11
|
+
# 2. Add repo secrets: CSS_BASE_URL, CSS_SITE_ID, CSS_REGISTRY_API_KEY.
|
|
12
|
+
# 3. Copy this file to .github/workflows/sync-puck-registry.yml.
|
|
13
|
+
#
|
|
14
|
+
# Triggers on push to any branch that touches puck.config.tsx or
|
|
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
|
|
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.
|
|
24
|
+
name: Sync Puck Component Registry
|
|
25
|
+
|
|
26
|
+
on:
|
|
27
|
+
push:
|
|
28
|
+
branches:
|
|
29
|
+
- '**'
|
|
30
|
+
paths:
|
|
31
|
+
- 'puck.config.tsx'
|
|
32
|
+
- 'components/puck/**'
|
|
33
|
+
workflow_dispatch:
|
|
34
|
+
inputs:
|
|
35
|
+
branch_id:
|
|
36
|
+
description: 'CSS branch ID/name to sync against (blank = match the current git branch, else site main)'
|
|
37
|
+
required: false
|
|
38
|
+
default: ''
|
|
39
|
+
|
|
40
|
+
jobs:
|
|
41
|
+
sync-registry:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
- uses: actions/setup-node@v4
|
|
46
|
+
with:
|
|
47
|
+
node-version: '22'
|
|
48
|
+
cache: npm
|
|
49
|
+
- run: npm ci
|
|
50
|
+
- name: Sync component registry
|
|
51
|
+
run: npm run sync:registry
|
|
52
|
+
env:
|
|
53
|
+
CSS_BASE_URL: ${{ secrets.CSS_BASE_URL }}
|
|
54
|
+
CSS_SITE_ID: ${{ secrets.CSS_SITE_ID }}
|
|
55
|
+
CSS_REGISTRY_API_KEY: ${{ secrets.CSS_REGISTRY_API_KEY }}
|
|
56
|
+
CSS_BRANCH_ID: ${{ github.event.inputs.branch_id || github.ref_name }}
|
|
57
|
+
CSS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
@@ -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,21 +1,13 @@
|
|
|
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 { sanitizeRichtextHtml } from "./sanitize-richtext";
|
|
10
6
|
|
|
11
7
|
export const paragraphBlock = {
|
|
12
8
|
label: "Paragraph",
|
|
13
9
|
fields: {
|
|
14
|
-
text:
|
|
15
|
-
type: "textarea" as const,
|
|
16
|
-
label: "Text",
|
|
17
|
-
contentEditable: true,
|
|
18
|
-
},
|
|
10
|
+
text: richtextField,
|
|
19
11
|
},
|
|
20
12
|
defaultProps: {
|
|
21
13
|
text: "Add your copy here. You can use multiple lines.",
|
|
@@ -24,25 +16,13 @@ export const paragraphBlock = {
|
|
|
24
16
|
if (isValidElement(text)) {
|
|
25
17
|
return <div className={blockPaddingClass}>{text}</div>;
|
|
26
18
|
}
|
|
27
|
-
const markdown = asMarkdownText(text);
|
|
28
19
|
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>
|
|
20
|
+
<div
|
|
21
|
+
className={`${blockPaddingClass} prose max-w-prose`}
|
|
22
|
+
dangerouslySetInnerHTML={{
|
|
23
|
+
__html: typeof text === "string" ? sanitizeRichtextHtml(text) : "",
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
46
26
|
);
|
|
47
27
|
},
|
|
48
28
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize richtext HTML before it is rendered via `dangerouslySetInnerHTML`.
|
|
5
|
+
*
|
|
6
|
+
* Blocks render editor-authored richtext as an HTML string on the public,
|
|
7
|
+
* server-rendered surface. This is defense-in-depth at the render boundary:
|
|
8
|
+
* it does not rely on the richtext editor's schema or on TipTap's default
|
|
9
|
+
* link-protocol allowlist to be the only thing standing between stored content
|
|
10
|
+
* and the DOM. The allowlist below matches what the richtext toolbar can
|
|
11
|
+
* actually produce (inline formatting + lists + links); anything else —
|
|
12
|
+
* `<script>`, `<img onerror>`, `javascript:`/`data:` hrefs — is stripped.
|
|
13
|
+
*
|
|
14
|
+
* Runs in both Node (SSR) and the browser via isomorphic-dompurify.
|
|
15
|
+
*/
|
|
16
|
+
const ALLOWED_TAGS = [
|
|
17
|
+
"p",
|
|
18
|
+
"br",
|
|
19
|
+
"strong",
|
|
20
|
+
"b",
|
|
21
|
+
"em",
|
|
22
|
+
"i",
|
|
23
|
+
"u",
|
|
24
|
+
"s",
|
|
25
|
+
"ul",
|
|
26
|
+
"ol",
|
|
27
|
+
"li",
|
|
28
|
+
"a",
|
|
29
|
+
"code",
|
|
30
|
+
"span",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const ALLOWED_ATTR = ["href", "target", "rel"];
|
|
34
|
+
|
|
35
|
+
export function sanitizeRichtextHtml(html: string): string {
|
|
36
|
+
return DOMPurify.sanitize(html, {
|
|
37
|
+
ALLOWED_TAGS,
|
|
38
|
+
ALLOWED_ATTR,
|
|
39
|
+
// Explicit protocol allowlist (defense-in-depth over DOMPurify's default,
|
|
40
|
+
// which already rejects javascript:/unknown schemes): only safe link
|
|
41
|
+
// protocols, plus relative/anchor hrefs.
|
|
42
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|ftp:|#|\/|\.)/i,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type {
|
|
3
|
+
SeoMetadata,
|
|
4
|
+
getPage,
|
|
5
|
+
} from "@pantheon-systems/puck-css/server";
|
|
6
|
+
import {
|
|
7
|
+
loadRemoteDatasourceContext,
|
|
8
|
+
extractReferencedDatasourceIds,
|
|
9
|
+
listRouteTemplateKeysFromDatabase,
|
|
10
|
+
resolveStringTemplates,
|
|
11
|
+
} from "@pantheon-systems/puck-css/server";
|
|
12
|
+
import { REMOTE_DATASOURCE_FETCHERS } from "./remote-datasource-fetchers";
|
|
13
|
+
import { buildPageMetadata } from "./seo-metadata";
|
|
14
|
+
|
|
15
|
+
type PageData = Awaited<ReturnType<typeof getPage>>;
|
|
16
|
+
|
|
17
|
+
// Untitled pages carry the editor's defaultProps.title boilerplate
|
|
18
|
+
// (components/puck/root.tsx); never ship it as <title>/og:title.
|
|
19
|
+
const DEFAULT_EDITOR_TITLE = "My Puck Editor";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Produces the per-page <head> Metadata for a route (PCC-3407). Title and
|
|
23
|
+
* description are template-allowed properties.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolvePageMetadata({
|
|
26
|
+
pageData,
|
|
27
|
+
path,
|
|
28
|
+
searchParams,
|
|
29
|
+
}: {
|
|
30
|
+
pageData: PageData;
|
|
31
|
+
path: string;
|
|
32
|
+
searchParams: Record<string, string | string[] | undefined>;
|
|
33
|
+
}): Promise<Metadata> {
|
|
34
|
+
const rootProps: Record<string, unknown> | undefined = pageData?.root.props;
|
|
35
|
+
const seo: Partial<SeoMetadata> | undefined = rootProps?._seo;
|
|
36
|
+
const rootTitle = rootProps?.title as string | undefined;
|
|
37
|
+
const rawTitle = rootTitle === DEFAULT_EDITOR_TITLE ? undefined : rootTitle;
|
|
38
|
+
const rawDescription = rootProps?.description as string | undefined;
|
|
39
|
+
|
|
40
|
+
const needsTemplates =
|
|
41
|
+
(typeof rawTitle === "string" && rawTitle.includes("{{")) ||
|
|
42
|
+
(typeof rawDescription === "string" && rawDescription.includes("{{"));
|
|
43
|
+
|
|
44
|
+
let title = rawTitle;
|
|
45
|
+
let description = rawDescription;
|
|
46
|
+
if (needsTemplates && pageData) {
|
|
47
|
+
const routeTemplateKeys = await listRouteTemplateKeysFromDatabase();
|
|
48
|
+
const referencedDatasourceIds = extractReferencedDatasourceIds(pageData);
|
|
49
|
+
const context = await loadRemoteDatasourceContext({
|
|
50
|
+
searchParams,
|
|
51
|
+
fetchImpl: fetch,
|
|
52
|
+
pagePath: path,
|
|
53
|
+
routeTemplateKeys,
|
|
54
|
+
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
55
|
+
referencedDatasourceIds,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const [resolvedTitle, resolvedDescription] = await Promise.all([
|
|
59
|
+
typeof rawTitle === "string"
|
|
60
|
+
? resolveStringTemplates(rawTitle, context)
|
|
61
|
+
: rawTitle,
|
|
62
|
+
typeof rawDescription === "string"
|
|
63
|
+
? resolveStringTemplates(rawDescription, context)
|
|
64
|
+
: rawDescription,
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
title = resolvedTitle;
|
|
68
|
+
description = resolvedDescription;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return buildPageMetadata({
|
|
72
|
+
seo: {
|
|
73
|
+
title,
|
|
74
|
+
description,
|
|
75
|
+
siteName: seo?.siteName,
|
|
76
|
+
},
|
|
77
|
+
path,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Head-side metadata inputs. Title, description, and canonical are derived
|
|
5
|
+
* client-side (root props, request path); only siteName arrives from the
|
|
6
|
+
* backend's SeoMetadata payload.
|
|
7
|
+
*/
|
|
8
|
+
export interface PageHeadMetadata {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
canonicalUrl?: string;
|
|
12
|
+
siteName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maps head metadata to the page's <head> Metadata. Next replaces (not
|
|
17
|
+
* deep-merges) a page's openGraph over the layout's, so og:type and the env
|
|
18
|
+
* og:site_name fallback must be declared here. A relative canonical is emitted
|
|
19
|
+
* only when NEXT_PUBLIC_SITE_URL is configured to resolve it — otherwise Next
|
|
20
|
+
* would resolve it against a localhost default, and a wrong canonical is worse
|
|
21
|
+
* than none. An empty title is treated as absent.
|
|
22
|
+
*/
|
|
23
|
+
export function buildPageMetadata({
|
|
24
|
+
seo,
|
|
25
|
+
path,
|
|
26
|
+
}: {
|
|
27
|
+
seo?: PageHeadMetadata;
|
|
28
|
+
path: string;
|
|
29
|
+
}): Metadata {
|
|
30
|
+
const { description, canonicalUrl } = seo ?? {};
|
|
31
|
+
const title = seo?.title || undefined;
|
|
32
|
+
const siteName = seo?.siteName ?? process.env.NEXT_PUBLIC_SITE_NAME;
|
|
33
|
+
const canonical =
|
|
34
|
+
canonicalUrl ?? (process.env.NEXT_PUBLIC_SITE_URL ? path : undefined);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
title,
|
|
38
|
+
description,
|
|
39
|
+
...(canonical ? { alternates: { canonical } } : {}),
|
|
40
|
+
openGraph: {
|
|
41
|
+
type: "website",
|
|
42
|
+
title,
|
|
43
|
+
description,
|
|
44
|
+
...(canonical ? { url: canonical } : {}),
|
|
45
|
+
...(siteName ? { siteName } : {}),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/template/package.json
CHANGED
|
@@ -7,17 +7,22 @@
|
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"test": "vitest run",
|
|
10
|
-
"lint": "eslint ."
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"sync:registry": "tsx scripts/sync-puck-registry.ts"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
14
|
"@pantheon-systems/cpub-react-sdk": "^5.2.1",
|
|
14
|
-
"@pantheon-systems/
|
|
15
|
-
"@pantheon-systems/p1-
|
|
16
|
-
"@pantheon-systems/
|
|
17
|
-
"@pantheon-systems/
|
|
15
|
+
"@pantheon-systems/css-client": "^0.8.0",
|
|
16
|
+
"@pantheon-systems/p1-ai-chat": "^0.1.2",
|
|
17
|
+
"@pantheon-systems/p1-media": "^0.4.2",
|
|
18
|
+
"@pantheon-systems/p1-next-sdk": "^0.8.0",
|
|
19
|
+
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.51",
|
|
20
|
+
"@pantheon-systems/puck-css": "^0.8.0",
|
|
18
21
|
"@puckeditor/core": "^0.21.1",
|
|
19
22
|
"@tailwindcss/postcss": "^4.2.2",
|
|
23
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
20
24
|
"classnames": "^2.5.1",
|
|
25
|
+
"isomorphic-dompurify": "^3.18.0",
|
|
21
26
|
"launchdarkly-react-client-sdk": "^3.9.2",
|
|
22
27
|
"next": "^16.2.6",
|
|
23
28
|
"postcss": "^8.5.12",
|
|
@@ -30,7 +35,9 @@
|
|
|
30
35
|
"@types/node": "^20.19.30",
|
|
31
36
|
"@types/react": "^19.2.14",
|
|
32
37
|
"@types/react-dom": "^19.2.3",
|
|
38
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
33
39
|
"eslint": "^9.27.0",
|
|
40
|
+
"tsx": "^4.23.1",
|
|
34
41
|
"typescript": "^5.9.3",
|
|
35
42
|
"vitest": "^4.1.5",
|
|
36
43
|
"@eslint/js": "^9.27.0",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
<svg width="
|
|
2
|
-
<path d="
|
|
3
|
-
<path d="
|
|
4
|
-
<path d="
|
|
1
|
+
<svg width="40" height="33" viewBox="0 0 40 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M1.47059 0L4.41354 7.08983H0.667969L1.8719 10.1666H9.49682L1.47059 0Z" fill="#FFDC28"/>
|
|
3
|
+
<path d="M11.4372 25.7508L10.1664 22.6741H8.42739L4.81559 13.9121H3.27723L6.88903 22.6741H2.47461L10.6346 32.8406L7.69166 25.7508H11.4372Z" fill="#FFDC28"/>
|
|
4
|
+
<path d="M12.4403 19.5305H7.69141L8.69468 21.9384H12.4403C12.5071 21.9384 12.7747 21.8046 12.7747 20.7345C12.7078 19.6643 12.5071 19.5305 12.4403 19.5305Z" fill="#23232D"/>
|
|
5
|
+
<path d="M12.9088 16.6543H6.55469L7.55797 19.0622H12.9088C12.9757 19.0622 13.2432 18.9284 13.2432 17.8582C13.1763 16.7881 12.9757 16.6543 12.9088 16.6543Z" fill="#23232D"/>
|
|
6
|
+
<path d="M12.4397 13.3102C12.5066 13.3102 12.7741 13.1764 12.7741 12.1063C12.7741 11.0361 12.5735 10.9023 12.4397 10.9023H7.22266L8.22593 13.3102H12.4397Z" fill="#23232D"/>
|
|
7
|
+
<path d="M9.36461 16.1862H12.8426C12.9095 16.1862 13.1771 16.0524 13.1771 14.9823C13.1771 13.9121 12.9764 13.7783 12.8426 13.7783H8.36133L9.36461 16.1862Z" fill="#23232D"/>
|
|
8
|
+
<path d="M12.4403 19.5305H7.69141L8.69468 21.9384H12.4403C12.5071 21.9384 12.7747 21.8046 12.7747 20.7345C12.7078 19.6643 12.5071 19.5305 12.4403 19.5305Z" fill="#23232D"/>
|
|
9
|
+
<path d="M12.9088 16.6545H6.55469L7.55797 19.0624H12.9088C12.9757 19.0624 13.2432 18.9286 13.2432 17.8585C13.1763 16.7883 12.9757 16.6545 12.9088 16.6545Z" fill="#23232D"/>
|
|
10
|
+
<path d="M3.6118 16.1863L2.47475 13.3102H5.08328L6.28721 16.1863H8.76196L6.55475 10.9023H1.13705C0.735737 10.9023 0.468196 10.9023 0.267541 11.5043C0.066885 12.24 0 13.6446 0 16.3869C0 19.1292 -2.59134e-07 20.5338 0.267541 21.2696C0.468196 21.8715 0.668852 21.8715 1.13705 21.8715H5.8859L3.6118 16.1863Z" fill="#23232D"/>
|
|
11
|
+
<path d="M21.0527 23.9409V9.39014H26.5117C27.6315 9.39014 28.569 9.59847 29.3242 10.0151C30.0859 10.4318 30.6621 11.0047 31.0527 11.7339C31.4434 12.4631 31.6387 13.2899 31.6387 14.2144C31.6387 15.1453 31.4401 15.9754 31.043 16.7046C30.6523 17.4272 30.0729 17.9969 29.3047 18.4136C28.5365 18.8237 27.5924 19.0288 26.4727 19.0288H22.8594V16.8608H26.1113C26.7689 16.8608 27.306 16.7502 27.7227 16.5288C28.1458 16.3009 28.4551 15.9884 28.6504 15.5913C28.8522 15.1877 28.9531 14.7287 28.9531 14.2144C28.9531 13.6935 28.8522 13.2378 28.6504 12.8472C28.4551 12.45 28.1458 12.144 27.7227 11.9292C27.306 11.7078 26.7656 11.5972 26.1016 11.5972H23.6895V23.9409H21.0527ZM38.7676 9.39014V23.9409H36.1504V11.9585H36.0625L32.6641 14.1362V11.7144L36.2773 9.39014H38.7676Z" fill="#23232D"/>
|
|
5
12
|
</svg>
|
package/template/puck.config.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { headingBlock } from "./components/puck/heading-block";
|
|
|
6
6
|
import { imageBlock } from "./components/puck/image-block";
|
|
7
7
|
import { gridBlock } from "./components/puck/grid-block";
|
|
8
8
|
import { listBlock } from "./components/puck/list-block";
|
|
9
|
+
import { mediaFigureBlock } from "./components/puck/media-figure-block";
|
|
9
10
|
import { paragraphBlock } from "./components/puck/paragraph-block";
|
|
10
11
|
import { quoteBlock } from "./components/puck/quote-block";
|
|
11
12
|
import { puckRoot } from "./components/puck/root";
|
|
@@ -20,7 +21,7 @@ export const config = {
|
|
|
20
21
|
},
|
|
21
22
|
media: {
|
|
22
23
|
title: "Media",
|
|
23
|
-
components: ["ImageBlock"],
|
|
24
|
+
components: ["ImageBlock", "MediaFigureBlock"],
|
|
24
25
|
},
|
|
25
26
|
data: {
|
|
26
27
|
title: "Data",
|
|
@@ -44,6 +45,7 @@ export const config = {
|
|
|
44
45
|
HeadingBlock: headingBlock,
|
|
45
46
|
ParagraphBlock: paragraphBlock,
|
|
46
47
|
ImageBlock: imageBlock,
|
|
48
|
+
MediaFigureBlock: mediaFigureBlock,
|
|
47
49
|
GridBlock: gridBlock,
|
|
48
50
|
QuoteBlock: quoteBlock,
|
|
49
51
|
ListBlock: listBlock,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { extractDescriptors } from "@pantheon-systems/puck-css/registry-sync";
|
|
3
|
+
import { resolve, load, ASSET_STUB_MARKER } from "../asset-stub-hooks.mjs";
|
|
4
|
+
import { filterAssetStubbedDescriptors } from "../sync-puck-registry.js";
|
|
5
|
+
|
|
6
|
+
describe("resolve", () => {
|
|
7
|
+
it("short-circuits CSS imports to an asset-stub URL without calling nextResolve", async () => {
|
|
8
|
+
const nextResolve = vi.fn();
|
|
9
|
+
const result = await resolve("./styles.css", {}, nextResolve);
|
|
10
|
+
expect(result.shortCircuit).toBe(true);
|
|
11
|
+
expect(result.url.startsWith("asset-stub:")).toBe(true);
|
|
12
|
+
expect(nextResolve).not.toHaveBeenCalled();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it.each([
|
|
16
|
+
"./logo.png", "./photo.jpg", "./photo.jpeg", "./icon.svg", "./anim.gif",
|
|
17
|
+
"./banner.webp", "./favicon.ico", "./sprite.bmp", "./photo.avif",
|
|
18
|
+
"./font.woff", "./font.woff2", "./font.ttf", "./font.eot", "./font.otf",
|
|
19
|
+
"./clip.mp4", "./clip.webm", "./clip.mov", "./audio.mp3", "./audio.wav",
|
|
20
|
+
"./theme.scss", "./theme.sass", "./theme.less",
|
|
21
|
+
])("short-circuits %s", async (specifier) => {
|
|
22
|
+
const nextResolve = vi.fn();
|
|
23
|
+
const result = await resolve(specifier, {}, nextResolve);
|
|
24
|
+
expect(result.shortCircuit).toBe(true);
|
|
25
|
+
expect(nextResolve).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("passes .ts specifiers through to nextResolve unchanged", async () => {
|
|
29
|
+
const nextResolve = vi.fn().mockResolvedValue({ url: "file:///abs/path.ts", shortCircuit: true });
|
|
30
|
+
const result = await resolve("./puck.config.tsx", {}, nextResolve);
|
|
31
|
+
expect(nextResolve).toHaveBeenCalledWith("./puck.config.tsx", {});
|
|
32
|
+
expect(result.url).toBe("file:///abs/path.ts");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("passes bare package specifiers through to nextResolve unchanged", async () => {
|
|
36
|
+
const nextResolve = vi.fn().mockResolvedValue({ url: "file:///node_modules/react/index.js", shortCircuit: true });
|
|
37
|
+
await resolve("react", {}, nextResolve);
|
|
38
|
+
expect(nextResolve).toHaveBeenCalledWith("react", {});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("propagates a real module-resolution error from nextResolve instead of masking it", async () => {
|
|
42
|
+
const nextResolve = vi.fn().mockRejectedValue(new Error("Cannot find module"));
|
|
43
|
+
await expect(resolve("./missing-module", {}, nextResolve)).rejects.toThrow("Cannot find module");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("load", () => {
|
|
48
|
+
it("returns a branded stub default export for asset-stub URLs without calling nextLoad", async () => {
|
|
49
|
+
const nextLoad = vi.fn();
|
|
50
|
+
const result = await load("asset-stub:.%2Fstyles.css", {}, nextLoad);
|
|
51
|
+
expect(result.format).toBe("module");
|
|
52
|
+
expect(result.shortCircuit).toBe(true);
|
|
53
|
+
expect(result.source).toContain("__p1AssetStub");
|
|
54
|
+
expect(nextLoad).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("branded sentinel", () => {
|
|
58
|
+
// The stub must be *recognizable* after import, so the CI sync can detect
|
|
59
|
+
// descriptors built from stubbed assets and skip them instead of writing
|
|
60
|
+
// content it cannot faithfully compute. A bare {} erases that provenance.
|
|
61
|
+
|
|
62
|
+
async function importStub(): Promise<Record<string, unknown>> {
|
|
63
|
+
const { source } = await load("asset-stub:.%2Flogo.png", {}, vi.fn());
|
|
64
|
+
const mod = (await import(
|
|
65
|
+
/* @vite-ignore */ `data:text/javascript,${encodeURIComponent(source as string)}`
|
|
66
|
+
)) as { default: Record<string, unknown> };
|
|
67
|
+
return mod.default;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it("brands the default export with __p1AssetStub", async () => {
|
|
71
|
+
const stub = await importStub();
|
|
72
|
+
expect(stub.__p1AssetStub).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns the marker string for arbitrary property reads (placeholder.src pattern)", async () => {
|
|
76
|
+
const stub = await importStub();
|
|
77
|
+
expect(stub.src).toBe(ASSET_STUB_MARKER);
|
|
78
|
+
expect((stub as { anythingAtAll?: unknown }).anythingAtAll).toBe(ASSET_STUB_MARKER);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("stringifies to the marker so template-literal usage stays detectable", async () => {
|
|
82
|
+
const stub = await importStub();
|
|
83
|
+
expect(String(stub)).toContain(ASSET_STUB_MARKER);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("passes non-asset-stub URLs through to nextLoad unchanged", async () => {
|
|
88
|
+
const nextLoad = vi.fn().mockResolvedValue({ format: "module", source: "export default 1;", shortCircuit: true });
|
|
89
|
+
const result = await load("file:///abs/path.ts", {}, nextLoad);
|
|
90
|
+
expect(nextLoad).toHaveBeenCalledWith("file:///abs/path.ts", {});
|
|
91
|
+
expect(result.source).toBe("export default 1;");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("propagates a real load error from nextLoad instead of masking it", async () => {
|
|
95
|
+
const nextLoad = vi.fn().mockRejectedValue(new Error("Syntax error"));
|
|
96
|
+
await expect(load("file:///abs/broken.ts", {}, nextLoad)).rejects.toThrow("Syntax error");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("integration: stubbed asset imports hash differently than bundler-resolved values", () => {
|
|
101
|
+
// The same puck.config.tsx yields different descriptor hashes depending on
|
|
102
|
+
// who loaded it — this loader stubs asset imports while the browser bundler
|
|
103
|
+
// resolves them to real values — so the CI sync and the editor perpetually
|
|
104
|
+
// disagree about whether an asset-bearing component "changed", and the
|
|
105
|
+
// CI-written descriptor content is missing the real default values entirely.
|
|
106
|
+
|
|
107
|
+
// Evaluate the module source load() actually emits — not a hand-written {} —
|
|
108
|
+
// so these tests track the real artifact if the stub's shape ever changes.
|
|
109
|
+
async function importStubbedAsset(): Promise<Record<string, unknown>> {
|
|
110
|
+
const { source } = await load("asset-stub:.%2Frandom-image.png", {}, vi.fn());
|
|
111
|
+
const mod = (await import(
|
|
112
|
+
/* @vite-ignore */ `data:text/javascript,${encodeURIComponent(source as string)}`
|
|
113
|
+
)) as { default: Record<string, unknown> };
|
|
114
|
+
return mod.default;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function configWithImageDefault(src: unknown) {
|
|
118
|
+
return {
|
|
119
|
+
components: {
|
|
120
|
+
imageBlock: {
|
|
121
|
+
label: "Image",
|
|
122
|
+
fields: { src: { type: "text", label: "Image URL" } },
|
|
123
|
+
defaultProps: { src },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it("`placeholder.src` (stub-derived under the loader) hashes differently than the browser's URL string", async () => {
|
|
130
|
+
const stub = await importStubbedAsset();
|
|
131
|
+
|
|
132
|
+
const [ciDescriptor] = extractDescriptors(configWithImageDefault(stub.src));
|
|
133
|
+
const [browserDescriptor] = extractDescriptors(
|
|
134
|
+
configWithImageDefault("/_next/static/media/random-image.abc123.png"),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(ciDescriptor.name).toBe(browserDescriptor.name);
|
|
138
|
+
expect(ciDescriptor.descriptorHash).not.toBe(browserDescriptor.descriptorHash);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("a whole stubbed import ({} under the stub) hashes differently than the browser's StaticImageData", async () => {
|
|
142
|
+
const stub = await importStubbedAsset();
|
|
143
|
+
|
|
144
|
+
const [ciDescriptor] = extractDescriptors(configWithImageDefault(stub));
|
|
145
|
+
const [browserDescriptor] = extractDescriptors(
|
|
146
|
+
configWithImageDefault({
|
|
147
|
+
src: "/_next/static/media/random-image.abc123.png",
|
|
148
|
+
width: 800,
|
|
149
|
+
height: 600,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(ciDescriptor.descriptorHash).not.toBe(browserDescriptor.descriptorHash);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("control: a plain string default hashes identically no matter who loaded the config", async () => {
|
|
157
|
+
// Same shapes as above but with a value the stub loader never touches —
|
|
158
|
+
// proves the divergence is caused by asset stubbing, not by hashing noise.
|
|
159
|
+
const [first] = extractDescriptors(configWithImageDefault("/images/static-path.png"));
|
|
160
|
+
const [second] = extractDescriptors(configWithImageDefault("/images/static-path.png"));
|
|
161
|
+
|
|
162
|
+
expect(first.descriptorHash).toBe(second.descriptorHash);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("end to end: descriptors built from stubbed assets are detected and skipped, clean ones kept", async () => {
|
|
166
|
+
const stub = await importStubbedAsset();
|
|
167
|
+
|
|
168
|
+
const [viaPropertyRead] = extractDescriptors(configWithImageDefault(stub.src));
|
|
169
|
+
const [viaWholeImport] = extractDescriptors(configWithImageDefault(stub));
|
|
170
|
+
const [clean] = extractDescriptors(configWithImageDefault("/images/static-path.png"));
|
|
171
|
+
|
|
172
|
+
const { writable, skipped } = filterAssetStubbedDescriptors([viaPropertyRead, viaWholeImport, clean]);
|
|
173
|
+
|
|
174
|
+
expect(skipped).toHaveLength(2);
|
|
175
|
+
expect(writable).toEqual([clean]);
|
|
176
|
+
});
|
|
177
|
+
});
|