@pantheon-systems/create-p1-starter-kit 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +114 -0
  2. package/lib/cli.js +1 -1
  3. package/lib/messages.js +1 -1
  4. package/package.json +14 -5
  5. package/template/.env.example +33 -6
  6. package/template/CHANGELOG.md +20 -0
  7. package/template/__tests__/ai-generate.test.ts +74 -0
  8. package/template/__tests__/auth-route.test.ts +1 -1
  9. package/template/__tests__/chatbot-flag-wiring.test.ts +22 -2
  10. package/template/__tests__/editor-integration.test.ts +2 -2
  11. package/template/__tests__/editor-route-group.test.ts +44 -0
  12. package/template/__tests__/page-seo-meta-templates.test.ts +143 -0
  13. package/template/__tests__/page-seo-meta.test.ts +98 -0
  14. package/template/__tests__/page-seo.test.ts +127 -0
  15. package/template/__tests__/paragraph-block.test.ts +39 -0
  16. package/template/__tests__/paragraph-editor-text.test.ts +79 -0
  17. package/template/__tests__/puck-root-guidance.test.ts +109 -0
  18. package/template/__tests__/puck-root-meta.test.ts +102 -0
  19. package/template/__tests__/puck-root-selects.test.ts +86 -0
  20. package/template/__tests__/remote-datasource-fetchers.test.ts +2 -2
  21. package/template/__tests__/sanitize-richtext.test.ts +58 -0
  22. package/template/__tests__/seo-metadata-meta.test.ts +180 -0
  23. package/template/__tests__/seo-metadata-site-defaults.test.ts +80 -0
  24. package/template/__tests__/seo-metadata.test.ts +105 -0
  25. package/template/__tests__/styles-canvas-scope.test.ts +50 -0
  26. package/template/app/[...puckPath]/page.tsx +27 -30
  27. package/template/app/layout.tsx +25 -1
  28. package/template/app/p1/{[[...p1]] → (editor)/[[...p1]]}/editor-client.tsx +49 -25
  29. package/template/app/p1/{[[...p1]]/page.tsx → (editor)/[[...p1]]/p1-pages.tsx} +2 -7
  30. package/template/app/p1/(editor)/[[...p1]]/page.tsx +5 -0
  31. package/template/app/p1/(editor)/layout.tsx +13 -0
  32. package/template/app/page.tsx +3 -21
  33. package/template/app/styles.css +19 -1
  34. package/template/ci-examples/github-actions-sync-puck-registry.yml +9 -3
  35. package/template/components/p1-lockup.tsx +6 -26
  36. package/template/components/puck/data-list-block/data-list-block.tsx +8 -0
  37. package/template/components/puck/data-list-block/index.ts +1 -0
  38. package/template/components/puck/grid-block.tsx +1 -1
  39. package/template/components/puck/media-figure-block.tsx +12 -0
  40. package/template/components/puck/paragraph-block.tsx +18 -33
  41. package/template/components/puck/paragraph-editor-text.tsx +84 -0
  42. package/template/components/puck/paragraph-markdown.tsx +15 -0
  43. package/template/components/puck/root.tsx +185 -4
  44. package/template/components/puck/sanitize-richtext.ts +44 -0
  45. package/template/constants/assets.ts +1 -0
  46. package/template/eslint.config.js +20 -4
  47. package/template/lib/chatbot-flag/ai-generate.ts +23 -0
  48. package/template/lib/chatbot-flag/draft-request-channel.ts +12 -0
  49. package/template/lib/monsters-api.ts +14 -8
  50. package/template/lib/page-seo.ts +112 -0
  51. package/template/lib/remote-datasources.ts +26 -31
  52. package/template/lib/seo-metadata.consts.ts +21 -0
  53. package/template/lib/seo-metadata.ts +129 -0
  54. package/template/lib/swapi.ts +5 -5
  55. package/template/middleware.ts +15 -0
  56. package/template/next.config.mjs +11 -0
  57. package/template/package.json +17 -10
  58. package/template/pnpm-workspace.yaml +6 -0
  59. package/template/public/images/p1_logo.svg +5 -12
  60. package/template/public/images/p1_logo_reverse.svg +5 -0
  61. package/template/puck.config.tsx +6 -2
  62. package/template/scripts/__tests__/asset-stub-hooks.test.ts +116 -3
  63. package/template/scripts/__tests__/sync-puck-registry.test.ts +107 -1
  64. package/template/scripts/asset-stub-hooks.mjs +25 -1
  65. package/template/scripts/sync-puck-registry.ts +84 -7
  66. package/template/tsconfig/nextjs.json +1 -1
  67. package/template/tsconfig.test.json +6 -0
  68. package/template/vitest.config.ts +25 -0
  69. package/template/next-env.d.ts +0 -6
