@pantheon-systems/create-p1-starter-kit 0.12.0 → 0.13.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.
- package/README.md +3 -3
- package/lib/cli.js +122 -53
- package/lib/cli.test.js +55 -0
- package/lib/copy-template.js +37 -0
- package/lib/copy-template.test.js +42 -1
- package/lib/messages.js +1 -1
- package/package.json +5 -4
- package/template/README.md +82 -0
- package/template/__tests__/auth-route.test.ts +1 -1
- package/template/__tests__/seo-metadata.test.ts +1 -1
- package/template/__tests__/styles-canvas-scope.test.ts +1 -1
- package/template/__tests__/widget-logout.test.ts +114 -0
- package/template/app/[...puckPath]/client.tsx +38 -10
- package/template/app/[...puckPath]/page.tsx +4 -4
- package/template/app/[...puckPath]/widget-logout.ts +36 -0
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +10 -67
- package/template/app/styles.css +1 -1
- package/template/ci-examples/github-actions-sync-puck-registry.yml +15 -7
- package/template/eslint.config.js +63 -0
- package/template/gitignore +43 -0
- package/template/lib/chatbot-flag/ai-generate.ts +1 -1
- package/template/lib/page-seo.ts +1 -1
- package/template/package.json +5 -5
- package/template/scripts/__tests__/sync-puck-registry.test.ts +3 -3
- package/template/scripts/sync-puck-registry.ts +18 -18
- package/template/CHANGELOG.md +0 -76
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import type { Data } from "@puckeditor/core";
|
|
5
|
-
import { RenderClient } from "@pantheon-systems/puck-css";
|
|
5
|
+
import { RenderClient, performLogout, P1_LOGGED_IN_KEY } from "@pantheon-systems/puck-css";
|
|
6
6
|
import config from "../../puck.config";
|
|
7
|
+
import { runWidgetLogout } from "./widget-logout";
|
|
7
8
|
|
|
8
9
|
function EditIcon() {
|
|
9
10
|
return (
|
|
@@ -35,10 +36,12 @@ function LogoutIcon() {
|
|
|
35
36
|
export function P1EditWidget({ route }: { route: string }) {
|
|
36
37
|
const [hasToken, setHasToken] = useState(false);
|
|
37
38
|
const [open, setOpen] = useState(false);
|
|
39
|
+
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
|
40
|
+
const [logoutError, setLogoutError] = useState<string | null>(null);
|
|
38
41
|
|
|
39
42
|
useEffect(() => {
|
|
40
43
|
const flag = typeof localStorage !== "undefined"
|
|
41
|
-
? localStorage.getItem(
|
|
44
|
+
? localStorage.getItem(P1_LOGGED_IN_KEY)
|
|
42
45
|
: null;
|
|
43
46
|
setHasToken(!!flag);
|
|
44
47
|
}, []);
|
|
@@ -63,6 +66,22 @@ export function P1EditWidget({ route }: { route: string }) {
|
|
|
63
66
|
|
|
64
67
|
const editHref = `/p1${route === "/" ? "" : route}`;
|
|
65
68
|
|
|
69
|
+
const handleLogout = () =>
|
|
70
|
+
runWidgetLogout({
|
|
71
|
+
logout: () =>
|
|
72
|
+
performLogout({
|
|
73
|
+
cssBaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL ?? "http://localhost:8787",
|
|
74
|
+
}),
|
|
75
|
+
navigate: (url) => {
|
|
76
|
+
window.location.href = url;
|
|
77
|
+
},
|
|
78
|
+
reload: () => {
|
|
79
|
+
window.location.reload();
|
|
80
|
+
},
|
|
81
|
+
setBusy: setIsLoggingOut,
|
|
82
|
+
setError: setLogoutError,
|
|
83
|
+
});
|
|
84
|
+
|
|
66
85
|
return (
|
|
67
86
|
<div
|
|
68
87
|
data-p1-widget
|
|
@@ -145,12 +164,8 @@ export function P1EditWidget({ route }: { route: string }) {
|
|
|
145
164
|
<div style={{ height: 1, background: "#e0e0e0", margin: "6px 4px" }} />
|
|
146
165
|
<button
|
|
147
166
|
role="menuitem"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
localStorage.removeItem("p1_auth_token");
|
|
151
|
-
localStorage.removeItem("css_broker_token");
|
|
152
|
-
window.location.reload();
|
|
153
|
-
}}
|
|
167
|
+
disabled={isLoggingOut}
|
|
168
|
+
onClick={handleLogout}
|
|
154
169
|
style={{
|
|
155
170
|
display: "flex",
|
|
156
171
|
alignItems: "center",
|
|
@@ -163,15 +178,28 @@ export function P1EditWidget({ route }: { route: string }) {
|
|
|
163
178
|
textDecoration: "none",
|
|
164
179
|
background: "transparent",
|
|
165
180
|
border: "none",
|
|
166
|
-
cursor: "pointer",
|
|
181
|
+
cursor: isLoggingOut ? "not-allowed" : "pointer",
|
|
167
182
|
fontFamily: "inherit",
|
|
168
183
|
}}
|
|
169
184
|
onMouseEnter={(e) => { e.currentTarget.style.background = "#f5f5f5"; }}
|
|
170
185
|
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
|
171
186
|
>
|
|
172
187
|
<span style={{ color: "#888", flex: "0 0 auto" }}><LogoutIcon /></span>
|
|
173
|
-
Log out
|
|
188
|
+
{isLoggingOut ? "Logging out…" : "Log out"}
|
|
174
189
|
</button>
|
|
190
|
+
{logoutError && (
|
|
191
|
+
<p
|
|
192
|
+
role="alert"
|
|
193
|
+
style={{
|
|
194
|
+
margin: "2px 10px 6px",
|
|
195
|
+
fontSize: 12,
|
|
196
|
+
lineHeight: 1.4,
|
|
197
|
+
color: "#b3261e",
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{logoutError}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
175
203
|
</div>
|
|
176
204
|
)}
|
|
177
205
|
</div>
|
|
@@ -21,7 +21,7 @@ import { ContentUnavailable } from "../../components/content-unavailable";
|
|
|
21
21
|
import { resolvePageMetadata } from "../../lib/page-seo";
|
|
22
22
|
import { Client } from "./client";
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const getCcrQueryFetchers = cache(() => createCssQueryFetchers());
|
|
25
25
|
|
|
26
26
|
// Document namespaces that live alongside pages but are never routable.
|
|
27
27
|
const INTERNAL_PATH_PREFIXES = ["/_registry", "/_redirects"];
|
|
@@ -101,11 +101,11 @@ export default async function Page({
|
|
|
101
101
|
|
|
102
102
|
const data = result.data;
|
|
103
103
|
|
|
104
|
-
const [routeTemplateKeys,
|
|
104
|
+
const [routeTemplateKeys, ccrQueryFetchers] = await Promise.all([
|
|
105
105
|
loadRouteTemplateKeys(),
|
|
106
|
-
|
|
106
|
+
getCcrQueryFetchers(),
|
|
107
107
|
]);
|
|
108
|
-
const builtinFetchers = [...REMOTE_DATASOURCE_FETCHERS, ...
|
|
108
|
+
const builtinFetchers = [...REMOTE_DATASOURCE_FETCHERS, ...ccrQueryFetchers];
|
|
109
109
|
|
|
110
110
|
const referencedDatasourceIds = extractReferencedDatasourceIds(data);
|
|
111
111
|
const context = await loadRemoteDatasourceContext({
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LogoutOutcome } from "@pantheon-systems/puck-css";
|
|
2
|
+
|
|
3
|
+
export type WidgetLogoutEffects = {
|
|
4
|
+
logout: () => Promise<LogoutOutcome>;
|
|
5
|
+
navigate: (url: string) => void;
|
|
6
|
+
reload: () => void;
|
|
7
|
+
setBusy: (busy: boolean) => void;
|
|
8
|
+
setError: (message: string | null) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Runs one logout attempt for the widget. Kept outside the component so each
|
|
13
|
+
* outcome — navigate away, stay and explain, reload — can be exercised without
|
|
14
|
+
* a browser.
|
|
15
|
+
*/
|
|
16
|
+
export async function runWidgetLogout(fx: WidgetLogoutEffects): Promise<void> {
|
|
17
|
+
fx.setBusy(true);
|
|
18
|
+
fx.setError(null);
|
|
19
|
+
try {
|
|
20
|
+
const outcome = await fx.logout();
|
|
21
|
+
if (outcome.status === "signed_out") {
|
|
22
|
+
fx.navigate(outcome.logoutUrl);
|
|
23
|
+
return; // navigation takes over
|
|
24
|
+
}
|
|
25
|
+
if (outcome.status === "error") {
|
|
26
|
+
// Still signed in and retryable — keep the menu open and say why.
|
|
27
|
+
fx.setError(outcome.message);
|
|
28
|
+
fx.setBusy(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
fx.reload(); // no_session: drop any stale widget state
|
|
32
|
+
} catch (err) {
|
|
33
|
+
fx.setError(err instanceof Error ? err.message : "Logout failed");
|
|
34
|
+
fx.setBusy(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
wrapConfigForEditorPreview,
|
|
15
15
|
P1QueryProvider,
|
|
16
16
|
editorPathHref,
|
|
17
|
+
EditorReloadOverlay,
|
|
17
18
|
} from "@pantheon-systems/puck-css";
|
|
18
19
|
import { DatasourceRegistryProvider, DatasourceDataProvider } from "@pantheon-systems/puck-css/fields";
|
|
19
20
|
import { LoadingMessage } from "@pantheon-systems/puck-css/pds";
|
|
@@ -120,7 +121,6 @@ export function EditorClientWrapper() {
|
|
|
120
121
|
const pathname = usePathname();
|
|
121
122
|
const path = editorPagePathFromUrlPath(pathname);
|
|
122
123
|
const [userRole, setUserRole] = useState<ContentRole>('editor');
|
|
123
|
-
const lastGoodStateRef = React.useRef<{ puckKey: string; puckProps: any } | null>(null);
|
|
124
124
|
|
|
125
125
|
if (!p1Config) {
|
|
126
126
|
return (
|
|
@@ -145,7 +145,7 @@ export function EditorClientWrapper() {
|
|
|
145
145
|
loginFallback={<P1SignInPage />}
|
|
146
146
|
>
|
|
147
147
|
<ChatbotFlagProvider>
|
|
148
|
-
<EditorContent path={path}
|
|
148
|
+
<EditorContent path={path} />
|
|
149
149
|
</ChatbotFlagProvider>
|
|
150
150
|
</P1App>
|
|
151
151
|
{process.env.NEXT_PUBLIC_ENABLE_ROLE_SWITCHER === 'true' && (
|
|
@@ -206,13 +206,7 @@ function RoleSwitcher({
|
|
|
206
206
|
);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
function EditorContent({
|
|
210
|
-
path,
|
|
211
|
-
lastGoodStateRef,
|
|
212
|
-
}: {
|
|
213
|
-
path: string;
|
|
214
|
-
lastGoodStateRef: React.MutableRefObject<{ puckKey: string; puckProps: any } | null>;
|
|
215
|
-
}) {
|
|
209
|
+
function EditorContent({ path }: { path: string }) {
|
|
216
210
|
const router = useRouter();
|
|
217
211
|
const { getToken } = useP1Auth();
|
|
218
212
|
const { data: editorCtx } = useEditorContext(path);
|
|
@@ -280,7 +274,7 @@ function EditorContent({
|
|
|
280
274
|
[getToken],
|
|
281
275
|
);
|
|
282
276
|
|
|
283
|
-
const { loading, error, puckKey, puckProps } = useP1Editor({
|
|
277
|
+
const { loading, reloading, hasContent, error, puckKey, puckProps } = useP1Editor({
|
|
284
278
|
documentPath: path,
|
|
285
279
|
puckConfig: editorConfig,
|
|
286
280
|
additionalPlugins,
|
|
@@ -305,24 +299,17 @@ function EditorContent({
|
|
|
305
299
|
},
|
|
306
300
|
});
|
|
307
301
|
|
|
308
|
-
// Update last good state when loading completes successfully (ref passed from parent)
|
|
309
|
-
React.useEffect(() => {
|
|
310
|
-
if (!loading && !error) {
|
|
311
|
-
lastGoodStateRef.current = { puckKey, puckProps };
|
|
312
|
-
}
|
|
313
|
-
}, [loading, error, puckKey, puckProps]);
|
|
314
|
-
|
|
315
302
|
if (redirecting) {
|
|
316
303
|
return <LoadingMessage message="Redirecting" data-testid="editor-redirecting" />;
|
|
317
304
|
}
|
|
318
305
|
|
|
319
|
-
|
|
320
|
-
if (loading && !lastGoodStateRef.current) {
|
|
306
|
+
if (loading) {
|
|
321
307
|
return <LoadingMessage message="Loading document" data-testid="editor-loading" />;
|
|
322
308
|
}
|
|
323
309
|
|
|
324
|
-
//
|
|
325
|
-
|
|
310
|
+
// A failed load with a document already on screen keeps that document; only a
|
|
311
|
+
// failure with nothing to fall back on takes over the view.
|
|
312
|
+
if (error && !hasContent) {
|
|
326
313
|
return (
|
|
327
314
|
<div style={{ textAlign: "center", padding: "4rem", fontFamily: "system-ui" }}>
|
|
328
315
|
<h3>Error loading document</h3>
|
|
@@ -331,57 +318,13 @@ function EditorContent({
|
|
|
331
318
|
);
|
|
332
319
|
}
|
|
333
320
|
|
|
334
|
-
// Use current state if loaded, otherwise keep showing last good state
|
|
335
|
-
const displayState = (!loading && !error)
|
|
336
|
-
? { puckKey, puckProps }
|
|
337
|
-
: lastGoodStateRef.current ?? { puckKey, puckProps };
|
|
338
|
-
|
|
339
321
|
return (
|
|
340
322
|
<div className="puck-editor-theme" style={{ position: "relative" }}>
|
|
341
|
-
{
|
|
342
|
-
{loading && lastGoodStateRef.current && (
|
|
343
|
-
<div
|
|
344
|
-
style={{
|
|
345
|
-
position: "fixed",
|
|
346
|
-
top: "50%",
|
|
347
|
-
left: "50%",
|
|
348
|
-
transform: "translate(-50%, -50%)",
|
|
349
|
-
zIndex: 9999,
|
|
350
|
-
background: "rgba(255, 255, 255, 0.95)",
|
|
351
|
-
padding: "1rem 2rem",
|
|
352
|
-
borderRadius: "8px",
|
|
353
|
-
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
|
354
|
-
fontFamily: "system-ui",
|
|
355
|
-
fontSize: "14px",
|
|
356
|
-
color: "#333",
|
|
357
|
-
fontWeight: 500,
|
|
358
|
-
display: "flex",
|
|
359
|
-
alignItems: "center",
|
|
360
|
-
gap: "0.75rem",
|
|
361
|
-
}}
|
|
362
|
-
>
|
|
363
|
-
<div
|
|
364
|
-
style={{
|
|
365
|
-
width: "16px",
|
|
366
|
-
height: "16px",
|
|
367
|
-
border: "2px solid #e0e0e0",
|
|
368
|
-
borderTopColor: "#2563eb",
|
|
369
|
-
borderRadius: "50%",
|
|
370
|
-
animation: "spin 0.6s linear infinite",
|
|
371
|
-
}}
|
|
372
|
-
/>
|
|
373
|
-
Switching workstream...
|
|
374
|
-
<style>{`
|
|
375
|
-
@keyframes spin {
|
|
376
|
-
to { transform: rotate(360deg); }
|
|
377
|
-
}
|
|
378
|
-
`}</style>
|
|
379
|
-
</div>
|
|
380
|
-
)}
|
|
323
|
+
<EditorReloadOverlay reloading={reloading} />
|
|
381
324
|
<DatasourceRegistryProvider registry={editorCtx?.remoteDatasourceRegistry ?? []}>
|
|
382
325
|
<DatasourceDataProvider context={remoteDatasourceContext}>
|
|
383
326
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
384
|
-
<Puck key={`${
|
|
327
|
+
<Puck key={`${puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...puckProps as any} _experimentalFullScreenCanvas={true} />
|
|
385
328
|
</DatasourceDataProvider>
|
|
386
329
|
</DatasourceRegistryProvider>
|
|
387
330
|
</div>
|
package/template/app/styles.css
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
@source "../node_modules/@pantheon-systems/puck-css/dist";
|
|
8
8
|
|
|
9
9
|
/* Scoped via :has() rather than a bare `body {...}` selector so this reset
|
|
10
|
-
stays out of Puck's canvas-preview iframe
|
|
10
|
+
stays out of Puck's canvas-preview iframe.
|
|
11
11
|
Puck's collectStyles() copies every parent <style>/<link> into the canvas
|
|
12
12
|
iframe verbatim (querySelectorAll('style, link[rel="stylesheet"]'), no
|
|
13
13
|
exclusion API) — so a bare `body {...}` rule here would also match inside
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Optional: syncs this site's Puck component registry to the
|
|
1
|
+
# Optional: syncs this site's Puck component registry to the backend
|
|
2
2
|
# headlessly on every push, without anyone needing to open the editor.
|
|
3
3
|
#
|
|
4
4
|
# This file is NOT active until you copy it into .github/workflows/ yourself
|
|
@@ -12,11 +12,12 @@
|
|
|
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
|
|
16
|
-
# git branch's name: the repo's default branch always targets the
|
|
17
|
-
#
|
|
18
|
-
# branch by name. A push on a non-default branch with
|
|
19
|
-
# is not an error: the script logs a skip and
|
|
15
|
+
# components/puck/**. The sync script resolves the target branch from the
|
|
16
|
+
# pushed git branch's name: the repo's default branch always targets the
|
|
17
|
+
# site's main branch (whatever the git branch is called), any other ref
|
|
18
|
+
# matches a branch on the site by name. A push on a non-default branch with
|
|
19
|
+
# no matching branch on the site is not an error: the script logs a skip and
|
|
20
|
+
# exits 0.
|
|
20
21
|
#
|
|
21
22
|
# CSS_DEFAULT_BRANCH defaults to "main" if omitted — repos whose default
|
|
22
23
|
# branch has another name (master, trunk) need the line below (or must set
|
|
@@ -33,10 +34,17 @@ on:
|
|
|
33
34
|
workflow_dispatch:
|
|
34
35
|
inputs:
|
|
35
36
|
branch_id:
|
|
36
|
-
description: '
|
|
37
|
+
description: 'Branch ID/name to sync against (blank = match the current git branch, else site main)'
|
|
37
38
|
required: false
|
|
38
39
|
default: ''
|
|
39
40
|
|
|
41
|
+
# A branch pushed several times in quick succession only needs its newest
|
|
42
|
+
# component set synced. Without this, every push starts its own run and they
|
|
43
|
+
# race each other into the same registry documents.
|
|
44
|
+
concurrency:
|
|
45
|
+
group: sync-puck-registry-${{ github.ref }}
|
|
46
|
+
cancel-in-progress: true
|
|
47
|
+
|
|
40
48
|
jobs:
|
|
41
49
|
sync-registry:
|
|
42
50
|
runs-on: ubuntu-latest
|
|
@@ -6,7 +6,31 @@ import react from 'eslint-plugin-react';
|
|
|
6
6
|
import reactHooks from 'eslint-plugin-react-hooks';
|
|
7
7
|
import prettierConfig from 'eslint-config-prettier';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Test-file relaxations.
|
|
11
|
+
*
|
|
12
|
+
* Append this AFTER the base/react/worker layers so it wins. Each rule here is
|
|
13
|
+
* off because the pattern it flags is idiomatic in tests, not debt — measured
|
|
14
|
+
* against the repo on 2026-08-05, where 79% of no-non-null-assertion and 77% of
|
|
15
|
+
* no-empty-function findings were in test files.
|
|
16
|
+
*
|
|
17
|
+
* Rules deliberately NOT relaxed: no-floating-promises and no-misused-promises
|
|
18
|
+
* (an unawaited promise in a test is a silently passing test),
|
|
19
|
+
* no-unnecessary-condition (it found real dead assertions), and no-unused-vars.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TEST_FILES = [
|
|
23
|
+
'**/*.{test,spec}.{js,jsx,mjs,cjs,ts,tsx}',
|
|
24
|
+
'**/tests/**/*.{js,jsx,mjs,cjs,ts,tsx}',
|
|
25
|
+
'**/test/**/*.{js,jsx,mjs,cjs,ts,tsx}',
|
|
26
|
+
'**/__tests__/**/*.{js,jsx,mjs,cjs,ts,tsx}',
|
|
27
|
+
'**/__mocks__/**/*.{js,jsx,mjs,cjs,ts,tsx}',
|
|
28
|
+
'**/test-stubs/**/*.{js,jsx,mjs,cjs,ts,tsx}',
|
|
29
|
+
'**/*.setup.{js,mjs,cjs,ts}',
|
|
30
|
+
];
|
|
31
|
+
|
|
9
32
|
export default tseslint.config(
|
|
33
|
+
// @pantheon-systems/eslint-config/base
|
|
10
34
|
eslint.configs.recommended,
|
|
11
35
|
...tseslint.configs.recommended,
|
|
12
36
|
...tseslint.configs.strict,
|
|
@@ -42,12 +66,14 @@ export default tseslint.config(
|
|
|
42
66
|
'@typescript-eslint/consistent-type-imports': 'warn',
|
|
43
67
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
44
68
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
69
|
+
|
|
45
70
|
// Import rules
|
|
46
71
|
'import/extensions': 'off',
|
|
47
72
|
'import/prefer-default-export': 'off',
|
|
48
73
|
'import/no-unresolved': 'off',
|
|
49
74
|
'import/order': 'warn',
|
|
50
75
|
'import/no-extraneous-dependencies': 'warn',
|
|
76
|
+
|
|
51
77
|
// General ESLint rules
|
|
52
78
|
'no-console': 'off',
|
|
53
79
|
'no-debugger': 'error',
|
|
@@ -75,6 +101,7 @@ export default tseslint.config(
|
|
|
75
101
|
],
|
|
76
102
|
},
|
|
77
103
|
],
|
|
104
|
+
|
|
78
105
|
// Stricter rules (warn to resolve over time)
|
|
79
106
|
'@typescript-eslint/consistent-generic-constructors': 'warn',
|
|
80
107
|
'@typescript-eslint/consistent-indexed-object-style': 'warn',
|
|
@@ -146,6 +173,7 @@ export default tseslint.config(
|
|
|
146
173
|
'**/.puppeteerrc.cjs',
|
|
147
174
|
],
|
|
148
175
|
},
|
|
176
|
+
// @pantheon-systems/eslint-config/react
|
|
149
177
|
{
|
|
150
178
|
files: ['**/*.{ts,tsx,jsx}'],
|
|
151
179
|
plugins: {
|
|
@@ -173,5 +201,40 @@ export default tseslint.config(
|
|
|
173
201
|
'react-hooks/exhaustive-deps': 'warn',
|
|
174
202
|
},
|
|
175
203
|
},
|
|
204
|
+
// @pantheon-systems/eslint-config/prettier
|
|
176
205
|
prettierConfig,
|
|
206
|
+
// @pantheon-systems/eslint-config/tests
|
|
207
|
+
{
|
|
208
|
+
files: TEST_FILES,
|
|
209
|
+
rules: {
|
|
210
|
+
// `x!` after a known-good arrange step asserts the fixture, it doesn't hide a bug.
|
|
211
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
212
|
+
// `() => {}` is the entire point of a stub.
|
|
213
|
+
'@typescript-eslint/no-empty-function': 'off',
|
|
214
|
+
// Passing an unbound method to vi.spyOn / expect is the documented API.
|
|
215
|
+
'@typescript-eslint/unbound-method': 'off',
|
|
216
|
+
// Test helpers are read at the call site; annotating their returns is noise.
|
|
217
|
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
218
|
+
// `async` with no await is how you satisfy an interface in a fake.
|
|
219
|
+
'@typescript-eslint/require-await': 'off',
|
|
220
|
+
// Mock payloads are structurally untyped by nature.
|
|
221
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
222
|
+
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
223
|
+
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
224
|
+
'@typescript-eslint/no-unsafe-call': 'off',
|
|
225
|
+
'@typescript-eslint/no-unsafe-argument': 'off',
|
|
226
|
+
'@typescript-eslint/no-unsafe-return': 'off',
|
|
227
|
+
'@typescript-eslint/no-useless-constructor': 'off',
|
|
228
|
+
// Stand-ins for `cloudflare:*` built-ins have to be classes to stand in
|
|
229
|
+
// for classes, even with nothing in them.
|
|
230
|
+
'@typescript-eslint/no-extraneous-class': 'off',
|
|
231
|
+
// `vi.importActual<typeof import('mod')>('mod')` is vitest's documented
|
|
232
|
+
// shape and cannot be hoisted to a top-level type import.
|
|
233
|
+
'@typescript-eslint/consistent-type-imports': ['warn', { disallowTypeAnnotations: false }],
|
|
234
|
+
// Inline fixtures and assertion chains run long. The formatter will own
|
|
235
|
+
// line length once it lands; until then this is the only rule that would
|
|
236
|
+
// force hand-wrapping code a formatter is about to rewrite.
|
|
237
|
+
'max-len': 'off',
|
|
238
|
+
},
|
|
239
|
+
},
|
|
177
240
|
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.js
|
|
7
|
+
|
|
8
|
+
.claude/
|
|
9
|
+
|
|
10
|
+
# testing
|
|
11
|
+
/coverage
|
|
12
|
+
|
|
13
|
+
# next.js
|
|
14
|
+
/.next/
|
|
15
|
+
/out/
|
|
16
|
+
|
|
17
|
+
# typescript
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
next-env.d.ts
|
|
20
|
+
|
|
21
|
+
.env
|
|
22
|
+
!.env.example
|
|
23
|
+
|
|
24
|
+
# production
|
|
25
|
+
/build
|
|
26
|
+
|
|
27
|
+
# misc
|
|
28
|
+
.DS_Store
|
|
29
|
+
*.pem
|
|
30
|
+
|
|
31
|
+
# debug
|
|
32
|
+
npm-debug.log*
|
|
33
|
+
yarn-debug.log*
|
|
34
|
+
yarn-error.log*
|
|
35
|
+
|
|
36
|
+
# local env files
|
|
37
|
+
.env.local
|
|
38
|
+
.env.development.local
|
|
39
|
+
.env.test.local
|
|
40
|
+
.env.production.local
|
|
41
|
+
|
|
42
|
+
# vercel
|
|
43
|
+
.vercel
|
|
@@ -6,7 +6,7 @@ import type { DraftRequestChannel } from "@pantheon-systems/p1-ai-chat";
|
|
|
6
6
|
*
|
|
7
7
|
* The page does not exist yet: the chat proposes the page template it should start from and
|
|
8
8
|
* creates it once the user agrees, because a document's template can only be set as it is
|
|
9
|
-
* created
|
|
9
|
+
* created. Until then the request carries the title and path the dialog collected.
|
|
10
10
|
*/
|
|
11
11
|
export function createGenerateWithAIHandler(
|
|
12
12
|
draftRequests: DraftRequestChannel,
|
package/template/lib/page-seo.ts
CHANGED
|
@@ -37,7 +37,7 @@ const carriesTemplate = (value: unknown): value is string =>
|
|
|
37
37
|
typeof value === "string" && value.includes("{{");
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Produces the per-page <head> Metadata for a route
|
|
40
|
+
* Produces the per-page <head> Metadata for a route. Title,
|
|
41
41
|
* description and the free-text metadata fields are template-allowed.
|
|
42
42
|
*/
|
|
43
43
|
export async function resolvePageMetadata({
|
package/template/package.json
CHANGED
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@pantheon-systems/cpub-react-sdk": "^5.2.1",
|
|
18
|
-
"@pantheon-systems/css-client": "^0.
|
|
19
|
-
"@pantheon-systems/p1-ai-chat": "^0.
|
|
20
|
-
"@pantheon-systems/p1-media": "^0.4.
|
|
21
|
-
"@pantheon-systems/p1-next-sdk": "^0.
|
|
18
|
+
"@pantheon-systems/css-client": "^0.13.0",
|
|
19
|
+
"@pantheon-systems/p1-ai-chat": "^0.6.0",
|
|
20
|
+
"@pantheon-systems/p1-media": "^0.4.5",
|
|
21
|
+
"@pantheon-systems/p1-next-sdk": "^0.13.0",
|
|
22
22
|
"@pantheon-systems/pds-toolkit-react": "2.0.0-alpha.66",
|
|
23
|
-
"@pantheon-systems/puck-css": "^0.
|
|
23
|
+
"@pantheon-systems/puck-css": "^0.13.0",
|
|
24
24
|
"@puckeditor/core": "^0.21.1",
|
|
25
25
|
"@tailwindcss/postcss": "^4.2.2",
|
|
26
26
|
"@tailwindcss/typography": "^0.5.16",
|
|
@@ -56,7 +56,7 @@ describe("validateEnv", () => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
it("defaults defaultBranchName to 'main' when CSS_DEFAULT_BRANCH is not set", () => {
|
|
59
|
-
// Safe because the
|
|
59
|
+
// Safe because the site's main content branch is always literally named
|
|
60
60
|
// "main": a push override of "main" resolves to the same branch either
|
|
61
61
|
// by name match or by isMain, so the default only adds semantics.
|
|
62
62
|
const result = validateEnv(baseEnv());
|
|
@@ -145,8 +145,8 @@ describe("resolveBranchId", () => {
|
|
|
145
145
|
|
|
146
146
|
describe("resolveBranchId default-branch semantics", () => {
|
|
147
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
|
|
149
|
-
// main branch (whose name is always "main") — the sync silently skips on
|
|
148
|
+
// default branch is not literally named "main" would never match the
|
|
149
|
+
// site's main branch (whose name is always "main") — the sync silently skips on
|
|
150
150
|
// every default-branch push. When the caller also supplies the repo's
|
|
151
151
|
// default branch name, an override equal to it must resolve via isMain.
|
|
152
152
|
const branches = [
|
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
* Syncs this site's Puck component registry (_registry/components/* and the
|
|
3
3
|
* registry index) headlessly, without opening the editor in a browser.
|
|
4
4
|
* Intended to run from CI on push to main or a branch whose name matches a
|
|
5
|
-
*
|
|
5
|
+
* branch on the site, whenever puck.config.tsx or components/puck/** change.
|
|
6
6
|
*
|
|
7
7
|
* Usage: tsx scripts/sync-puck-registry.ts [--dry-run]
|
|
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
11
|
* Optional:
|
|
12
|
-
* CSS_BRANCH_ID —
|
|
12
|
+
* CSS_BRANCH_ID — branch to target (in CI: the pushed git ref's name)
|
|
13
13
|
* CSS_DEFAULT_BRANCH — the repo's default branch name (defaults to "main");
|
|
14
14
|
* when CSS_BRANCH_ID equals it, the site's isMain branch is targeted
|
|
15
15
|
* regardless of naming (see resolveBranchId for the resolution contract)
|
|
16
16
|
*
|
|
17
17
|
* CSS_REGISTRY_API_KEY must be a sat_ site token scoped to write:registry
|
|
18
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
|
|
20
|
-
* descriptor + the registry index
|
|
21
|
-
* see syncComponentRegistryWriteOnly.
|
|
19
|
+
* token has no read access at all, every run posts every component
|
|
20
|
+
* descriptor + the registry index; the backend compares each against stored
|
|
21
|
+
* content and writes only what changed — see syncComponentRegistryWriteOnly.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { register } from "node:module";
|
|
@@ -43,7 +43,7 @@ export function validateEnv(env: Record<string, string | undefined>): ValidatedE
|
|
|
43
43
|
const siteId = env.CSS_SITE_ID ?? env.NEXT_PUBLIC_CSS_SITE_ID;
|
|
44
44
|
const apiKey = env.CSS_REGISTRY_API_KEY;
|
|
45
45
|
const branchOverride = env.CSS_BRANCH_ID ?? env.NEXT_PUBLIC_CSS_BRANCH_ID;
|
|
46
|
-
// Defaults to "main": the
|
|
46
|
+
// Defaults to "main": the site's main content branch is always literally
|
|
47
47
|
// named "main", so for repos whose default git branch is also "main" this
|
|
48
48
|
// resolves to the same branch it would have matched by name. Repos with a
|
|
49
49
|
// differently-named default branch (master, trunk) must set it explicitly.
|
|
@@ -88,10 +88,10 @@ export function resolveConfigModule(mod: unknown): unknown {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* Thrown by resolveBranchId when no
|
|
92
|
-
* other errors so callers (main(), a CI trigger firing on every git
|
|
93
|
-
* can treat "this branch has no
|
|
94
|
-
* than a real sync failure.
|
|
91
|
+
* Thrown by resolveBranchId when no matching branch is found. Distinguished
|
|
92
|
+
* from other errors so callers (main(), a CI trigger firing on every git
|
|
93
|
+
* branch) can treat "this branch has no counterpart on the site" as a benign
|
|
94
|
+
* no-op rather than a real sync failure.
|
|
95
95
|
*/
|
|
96
96
|
export class NoBranchMatchError extends Error {}
|
|
97
97
|
|
|
@@ -133,12 +133,12 @@ export function filterAssetStubbedDescriptors(descriptors: Descriptor[]): {
|
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* Resolution contract: an override (in CI, always the pushed git ref's name)
|
|
136
|
-
* matches a
|
|
137
|
-
* default branch name, which always resolves the site's isMain
|
|
138
|
-
* main is always literally named "main", so
|
|
139
|
-
* default branch is "master"/"trunk" would
|
|
140
|
-
* default branch means the main registry,
|
|
141
|
-
*
|
|
136
|
+
* matches a branch on the site by id or name — EXCEPT when it equals the
|
|
137
|
+
* repo's default branch name, which always resolves the site's isMain
|
|
138
|
+
* branch. The site's main branch is always literally named "main", so
|
|
139
|
+
* without that rule a repo whose default branch is "master"/"trunk" would
|
|
140
|
+
* silently skip on every push. The default branch means the main registry,
|
|
141
|
+
* even over a coincidentally-named branch on the site, e.g. "master".
|
|
142
142
|
*/
|
|
143
143
|
export function resolveBranchId(
|
|
144
144
|
branches: Branch[],
|
|
@@ -211,8 +211,8 @@ const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").hr
|
|
|
211
211
|
if (isMainModule) {
|
|
212
212
|
main().catch((err: unknown) => {
|
|
213
213
|
// A CI trigger firing on every git branch push has no way to know ahead
|
|
214
|
-
// of time which branches have a matching
|
|
215
|
-
// discovered at runtime. Treat "no match" as a benign no-op, not a
|
|
214
|
+
// of time which branches have a matching branch on the site — that has
|
|
215
|
+
// to be discovered at runtime. Treat "no match" as a benign no-op, not a
|
|
216
216
|
// failure, so unrelated feature-branch pushes don't turn CI red.
|
|
217
217
|
if (err instanceof NoBranchMatchError) {
|
|
218
218
|
console.log(`[sync-puck-registry] Skipping: ${err.message}`);
|