@pantheon-systems/create-p1-starter-kit 0.7.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 (33) hide show
  1. package/package.json +7 -4
  2. package/template/.env.example +6 -0
  3. package/template/CHANGELOG.md +20 -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 +9 -3
  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 +9 -5
  26. package/template/pnpm-workspace.yaml +3 -0
  27. package/template/puck.config.tsx +3 -1
  28. package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
  29. package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
  30. package/template/scripts/asset-stub-hooks.mjs +25 -1
  31. package/template/scripts/sync-puck-registry.ts +84 -7
  32. package/template/tsconfig.test.json +6 -0
  33. package/template/vitest.config.ts +5 -0
@@ -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;
@@ -12,10 +12,15 @@
12
12
  # 3. Copy this file to .github/workflows/sync-puck-registry.yml.
13
13
  #
14
14
  # Triggers on push to any branch that touches puck.config.tsx or
15
- # components/puck/** the sync script resolves the CSS branch by matching
16
- # the pushed git branch's name (falling back to the site's main branch when
17
- # no CSS_BRANCH_ID is given). A push on a branch with no matching CSS branch
15
+ # components/puck/**. The sync script resolves the CSS branch from the pushed
16
+ # git branch's name: the repo's default branch always targets the site's main
17
+ # CSS branch (whatever the git branch is called), any other ref matches a CSS
18
+ # branch by name. A push on a non-default branch with no matching CSS branch
18
19
  # is not an error: the script logs a skip and exits 0.
20
+ #
21
+ # CSS_DEFAULT_BRANCH defaults to "main" if omitted — repos whose default
22
+ # branch has another name (master, trunk) need the line below (or must set
23
+ # the variable themselves) for default-branch pushes to sync at all.
19
24
  name: Sync Puck Component Registry
20
25
 
21
26
  on:
@@ -49,3 +54,4 @@ jobs:
49
54
  CSS_SITE_ID: ${{ secrets.CSS_SITE_ID }}
50
55
  CSS_REGISTRY_API_KEY: ${{ secrets.CSS_REGISTRY_API_KEY }}
51
56
  CSS_BRANCH_ID: ${{ github.event.inputs.branch_id || github.ref_name }}
57
+ CSS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
@@ -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
+ }
@@ -12,14 +12,17 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@pantheon-systems/cpub-react-sdk": "^5.2.1",
15
- "@pantheon-systems/css-client": "^0.7.0",
16
- "@pantheon-systems/p1-ai-chat": "^0.1.0",
17
- "@pantheon-systems/p1-next-sdk": "^0.7.0",
18
- "@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.44",
19
- "@pantheon-systems/puck-css": "^0.7.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",
20
21
  "@puckeditor/core": "^0.21.1",
21
22
  "@tailwindcss/postcss": "^4.2.2",
23
+ "@tailwindcss/typography": "^0.5.16",
22
24
  "classnames": "^2.5.1",
25
+ "isomorphic-dompurify": "^3.18.0",
23
26
  "launchdarkly-react-client-sdk": "^3.9.2",
24
27
  "next": "^16.2.6",
25
28
  "postcss": "^8.5.12",
@@ -32,6 +35,7 @@
32
35
  "@types/node": "^20.19.30",
33
36
  "@types/react": "^19.2.14",
34
37
  "@types/react-dom": "^19.2.3",
38
+ "@vitejs/plugin-react": "^4.7.0",
35
39
  "eslint": "^9.27.0",
36
40
  "tsx": "^4.23.1",
37
41
  "typescript": "^5.9.3",
@@ -0,0 +1,3 @@
1
+ allowBuilds:
2
+ esbuild: true
3
+ sharp: true
@@ -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,
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { resolve, load } from "../asset-stub-hooks.mjs";
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";
3
5
 