@@ -0,0 +1,129 @@
1
+ import type { Metadata } from "next";
2
+ import { OG_TYPES, TWITTER_CARDS } from "./seo-metadata.consts";
3
+
4
+ /**
5
+ * Authored page metadata, stored at `root.props._meta`. Empty means inherit: a
6
+ * blank field resolves from the page's own title/description at render time
7
+ * rather than having been copied when the page was created.
8
+ */
9
+ export interface PageMetaFields {
10
+ ogTitle?: string;
11
+ ogDescription?: string;
12
+ ogType?: string;
13
+ ogImage?: string;
14
+ ogLocale?: string;
15
+ twitterCard?: string;
16
+ twitterTitle?: string;
17
+ twitterImage?: string;
18
+ }
19
+
20
+ /**
21
+ * Site-wide fallbacks from the backend's SeoMetadata payload, for the fields a
22
+ * site can sensibly default. They resolve below the page's own values.
23
+ */
24
+ export interface SiteMetaDefaults {
25
+ ogImage?: string;
26
+ ogLocale?: string;
27
+ }
28
+
29
+ /**
30
+ * Head-side metadata inputs. Title, description, and canonical are derived
31
+ * client-side (root props, request path); siteName and the site defaults arrive
32
+ * from the backend's SeoMetadata payload.
33
+ */
34
+ export interface PageHeadMetadata {
35
+ title?: string;
36
+ description?: string;
37
+ canonicalUrl?: string;
38
+ siteName?: string;
39
+ siteDefaults?: SiteMetaDefaults;
40
+ meta?: PageMetaFields;
41
+ }
42
+
43
+ /**
44
+ * The editor offers these as dropdowns built from the same lists, but the API
45
+ * and MCP write the props directly, so the validation stays.
46
+ */
47
+ function oneOf<T extends readonly string[]>(
48
+ allowed: T,
49
+ authored: string | undefined,
50
+ fallback: T[number],
51
+ ): T[number] {
52
+ return authored && (allowed as readonly string[]).includes(authored)
53
+ ? (authored as T[number])
54
+ : fallback;
55
+ }
56
+
57
+ /** Drops absent values so no empty tag is emitted. */
58
+ function compact<T extends object>(value: T): T {
59
+ return Object.fromEntries(
60
+ Object.entries(value).filter(([, v]) => v !== undefined && v !== ""),
61
+ ) as T;
62
+ }
63
+
64
+ /**
65
+ * Maps head metadata to the page's <head> Metadata. Next replaces (not
66
+ * deep-merges) a page's openGraph over the layout's, so og:type and the env
67
+ * og:site_name fallback must be declared here. A relative canonical is emitted
68
+ * only when NEXT_PUBLIC_SITE_URL is configured to resolve it — otherwise Next
69
+ * would resolve it against a localhost default, and a wrong canonical is worse
70
+ * than none. An empty title is treated as absent.
71
+ *
72
+ * Social tags resolve as: page value → site default (og:image, og:locale) →
73
+ * derived from title/description → omit. There is no separate template tier:
74
+ * a template's defaults are copied into the page's own `_meta` at create time,
75
+ * so by the time they reach here they are page values and outrank the site
76
+ * default. A field the template left empty is copied in as an empty string,
77
+ * which is falsy, so it still falls through to the site tier.
78
+ */
79
+ export function buildPageMetadata({
80
+ seo,
81
+ path,
82
+ }: {
83
+ seo?: PageHeadMetadata;
84
+ path: string;
85
+ }): Metadata {
86
+ const meta = seo?.meta ?? {};
87
+
88
+ const title = seo?.title || undefined;
89
+ const description = seo?.description || undefined;
90
+ const canonical =
91
+ seo?.canonicalUrl ?? (process.env.NEXT_PUBLIC_SITE_URL ? path : undefined);
92
+
93
+ const siteDefaults = seo?.siteDefaults ?? {};
94
+ const ogImage = meta.ogImage || siteDefaults.ogImage || undefined;
95
+ const ogLocale = meta.ogLocale || siteDefaults.ogLocale || undefined;
96
+ const twitterTitle = meta.twitterTitle || meta.ogTitle || title;
97
+ const twitterImage = meta.twitterImage || ogImage;
98
+
99
+ return compact({
100
+ title,
101
+ description,
102
+ alternates: canonical ? { canonical } : undefined,
103
+
104
+ openGraph: compact({
105
+ type: oneOf(OG_TYPES, meta.ogType, "website"),
106
+ title: meta.ogTitle || title,
107
+ description: meta.ogDescription || description,
108
+ url: canonical,
109
+ siteName: seo?.siteName ?? process.env.NEXT_PUBLIC_SITE_NAME,
110
+ images: ogImage,
111
+ locale: ogLocale,
112
+ }),
113
+
114
+ // Without a card style X renders nothing, so it is always set when there is
115
+ // anything to show — but an untitled, imageless page gets no twitter tags.
116
+ twitter:
117
+ twitterTitle || twitterImage
118
+ ? compact({
119
+ card: oneOf(
120
+ TWITTER_CARDS,
121
+ meta.twitterCard,
122
+ twitterImage ? "summary_large_image" : "summary",
123
+ ),
124
+ title: twitterTitle,
125
+ images: twitterImage,
126
+ })
127
+ : undefined,
128
+ });
129
+ }
@@ -39,23 +39,23 @@ async function fetchSwapiPerson(
39
39
  }
