@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
@@ -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
  }
@@ -8,6 +8,11 @@
8
8
  *
9
9
  * Required env vars (see validateEnv below for the full fallback contract):
10
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)
11
16
  *
12
17
  * CSS_REGISTRY_API_KEY must be a sat_ site token scoped to write:registry
13
18
  * only — do not reuse a read-scoped token (P1_CSS_API_KEY). Because that
@@ -22,12 +27,14 @@ import { pathToFileURL } from "node:url";
22
27
  import { P1Client } from "@pantheon-systems/css-client";
23
28
  import type { Branch } from "@pantheon-systems/css-client";
24
29
  import { extractDescriptors, syncComponentRegistryWriteOnly } from "@pantheon-systems/puck-css/registry-sync";
30
+ import { ASSET_STUB_MARKER } from "./asset-stub-hooks.mjs";
25
31
 
26
32
  export interface ValidatedEnv {
27
33
  baseUrl: string;
28
34
  siteId: string;
29
35
  apiKey: string;
30
36
  branchOverride?: string;
37
+ defaultBranchName?: string;
31
38
  puckConfigPath: string;
32
39
  }
33
40
 
@@ -36,6 +43,11 @@ export function validateEnv(env: Record<string, string | undefined>): ValidatedE
36
43
  const siteId = env.CSS_SITE_ID ?? env.NEXT_PUBLIC_CSS_SITE_ID;
37
44
  const apiKey = env.CSS_REGISTRY_API_KEY;
38
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";
39
51
  const puckConfigPath = env.PUCK_CONFIG_PATH ?? "puck.config.tsx";
40
52
 
41
53
  const missing: string[] = [];
@@ -65,6 +77,7 @@ export function validateEnv(env: Record<string, string | undefined>): ValidatedE
65
77
  siteId: siteId as string,
66
78
  apiKey: apiKey as string,
67
79
  branchOverride,
80
+ defaultBranchName,
68
81
  puckConfigPath,
69
82
  };
70
83
  }
@@ -82,8 +95,60 @@ export function resolveConfigModule(mod: unknown): unknown {
82
95
  */
83
96
  export class NoBranchMatchError extends Error {}
84
97
 
85
- export function resolveBranchId(branches: Branch[], siteId: string, override?: string): string {
86
- if (override !== undefined && override !== "") {
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) {
87
152
  const match = branches.find((b) => b.id === override || b.name === override);
88
153
  if (match === undefined) {
89
154
  throw new NoBranchMatchError(`No branch matching "${override}" found for site ${siteId}`);
@@ -104,29 +169,41 @@ async function main(): Promise<void> {
104
169
  // never installs a process-wide loader hook.
105
170
  register("./asset-stub-hooks.mjs", import.meta.url);
106
171
 
107
- const { baseUrl, siteId, apiKey, branchOverride, puckConfigPath } = validateEnv(process.env);
172
+ const { baseUrl, siteId, apiKey, branchOverride, defaultBranchName, puckConfigPath } =
173
+ validateEnv(process.env);
108
174
 
109
175
  const configUrl = pathToFileURL(path.resolve(process.cwd(), puckConfigPath)).href;
110
176
  const mod: unknown = await import(configUrl);
111
177
  const puckConfig = resolveConfigModule(mod);
112
178
 
113
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
+ }
114
189
 
115
190
  const client = new P1Client({ baseUrl, apiKey });
116
191
  const branches = await client.branches.list(siteId);
117
- const branchId = resolveBranchId(branches, siteId, branchOverride);
192
+ const branchId = resolveBranchId(branches, siteId, branchOverride, defaultBranchName);
118
193
 
119
194
  if (dryRun) {
120
195
  console.log(
121
- `[sync-puck-registry] Dry run: ${String(descriptors.length)} component descriptor(s) found for site ${siteId}, branch ${branchId}. No writes performed.`,
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.`,
122
198
  );
123
199
  return;
124
200
  }
125
201
 
126
- const result = await syncComponentRegistryWriteOnly(client, siteId, branchId, descriptors);
202
+ const result = await syncComponentRegistryWriteOnly(client, siteId, branchId, writable);
127
203
  console.log(
128
204
  `[sync-puck-registry] Synced site ${siteId}, branch ${branchId}: ` +
129
- `wrote ${String(result.total)} component descriptor(s) + registry index`,
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)` : ""),
130
207
  );
131
208
  }
132
209
 
@@ -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
  });