4
6
  describe("resolve", () => {
5
7
  it("short-circuits CSS imports to an asset-stub URL without calling nextResolve", async () => {
@@ -43,13 +45,45 @@ describe("resolve", () => {
43
45
  });
44
46
 
45
47
  describe("load", () => {
46
- it("returns an empty default export for asset-stub URLs without calling nextLoad", async () => {
48
+ it("returns a branded stub default export for asset-stub URLs without calling nextLoad", async () => {
47
49
  const nextLoad = vi.fn();
48
50
  const result = await load("asset-stub:.%2Fstyles.css", {}, nextLoad);
49
- expect(result).toEqual({ format: "module", source: "export default {};", shortCircuit: true });
51
+ expect(result.format).toBe("module");
52
+ expect(result.shortCircuit).toBe(true);
53
+ expect(result.source).toContain("__p1AssetStub");
50
54
  expect(nextLoad).not.toHaveBeenCalled();
51
55
  });
52
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
+
53
87
  it("passes non-asset-stub URLs through to nextLoad unchanged", async () => {
54
88
  const nextLoad = vi.fn().mockResolvedValue({ format: "module", source: "export default 1;", shortCircuit: true });
55
89
  const result = await load("file:///abs/path.ts", {}, nextLoad);
@@ -62,3 +96,82 @@ describe("load", () => {
62
96
  await expect(load("file:///abs/broken.ts", {}, nextLoad)).rejects.toThrow("Syntax error");
63
97
  });
64
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
+ });
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { validateEnv, resolveConfigModule, resolveBranchId, NoBranchMatchError } from "../sync-puck-registry.js";
2
+ import {
3
+ validateEnv,
4
+ resolveConfigModule,
5
+ resolveBranchId,
6
+ filterAssetStubbedDescriptors,
7
+ NoBranchMatchError,
8
+ } from "../sync-puck-registry.js";
9
+ import { ASSET_STUB_MARKER } from "../asset-stub-hooks.mjs";
3
10
 
4
11
  function baseEnv(overrides: Record<string, string | undefined> = {}): Record<string, string | undefined> {
5
12
  return {
@@ -43,6 +50,19 @@ describe("validateEnv", () => {
43
50
  expect(result.branchOverride).toBe("explicit");
44
51
  });
45
52
 
53
+ it("reads CSS_DEFAULT_BRANCH into defaultBranchName, overriding the default", () => {
54
+ const result = validateEnv(baseEnv({ CSS_DEFAULT_BRANCH: "master" }));
55
+ expect(result.defaultBranchName).toBe("master");
56
+ });
57
+
58
+ it("defaults defaultBranchName to 'main' when CSS_DEFAULT_BRANCH is not set", () => {
59
+ // Safe because the CSS main content branch is always literally named
60
+ // "main": a push override of "main" resolves to the same branch either
61
+ // by name match or by isMain, so the default only adds semantics.
62
+ const result = validateEnv(baseEnv());
63
+ expect(result.defaultBranchName).toBe("main");
64
+ });
65
+
46
66
  it("defaults PUCK_CONFIG_PATH to puck.config.tsx", () => {
47
67
  const result = validateEnv(baseEnv());
48
68
  expect(result.puckConfigPath).toBe("puck.config.tsx");
@@ -122,3 +142,89 @@ describe("resolveBranchId", () => {
122
142
  expect(() => resolveBranchId(branches as never, "site-123", "nonexistent")).toThrow(NoBranchMatchError);
123
143
  });
124
144
  });
145
+
146
+ describe("resolveBranchId default-branch semantics", () => {
147
+ // In CI the override is always the pushed git ref's name, so a repo whose
148
+ // default branch is not literally named "main" would never match the CSS
149
+ // main branch (whose name is always "main") — the sync silently skips on
150
+ // every default-branch push. When the caller also supplies the repo's
151
+ // default branch name, an override equal to it must resolve via isMain.
152
+ const branches = [
153
+ { id: "b-1", siteId: "site-123", name: "main", isMain: true },
154
+ { id: "b-2", siteId: "site-123", name: "staging", isMain: false },
155
+ ];
156
+
157
+ it("resolves the isMain branch when the override is the repo's default branch name", () => {
158
+ expect(resolveBranchId(branches as never, "site-123", "master", "master")).toBe("b-1");
159
+ });
160
+
161
+ it("prefers isMain over a coincidental name match for the default branch", () => {
162
+ const withDecoy = [...branches, { id: "b-3", siteId: "site-123", name: "master", isMain: false }];
163
+ expect(resolveBranchId(withDecoy as never, "site-123", "master", "master")).toBe("b-1");
164
+ });
165
+
166
+ it("throws NoBranchMatchError for a default-branch override when no isMain branch exists", () => {
167
+ const noMain = [{ id: "b-2", siteId: "site-123", name: "staging", isMain: false }];
168
+ expect(() => resolveBranchId(noMain as never, "site-123", "master", "master")).toThrow(NoBranchMatchError);
169
+ });
170
+
171
+ it("keeps plain name matching for overrides that are not the default branch", () => {
172
+ expect(resolveBranchId(branches as never, "site-123", "staging", "master")).toBe("b-2");
173
+ });
174
+
175
+ it("keeps the silent-skip path for non-default refs that match nothing", () => {
176
+ expect(() => resolveBranchId(branches as never, "site-123", "feature-x", "master")).toThrow(NoBranchMatchError);
177
+ });
178
+ });
179
+
180
+ describe("filterAssetStubbedDescriptors", () => {
181
+ // CI loads puck.config.tsx under the asset-stub loader, so any defaultProps
182
+ // value derived from an asset import is a branded sentinel, not the real
183
+ // bundler-resolved value. CI cannot faithfully describe those components —
184
+ // it skips them (loudly) and leaves them to the editor path.
185
+
186
+ const descriptor = (name: string, defaultProps: Record<string, unknown>) =>
187
+ ({ name, label: name, fields: [], defaultProps, descriptorHash: "h" }) as never;
188
+
189
+ it("keeps descriptors whose defaults are plain values", () => {
190
+ const clean = descriptor("heroBlock", { title: "Hello", count: 3, nested: { a: [1, "x"] } });
191
+ const { writable, skipped } = filterAssetStubbedDescriptors([clean]);
192
+ expect(writable).toEqual([clean]);
193
+ expect(skipped).toEqual([]);
194
+ });
195
+
196
+ it("skips a descriptor whose default carries the asset-stub marker string (placeholder.src pattern)", () => {
197
+ const stubbed = descriptor("imageBlock", { src: ASSET_STUB_MARKER, alt: "Mountain" });
198
+ const { writable, skipped } = filterAssetStubbedDescriptors([stubbed]);
199
+ expect(writable).toEqual([]);
200
+ expect(skipped.map((d: { name: string }) => d.name)).toEqual(["imageBlock"]);
201
+ });
202
+
203
+ it("skips a descriptor whose default is a branded stub object (whole-import pattern)", () => {
204
+ const stubbed = descriptor("imageBlock", { src: { __p1AssetStub: true } });
205
+ const { skipped } = filterAssetStubbedDescriptors([stubbed]);
206
+ expect(skipped).toHaveLength(1);
207
+ });
208
+
209
+ it("detects the marker arbitrarily deep in defaultProps", () => {
210
+ const stubbed = descriptor("gallery", { items: [{ media: { src: `prefix ${ASSET_STUB_MARKER}` } }] });
211
+ const { skipped } = filterAssetStubbedDescriptors([stubbed]);
212
+ expect(skipped).toHaveLength(1);
213
+ });
214
+
215
+ it("partitions a mixed list preserving order of writable descriptors", () => {
216
+ const a = descriptor("a", { t: "1" });
217
+ const b = descriptor("b", { src: ASSET_STUB_MARKER });
218
+ const c = descriptor("c", { t: "2" });
219
+ const { writable, skipped } = filterAssetStubbedDescriptors([a, b, c]);
220
+ expect(writable.map((d: { name: string }) => d.name)).toEqual(["a", "c"]);
221
+ expect(skipped.map((d: { name: string }) => d.name)).toEqual(["b"]);
222
+ });
223
+
224
+ it("does not hang on circular defaultProps", () => {
225
+ const circular: Record<string, unknown> = { title: "ok" };
226
+ circular.self = circular;
227
+ const { skipped } = filterAssetStubbedDescriptors([descriptor("looper", circular)]);
228
+ expect(skipped).toEqual([]);
229
+ });
230
+ });