@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,230 @@
1
+ import { describe, expect, it } from "vitest";
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";
10
+
11
+ function baseEnv(overrides: Record<string, string | undefined> = {}): Record<string, string | undefined> {
12
+ return {
13
+ CSS_BASE_URL: "https://css.example.com",
14
+ CSS_SITE_ID: "site-123",
15
+ CSS_REGISTRY_API_KEY: "sat_registrytoken",
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("validateEnv", () => {
21
+ it("accepts CSS_BASE_URL/CSS_SITE_ID/CSS_REGISTRY_API_KEY directly", () => {
22
+ const result = validateEnv(baseEnv());
23
+ expect(result.baseUrl).toBe("https://css.example.com");
24
+ expect(result.siteId).toBe("site-123");
25
+ expect(result.apiKey).toBe("sat_registrytoken");
26
+ expect(result.branchOverride).toBeUndefined();
27
+ expect(result.puckConfigPath).toBe("puck.config.tsx");
28
+ });
29
+
30
+ it("falls back to NEXT_PUBLIC_CSS_BASE_URL and NEXT_PUBLIC_CSS_SITE_ID", () => {
31
+ const result = validateEnv(
32
+ baseEnv({
33
+ CSS_BASE_URL: undefined,
34
+ CSS_SITE_ID: undefined,
35
+ NEXT_PUBLIC_CSS_BASE_URL: "https://fallback.example.com",
36
+ NEXT_PUBLIC_CSS_SITE_ID: "site-fallback",
37
+ }),
38
+ );
39
+ expect(result.baseUrl).toBe("https://fallback.example.com");
40
+ expect(result.siteId).toBe("site-fallback");
41
+ });
42
+
43
+ it("falls back to NEXT_PUBLIC_CSS_BRANCH_ID for the branch override", () => {
44
+ const result = validateEnv(baseEnv({ NEXT_PUBLIC_CSS_BRANCH_ID: "staging" }));
45
+ expect(result.branchOverride).toBe("staging");
46
+ });
47
+
48
+ it("prefers CSS_BRANCH_ID over the NEXT_PUBLIC_ fallback when both are set", () => {
49
+ const result = validateEnv(baseEnv({ CSS_BRANCH_ID: "explicit", NEXT_PUBLIC_CSS_BRANCH_ID: "staging" }));
50
+ expect(result.branchOverride).toBe("explicit");
51
+ });
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
+
66
+ it("defaults PUCK_CONFIG_PATH to puck.config.tsx", () => {
67
+ const result = validateEnv(baseEnv());
68
+ expect(result.puckConfigPath).toBe("puck.config.tsx");
69
+ });
70
+
71
+ it("honors an explicit PUCK_CONFIG_PATH", () => {
72
+ const result = validateEnv(baseEnv({ PUCK_CONFIG_PATH: "config/puck.config.tsx" }));
73
+ expect(result.puckConfigPath).toBe("config/puck.config.tsx");
74
+ });
75
+
76
+ it("reports all missing required vars together, not just the first", () => {
77
+ expect(() => validateEnv({})).toThrowError(/CSS_BASE_URL[\s\S]*CSS_SITE_ID[\s\S]*CSS_REGISTRY_API_KEY/);
78
+ });
79
+
80
+ it("gives an explicit, actionable message when only P1_CSS_API_KEY is set (do not reuse the read-only token)", () => {
81
+ expect(() =>
82
+ validateEnv(baseEnv({ CSS_REGISTRY_API_KEY: undefined, P1_CSS_API_KEY: "sat_readonlytoken" })),
83
+ ).toThrowError(/write:registry/);
84
+ });
85
+
86
+ it("does not accidentally accept P1_CSS_API_KEY as a substitute for CSS_REGISTRY_API_KEY", () => {
87
+ const result = () =>
88
+ validateEnv(baseEnv({ CSS_REGISTRY_API_KEY: undefined, P1_CSS_API_KEY: "sat_readonlytoken" }));
89
+ expect(result).toThrow();
90
+ });
91
+ });
92
+
93
+ describe("resolveConfigModule", () => {
94
+ it("prefers a default export", () => {
95
+ const mod = { default: { components: {} }, config: { wrong: true } };
96
+ expect(resolveConfigModule(mod)).toBe(mod.default);
97
+ });
98
+
99
+ it("falls back to a named config export when there is no default", () => {
100
+ const mod = { config: { components: {} } };
101
+ expect(resolveConfigModule(mod)).toBe(mod.config);
102
+ });
103
+
104
+ it("falls back to the module itself when neither default nor config is present", () => {
105
+ const mod = { components: {} };
106
+ expect(resolveConfigModule(mod)).toBe(mod);
107
+ });
108
+ });
109
+
110
+ describe("resolveBranchId", () => {
111
+ const branches = [
112
+ { id: "b-1", siteId: "site-123", name: "main", isMain: true },
113
+ { id: "b-2", siteId: "site-123", name: "staging", isMain: false },
114
+ ];
115
+
116
+ it("resolves to the main branch when no override is given", () => {
117
+ expect(resolveBranchId(branches as never, "site-123")).toBe("b-1");
118
+ });
119
+
120
+ it("resolves an override by branch id", () => {
121
+ expect(resolveBranchId(branches as never, "site-123", "b-2")).toBe("b-2");
122
+ });
123
+
124
+ it("resolves an override by branch name", () => {
125
+ expect(resolveBranchId(branches as never, "site-123", "staging")).toBe("b-2");
126
+ });
127
+
128
+ it("throws NoBranchMatchError ('No main branch found') when there is no override and no isMain branch", () => {
129
+ const noMain = [{ id: "b-2", siteId: "site-123", name: "staging", isMain: false }];
130
+ expect(() => resolveBranchId(noMain as never, "site-123")).toThrowError(
131
+ "No main branch found for site site-123",
132
+ );
133
+ try {
134
+ resolveBranchId(noMain as never, "site-123");
135
+ expect.unreachable();
136
+ } catch (err) {
137
+ expect(err).toBeInstanceOf(NoBranchMatchError);
138
+ }
139
+ });
140
+
141
+ it("throws NoBranchMatchError when an explicit override matches no branch by id or name", () => {
142
+ expect(() => resolveBranchId(branches as never, "site-123", "nonexistent")).toThrow(NoBranchMatchError);
143
+ });
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
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Node module customization hooks that stub out non-JS asset imports
3
+ * (CSS, images, fonts, video) so a plain Node script can `import()` a
4
+ * Next.js app's puck.config.tsx without a bundler. Zero dependencies.
5
+ *
6
+ * Wire in with node:module's register() — not the --import flag, which
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.
15
+ */
16
+
17
+ const ASSET_EXTENSION_PATTERN =
18
+ /\.(css|scss|sass|less|png|jpe?g|gif|svg|webp|ico|bmp|avif|woff2?|ttf|eot|otf|mp4|webm|mov|mp3|wav)$/i;
19
+
20
+ const ASSET_STUB_PROTOCOL = 'asset-stub:';
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
+
39
+ export async function resolve(specifier, context, nextResolve) {
40
+ if (ASSET_EXTENSION_PATTERN.test(specifier)) {
41
+ return {
42
+ url: `${ASSET_STUB_PROTOCOL}${encodeURIComponent(specifier)}`,
43
+ shortCircuit: true,
44
+ };
45
+ }
46
+ return nextResolve(specifier, context);
47
+ }
48
+
49
+ export async function load(url, context, nextLoad) {
50
+ if (url.startsWith(ASSET_STUB_PROTOCOL)) {
51
+ return {
52
+ format: 'module',
53
+ source: ASSET_STUB_SOURCE,
54
+ shortCircuit: true,
55
+ };
56
+ }
57
+ return nextLoad(url, context);
58
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Syncs this site's Puck component registry (_registry/components/* and the
3
+ * registry index) headlessly, without opening the editor in a browser.
4
+ * Intended to run from CI on push to main or a branch whose name matches a
5
+ * CSS branch, whenever puck.config.tsx or components/puck/** change.
6
+ *
7
+ * Usage: tsx scripts/sync-puck-registry.ts [--dry-run]
8
+ *
9
+ * Required env vars (see validateEnv below for the full fallback contract):
10
+ * CSS_BASE_URL, CSS_SITE_ID, CSS_REGISTRY_API_KEY
11
+ * Optional:
12
+ * CSS_BRANCH_ID — CSS branch to target (in CI: the pushed git ref's name)
13
+ * CSS_DEFAULT_BRANCH — the repo's default branch name (defaults to "main");
14
+ * when CSS_BRANCH_ID equals it, the site's isMain branch is targeted
15
+ * regardless of naming (see resolveBranchId for the resolution contract)
16
+ *
17
+ * CSS_REGISTRY_API_KEY must be a sat_ site token scoped to write:registry
18
+ * only — do not reuse a read-scoped token (P1_CSS_API_KEY). Because that
19
+ * token has no read access at all, every run rewrites every component
20
+ * descriptor + the registry index unconditionally (no skip-if-unchanged) —
21
+ * see syncComponentRegistryWriteOnly.
22
+ */
23
+
24
+ import { register } from "node:module";
25
+ import path from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import { P1Client } from "@pantheon-systems/css-client";
28
+ import type { Branch } from "@pantheon-systems/css-client";
29
+ import { extractDescriptors, syncComponentRegistryWriteOnly } from "@pantheon-systems/puck-css/registry-sync";
30
+ import { ASSET_STUB_MARKER } from "./asset-stub-hooks.mjs";
31
+
32
+ export interface ValidatedEnv {
33
+ baseUrl: string;
34
+ siteId: string;
35
+ apiKey: string;
36
+ branchOverride?: string;
37
+ defaultBranchName?: string;
38
+ puckConfigPath: string;
39
+ }
40
+
41
+ export function validateEnv(env: Record<string, string | undefined>): ValidatedEnv {
42
+ const baseUrl = env.CSS_BASE_URL ?? env.NEXT_PUBLIC_CSS_BASE_URL;
43
+ const siteId = env.CSS_SITE_ID ?? env.NEXT_PUBLIC_CSS_SITE_ID;
44
+ const apiKey = env.CSS_REGISTRY_API_KEY;
45
+ const branchOverride = env.CSS_BRANCH_ID ?? env.NEXT_PUBLIC_CSS_BRANCH_ID;
46
+ // Defaults to "main": the CSS main content branch is always literally
47
+ // named "main", so for repos whose default git branch is also "main" this
48
+ // resolves to the same branch it would have matched by name. Repos with a
49
+ // differently-named default branch (master, trunk) must set it explicitly.
50
+ const defaultBranchName = env.CSS_DEFAULT_BRANCH ?? "main";
51
+ const puckConfigPath = env.PUCK_CONFIG_PATH ?? "puck.config.tsx";
52
+
53
+ const missing: string[] = [];
54
+ if (baseUrl === undefined || baseUrl === "") {
55
+ missing.push("CSS_BASE_URL (or NEXT_PUBLIC_CSS_BASE_URL)");
56
+ }
57
+ if (siteId === undefined || siteId === "") {
58
+ missing.push("CSS_SITE_ID (or NEXT_PUBLIC_CSS_SITE_ID)");
59
+ }
60
+ if (apiKey === undefined || apiKey === "") {
61
+ if (env.P1_CSS_API_KEY !== undefined && env.P1_CSS_API_KEY !== "") {
62
+ missing.push(
63
+ "CSS_REGISTRY_API_KEY — do not reuse P1_CSS_API_KEY (your read-scoped site token); " +
64
+ "create a separate sat_ token scoped to write:registry",
65
+ );
66
+ } else {
67
+ missing.push("CSS_REGISTRY_API_KEY");
68
+ }
69
+ }
70
+
71
+ if (missing.length > 0) {
72
+ throw new Error(`Missing required environment variable(s):\n - ${missing.join("\n - ")}`);
73
+ }
74
+
75
+ return {
76
+ baseUrl: baseUrl as string,
77
+ siteId: siteId as string,
78
+ apiKey: apiKey as string,
79
+ branchOverride,
80
+ defaultBranchName,
81
+ puckConfigPath,
82
+ };
83
+ }
84
+
85
+ export function resolveConfigModule(mod: unknown): unknown {
86
+ const record = mod as Record<string, unknown>;
87
+ return record.default ?? record.config ?? mod;
88
+ }
89
+
90
+ /**
91
+ * Thrown by resolveBranchId when no CSS branch matches. Distinguished from
92
+ * other errors so callers (main(), a CI trigger firing on every git branch)
93
+ * can treat "this branch has no CSS counterpart" as a benign no-op rather
94
+ * than a real sync failure.
95
+ */
96
+ export class NoBranchMatchError extends Error {}
97
+
98
+ type Descriptor = ReturnType<typeof extractDescriptors>[number];
99
+
100
+ function containsAssetStubValue(value: unknown, seen = new Set<object>()): boolean {
101
+ if (typeof value === "string") return value.includes(ASSET_STUB_MARKER);
102
+ if (typeof value !== "object" || value === null) return false;
103
+ if (seen.has(value)) return false;
104
+ seen.add(value);
105
+ if ((value as Record<string, unknown>).__p1AssetStub === true) return true;
106
+ return Object.values(value).some((nested) => containsAssetStubValue(nested, seen));
107
+ }
108
+
109
+ /**
110
+ * Partitions descriptors into those this script can faithfully write and
111
+ * those it must skip. Under the asset-stub loader, any config
112
+ * value derived from an asset import is a branded sentinel — the browser
113
+ * bundler resolves the same import to a real URL this script cannot know.
114
+ * Writing such a descriptor stores wrong default values and an index hash
115
+ * the editor will disagree with on every load (a perpetual re-register
116
+ * flip-flop between CI and editor). Skipped components stay editor-owned:
117
+ * one writer per component, no disagreement.
118
+ *
119
+ * Scans the whole descriptor, not just defaultProps, so a stub value that
120
+ * reaches any future descriptor field is still caught.
121
+ */
122
+ export function filterAssetStubbedDescriptors(descriptors: Descriptor[]): {
123
+ writable: Descriptor[];
124
+ skipped: Descriptor[];
125
+ } {
126
+ const writable: Descriptor[] = [];
127
+ const skipped: Descriptor[] = [];
128
+ for (const descriptor of descriptors) {
129
+ (containsAssetStubValue(descriptor) ? skipped : writable).push(descriptor);
130
+ }
131
+ return { writable, skipped };
132
+ }
133
+
134
+ /**
135
+ * Resolution contract: an override (in CI, always the pushed git ref's name)
136
+ * matches a CSS branch by id or name — EXCEPT when it equals the repo's
137
+ * default branch name, which always resolves the site's isMain branch. CSS
138
+ * main is always literally named "main", so without that rule a repo whose
139
+ * default branch is "master"/"trunk" would silently skip on every push. The
140
+ * default branch means the main registry, even over a coincidental CSS
141
+ * branch named e.g. "master".
142
+ */
143
+ export function resolveBranchId(
144
+ branches: Branch[],
145
+ siteId: string,
146
+ override?: string,
147
+ defaultBranchName?: string,
148
+ ): string {
149
+ const isDefaultBranch =
150
+ override !== undefined && override !== "" && override === defaultBranchName;
151
+ if (override !== undefined && override !== "" && !isDefaultBranch) {
152
+ const match = branches.find((b) => b.id === override || b.name === override);
153
+ if (match === undefined) {
154
+ throw new NoBranchMatchError(`No branch matching "${override}" found for site ${siteId}`);
155
+ }
156
+ return match.id;
157
+ }
158
+ const mainBranch = branches.find((b) => b.isMain);
159
+ if (mainBranch === undefined) {
160
+ throw new NoBranchMatchError("No main branch found for site " + siteId);
161
+ }
162
+ return mainBranch.id;
163
+ }
164
+
165
+ async function main(): Promise<void> {
166
+ const dryRun = process.argv.includes("--dry-run");
167
+
168
+ // Registered here (not at module scope) so importing this file for tests
169
+ // never installs a process-wide loader hook.
170
+ register("./asset-stub-hooks.mjs", import.meta.url);
171
+
172
+ const { baseUrl, siteId, apiKey, branchOverride, defaultBranchName, puckConfigPath } =
173
+ validateEnv(process.env);
174
+
175
+ const configUrl = pathToFileURL(path.resolve(process.cwd(), puckConfigPath)).href;
176
+ const mod: unknown = await import(configUrl);
177
+ const puckConfig = resolveConfigModule(mod);
178
+
179
+ const descriptors = extractDescriptors(puckConfig);
180
+ const { writable, skipped } = filterAssetStubbedDescriptors(descriptors);
181
+
182
+ for (const descriptor of skipped) {
183
+ console.warn(
184
+ `[sync-puck-registry] SKIPPED ${descriptor.name}: its defaults use bundler-resolved asset imports ` +
185
+ `this script cannot faithfully compute — it will register from the editor instead. ` +
186
+ `To include it in CI sync, replace imported assets in defaultProps with plain string paths.`,
187
+ );
188
+ }
189
+
190
+ const client = new P1Client({ baseUrl, apiKey });
191
+ const branches = await client.branches.list(siteId);
192
+ const branchId = resolveBranchId(branches, siteId, branchOverride, defaultBranchName);
193
+
194
+ if (dryRun) {
195
+ console.log(
196
+ `[sync-puck-registry] Dry run: ${String(writable.length)} component descriptor(s) to write ` +
197
+ `(${String(skipped.length)} skipped) for site ${siteId}, branch ${branchId}. No writes performed.`,
198
+ );
199
+ return;
200
+ }
201
+
202
+ const result = await syncComponentRegistryWriteOnly(client, siteId, branchId, writable);
203
+ console.log(
204
+ `[sync-puck-registry] Synced site ${siteId}, branch ${branchId}: ` +
205
+ `wrote ${String(result.total)} component descriptor(s) + registry index` +
206
+ (skipped.length > 0 ? ` (${String(skipped.length)} asset-bearing component(s) left to the editor)` : ""),
207
+ );
208
+ }
209
+
210
+ const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
211
+ if (isMainModule) {
212
+ main().catch((err: unknown) => {
213
+ // A CI trigger firing on every git branch push has no way to know ahead
214
+ // of time which branches have a matching CSS branch — that has to be
215
+ // discovered at runtime. Treat "no match" as a benign no-op, not a
216
+ // failure, so unrelated feature-branch pushes don't turn CI red.
217
+ if (err instanceof NoBranchMatchError) {
218
+ console.log(`[sync-puck-registry] Skipping: ${err.message}`);
219
+ return;
220
+ }
221
+ const message = err instanceof Error ? err.message : String(err);
222
+ console.error("[sync-puck-registry] FAILED:", message);
223
+ process.exitCode = 1;
224
+ });
225
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx"
5
+ }
6
+ }
@@ -1,7 +1,12 @@
1
1
  import { defineConfig } from "vitest/config";
2
+ import react from "@vitejs/plugin-react";
2
3
 
3
4
  export default defineConfig({
5
+ plugins: [react()],
4
6
  test: {
5
7
  environment: "node",
8
+ typecheck: {
9
+ tsconfig: "./tsconfig.test.json",
10
+ },
6
11
  },
7
12
  });