@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.
Files changed (34) hide show
  1. package/package.json +8 -3
  2. package/template/.env.example +6 -0
  3. package/template/CHANGELOG.md +32 -0
  4. package/template/__tests__/chatbot-flag-wiring.test.ts +1 -1
  5. package/template/__tests__/editor-integration.test.ts +1 -1
  6. package/template/__tests__/editor-route-group.test.ts +44 -0
  7. package/template/__tests__/page-seo.test.ts +127 -0
  8. package/template/__tests__/paragraph-block.test.ts +37 -0
  9. package/template/__tests__/sanitize-richtext.test.ts +58 -0
  10. package/template/__tests__/seo-metadata.test.ts +105 -0
  11. package/template/app/[...puckPath]/page.tsx +5 -26
  12. package/template/app/layout.tsx +14 -0
  13. package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +20 -21
  14. package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
  15. package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
  16. package/template/app/p1/(editor)/layout.tsx +13 -0
  17. package/template/app/page.tsx +3 -21
  18. package/template/app/styles.css +1 -0
  19. package/template/ci-examples/github-actions-sync-puck-registry.yml +57 -0
  20. package/template/components/puck/media-figure-block.tsx +12 -0
  21. package/template/components/puck/paragraph-block.tsx +11 -31
  22. package/template/components/puck/sanitize-richtext.ts +44 -0
  23. package/template/lib/page-seo.ts +79 -0
  24. package/template/lib/seo-metadata.ts +48 -0
  25. package/template/package.json +12 -5
  26. package/template/pnpm-workspace.yaml +3 -0
  27. package/template/public/images/p1_logo.svg +11 -4
  28. package/template/puck.config.tsx +3 -1
  29. package/template/scripts/__tests__/asset-stub-hooks.test.ts +177 -0
  30. package/template/scripts/__tests__/sync-puck-registry.test.ts +230 -0
  31. package/template/scripts/asset-stub-hooks.mjs +58 -0
  32. package/template/scripts/sync-puck-registry.ts +225 -0
  33. package/template/tsconfig.test.json +6 -0
  34. 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;
@@ -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,4 +1,5 @@
1
1
  @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
2
3
 
3
4
  body {
4
5
  margin: 0;
@@ -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
- 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 { 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 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>
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
+ }
@@ -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/p1-ai-chat": "^0.1.0",
15
- "@pantheon-systems/p1-next-sdk": "^0.6.0",
16
- "@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.12",
17
- "@pantheon-systems/puck-css": "^0.6.0",
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",
@@ -0,0 +1,3 @@
1
+ allowBuilds:
2
+ esbuild: true
3
+ sharp: true
@@ -1,5 +1,12 @@
1
- <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <path d="M0 3C0 1.34315 1.34315 0 3 0H29C30.6569 0 32 1.34315 32 3V29C32 30.6569 30.6569 32 29 32H3C1.34315 32 0 30.6569 0 29V3Z" fill="#171717"/>
3
- <path d="M6 7H16.9111V8.82232H18.7335V17.9112H16.9111V19.7335H9.64464V25.189H6.01139V7H6ZM15.0888 10.6333H9.63325V16.0888H15.0888V10.6333Z" fill="#FFDC28"/>
4
- <path d="M20.5449 7H26.0005V25.1777H22.3672V10.6333H20.5449V7Z" fill="#FFDC28"/>
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>
@@ -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
+ });