40
40
  }
41
41
 
42
- async function fetchSwapiPeopleList(
43
- fetchImpl: typeof fetch,
44
- ): Promise<Array<{ id: string; name: string; url?: string }>> {
42
+ type SwapiItem = { id: string; name: string; url?: string } & Record<string, unknown>;
43
+
44
+ async function fetchSwapiPeopleList(fetchImpl: typeof fetch): Promise<SwapiItem[]> {
45
45
  try {
46
46
  const res = await fetchImpl(`${SWAPI_BASE}/people`);
47
47
  if (!res.ok) return [];
48
48
  const json: unknown = await res.json();
49
49
  const results = Array.isArray(json) ? json : null;
50
50
  if (!results) return [];
51
- const out: Array<{ id: string; name: string; url?: string }> = [];
51
+ const out: SwapiItem[] = [];
52
52
  for (const row of results) {
53
53
  if (!row || typeof row !== "object" || Array.isArray(row)) continue;
54
54
  const r = row as Record<string, unknown>;
55
55
  const id = swapiPersonIdFromUrl(r.url);
56
56
  const name = typeof r.name === "string" ? r.name : "";
57
57
  const url = typeof r.url === "string" ? r.url : undefined;
58
- if (id && name) out.push({ id, name, url });
58
+ if (id && name) out.push({ ...r, id, name, url });
59
59
  }
60
60
  return out;
61
61
  } catch {
@@ -0,0 +1,15 @@
1
+ import { createP1Middleware } from "@pantheon-systems/p1-next-sdk/server";
2
+
3
+ const p1Middleware = createP1Middleware({
4
+ cssBaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL ?? "http://localhost:8787",
5
+ apiToken: process.env.CSS_API_KEY ?? "",
6
+ siteId: process.env.NEXT_PUBLIC_CSS_SITE_ID ?? "",
7
+ });
8
+
9
+ export async function middleware(request: Request) {
10
+ return p1Middleware(request);
11
+ }
12
+
13
+ export const config = {
14
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
15
+ };
@@ -1,3 +1,6 @@
1
+ import { createRequire } from "module";
2
+ const require = createRequire(import.meta.url);
3
+
1
4
  export default {
2
5
  reactStrictMode: true,
3
6
  experimental: {
@@ -10,4 +13,12 @@ export default {
10
13
  "@pantheon-systems/puck-css",
11
14
  "@pantheon-systems/p1-next-sdk",
12
15
  ],
16
+ turbopack: {},
17
+ webpack: (config) => {
18
+ config.resolve.alias = {
19
+ ...config.resolve.alias,
20
+ yjs: require.resolve("yjs"),
21
+ };
22
+ return config;
23
+ },
13
24
  };
@@ -3,26 +3,32 @@
3
3
  "version": "0.1.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "dev": "next dev",
7
6
  "build": "next build",
7
+ "clean": "rm -rf .next out *.tsbuildinfo node_modules/.vite",
8
+ "dev": "next dev",
9
+ "lint": "eslint .",
10
+ "lint:fix": "eslint . --fix",
8
11
  "start": "next start",
12
+ "sync:registry": "tsx scripts/sync-puck-registry.ts",
9
13
  "test": "vitest run",
10
- "lint": "eslint .",
11
- "sync:registry": "tsx scripts/sync-puck-registry.ts"
14
+ "typecheck": "tsc --noEmit"
12
15
  },
13
16
  "dependencies": {
14
17
  "@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",
18
+ "@pantheon-systems/css-client": "^0.10.0",
19
+ "@pantheon-systems/p1-ai-chat": "workspace:*",
20
+ "@pantheon-systems/p1-media": "workspace:*",
21
+ "@pantheon-systems/p1-next-sdk": "^0.10.0",
22
+ "@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.51",
23
+ "@pantheon-systems/puck-css": "^0.10.0",
20
24
  "@puckeditor/core": "^0.21.1",
21
25
  "@tailwindcss/postcss": "^4.2.2",
26
+ "@tailwindcss/typography": "^0.5.16",
22
27
  "classnames": "^2.5.1",
28
+ "isomorphic-dompurify": "^3.18.0",
23
29
  "launchdarkly-react-client-sdk": "^3.9.2",
24
- "next": "^16.2.6",
25
- "postcss": "^8.5.12",
30
+ "next": "^16.2.12",
31
+ "postcss": "^8.5.25",
26
32
  "react": "^19.2.5",
27
33
  "react-dom": "^19.2.5",
28
34
  "react-markdown": "^10.1.0",
@@ -32,6 +38,7 @@
32
38
  "@types/node": "^20.19.30",
33
39
  "@types/react": "^19.2.14",
34
40
  "@types/react-dom": "^19.2.3",
41
+ "@vitejs/plugin-react": "^6.0.5",
35
42
  "eslint": "^9.27.0",
36
43
  "tsx": "^4.23.1",
37
44
  "typescript": "^5.9.3",
@@ -0,0 +1,6 @@
1
+ allowBuilds:
2
+ browser-tabs-lock: true
3
+ core-js-pure: true
4
+ esbuild: true
5
+ sharp: true
6
+ workerd: true
@@ -1,12 +1,5 @@
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"/>
12
- </svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="1066 986 2401 1944">
2
+ <g transform="translate(0,4750) scale(0.1,-0.1)" fill="currentColor">
3
+ <path d="M31555 37353 c-105 -343 -302 -965 -332 -1048 -87 -241 -173 -419 -284 -590 -322 -495 -787 -772 -1484 -885 -323 -53 -316 -53 -1507 -57 l-1118 -4 0 -1695 0 -1694 1925 0 1925 0 0 -5490 0 -5490 1900 0 1900 0 0 8520 0 8520 -1449 0 -1450 0 -26 -87z M10860 35245 l0 -2135 5130 0 5130 0 0 -2890 0 -2890 -5130 0 -5130 0 0 -2338 0 -2337 2135 -2135 2135 -2135 0 2332 0 2333 2993 2 2992 3 3 2138 2 2137 2135 0 2135 0 0 2794 0 2795 -157 158 c-845 849 -3390 3431 -3963 4020 l-275 283 -5067 0 -5068 0 0 -2135z"/>
4
+ </g>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="1066 986 2401 1944">
2
+ <g transform="translate(0,4750) scale(0.1,-0.1)" fill="#ffffff">
3
+ <path d="M31555 37353 c-105 -343 -302 -965 -332 -1048 -87 -241 -173 -419 -284 -590 -322 -495 -787 -772 -1484 -885 -323 -53 -316 -53 -1507 -57 l-1118 -4 0 -1695 0 -1694 1925 0 1925 0 0 -5490 0 -5490 1900 0 1900 0 0 8520 0 8520 -1449 0 -1450 0 -26 -87z M10860 35245 l0 -2135 5130 0 5130 0 0 -2890 0 -2890 -5130 0 -5130 0 0 -2338 0 -2337 2135 -2135 2135 -2135 0 2332 0 2333 2993 2 2992 3 3 2138 2 2137 2135 0 2135 0 0 2794 0 2795 -157 158 c-845 849 -3390 3431 -3963 4020 l-275 283 -5067 0 -5068 0 0 -2135z"/>
4
+ </g>
5
+ </svg>
@@ -6,11 +6,13 @@ 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";
12
13
  import { spacerBlock } from "./components/puck/spacer-block";
13
14
  import { welcomeBlock } from "./components/puck/welcome-block";
15
+ import { dataListBlock } from "./components/puck/data-list-block";
14
16
 
15
17
  export const config = {
16
18
  categories: {
@@ -20,11 +22,11 @@ export const config = {
20
22
  },
21
23
  media: {
22
24
  title: "Media",
23
- components: ["ImageBlock"],
25
+ components: ["ImageBlock", "MediaFigureBlock"],
24
26
  },
25
27
  data: {
26
28
  title: "Data",
27
- components: ["GridBlock"],
29
+ components: ["GridBlock", "DataListBlock"],
28
30
  },
29
31
  layout: {
30
32
  title: "Layout",
@@ -44,12 +46,14 @@ export const config = {
44
46
  HeadingBlock: headingBlock,
45
47
  ParagraphBlock: paragraphBlock,
46
48
  ImageBlock: imageBlock,
49
+ MediaFigureBlock: mediaFigureBlock,
47
50
  GridBlock: gridBlock,
48
51
  QuoteBlock: quoteBlock,
49
52
  ListBlock: listBlock,
50
53
  DividerBlock: dividerBlock,
51
54
  SpacerBlock: spacerBlock,
52
55
  ButtonBlock: buttonBlock,
56
+ DataListBlock: dataListBlock,
53
57
  P1WelcomeBlock: welcomeBlock,
54
58
  },
55
59
  } as Config;
@@ -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
+ });
@@ -5,6 +5,13 @@
5
5
  *
6
6
  * Wire in with node:module's register() — not the --import flag, which
7
7
  * does not auto-install a resolve/load-only hooks file.
8
+ *
9
+ * The stub is a *branded* sentinel, not a bare {}: the browser bundler
10
+ * resolves these same imports to real URLs/StaticImageData that this script
11
+ * cannot compute. The brand (`__p1AssetStub`) and the marker string (returned
12
+ * for every property read, so `placeholder.src` stays detectable) let the
13
+ * sync filter recognize and skip such components instead of writing wrong
14
+ * descriptor content and a hash the editor will forever disagree with.
8
15
  */
9
16
 
10
17
  const ASSET_EXTENSION_PATTERN =
@@ -12,6 +19,23 @@ const ASSET_EXTENSION_PATTERN =
12
19
 
13
20
  const ASSET_STUB_PROTOCOL = 'asset-stub:';
14
21
 
22
+ export const ASSET_STUB_MARKER = '__p1_asset_stub__';
23
+
24
+ // The get trap resolves every unknown property (including well-known symbols)
25
+ // to the marker string, so exotic usage of a stub (spread, iteration) throws
26
+ // during extraction — deliberately loud, rather than silently producing
27
+ // garbage.
28
+ const ASSET_STUB_SOURCE = `
29
+ const target = {
30
+ __p1AssetStub: true,
31
+ toString: () => '${ASSET_STUB_MARKER}',
32
+ [Symbol.toPrimitive]: () => '${ASSET_STUB_MARKER}',
33
+ };
34
+ export default new Proxy(target, {
35
+ get: (t, prop) => (prop in t ? t[prop] : '${ASSET_STUB_MARKER}'),
36
+ });
37
+ `;
38
+
15
39
  export async function resolve(specifier, context, nextResolve) {
16
40
  if (ASSET_EXTENSION_PATTERN.test(specifier)) {
17
41
  return {
@@ -26,7 +50,7 @@ export async function load(url, context, nextLoad) {
26
50
  if (url.startsWith(ASSET_STUB_PROTOCOL)) {
27
51
  return {
28
52
  format: 'module',
29
- source: 'export default {};',
53
+ source: ASSET_STUB_SOURCE,
30
54
  shortCircuit: true,
31
55
  };
32
56
  }