@pantheon-systems/create-p1-starter-kit 0.11.1 → 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 +44 -1
- package/lib/copy-template.test.js +75 -0
- 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 +6 -2
- 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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { LogoutOutcome } from "@pantheon-systems/puck-css";
|
|
3
|
+
import { runWidgetLogout } from "../app/[...puckPath]/widget-logout";
|
|
4
|
+
|
|
5
|
+
// Records the effects in order, so each test asserts the whole sequence a
|
|
6
|
+
// logout attempt produces rather than one call in isolation.
|
|
7
|
+
function recorder(logout: () => Promise<LogoutOutcome>) {
|
|
8
|
+
const calls: string[] = [];
|
|
9
|
+
return {
|
|
10
|
+
calls,
|
|
11
|
+
fx: {
|
|
12
|
+
logout,
|
|
13
|
+
navigate: (url: string) => calls.push(`navigate:${url}`),
|
|
14
|
+
reload: () => calls.push("reload"),
|
|
15
|
+
setBusy: (busy: boolean) => calls.push(`busy:${busy}`),
|
|
16
|
+
setError: (message: string | null) =>
|
|
17
|
+
calls.push(`error:${message ?? "cleared"}`),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const LOGOUT_URL = "https://example.auth0.com/v2/logout?client_id=abc";
|
|
23
|
+
|
|
24
|
+
describe("runWidgetLogout", () => {
|
|
25
|
+
it("navigates to the Auth0 logout URL when the session ended", async () => {
|
|
26
|
+
const { calls, fx } = recorder(async () => ({
|
|
27
|
+
status: "signed_out",
|
|
28
|
+
logoutUrl: LOGOUT_URL,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
await runWidgetLogout(fx);
|
|
32
|
+
|
|
33
|
+
// Stays busy: the navigation replaces the page, so releasing the button
|
|
34
|
+
// would only flash it back to "Log out" on the way out.
|
|
35
|
+
expect(calls).toEqual([
|
|
36
|
+
"busy:true",
|
|
37
|
+
"error:cleared",
|
|
38
|
+
`navigate:${LOGOUT_URL}`,
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps the menu open and shows why when logout failed", async () => {
|
|
43
|
+
const { calls, fx } = recorder(async () => ({
|
|
44
|
+
status: "error",
|
|
45
|
+
message: "Broker logout failed (503)",
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
await runWidgetLogout(fx);
|
|
49
|
+
|
|
50
|
+
// Still signed in and retryable, so the button must come back.
|
|
51
|
+
expect(calls).toEqual([
|
|
52
|
+
"busy:true",
|
|
53
|
+
"error:cleared",
|
|
54
|
+
"error:Broker logout failed (503)",
|
|
55
|
+
"busy:false",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reloads when there was no session to end", async () => {
|
|
60
|
+
const { calls, fx } = recorder(async () => ({ status: "no_session" }));
|
|
61
|
+
|
|
62
|
+
await runWidgetLogout(fx);
|
|
63
|
+
|
|
64
|
+
expect(calls).toEqual(["busy:true", "error:cleared", "reload"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reports a thrown error instead of leaving the button stuck", async () => {
|
|
68
|
+
const { calls, fx } = recorder(async () => {
|
|
69
|
+
throw new Error("Failed to fetch");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await runWidgetLogout(fx);
|
|
73
|
+
|
|
74
|
+
expect(calls).toEqual([
|
|
75
|
+
"busy:true",
|
|
76
|
+
"error:cleared",
|
|
77
|
+
"error:Failed to fetch",
|
|
78
|
+
"busy:false",
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("falls back to a generic message when something non-Error is thrown", async () => {
|
|
83
|
+
const { calls, fx } = recorder(async () => {
|
|
84
|
+
throw "socket closed";
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await runWidgetLogout(fx);
|
|
88
|
+
|
|
89
|
+
expect(calls).toEqual([
|
|
90
|
+
"busy:true",
|
|
91
|
+
"error:cleared",
|
|
92
|
+
"error:Logout failed",
|
|
93
|
+
"busy:false",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("clears a previous failure before retrying", async () => {
|
|
98
|
+
const { calls, fx } = recorder(async () => ({
|
|
99
|
+
status: "signed_out",
|
|
100
|
+
logoutUrl: LOGOUT_URL,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
await runWidgetLogout(fx);
|
|
104
|
+
await runWidgetLogout(fx);
|
|
105
|
+
|
|
106
|
+
// The second attempt clears the slot again; a stale message must not sit
|
|
107
|
+
// under a logout that is now succeeding.
|
|
108
|
+
expect(calls.slice(3)).toEqual([
|
|
109
|
+
"busy:true",
|
|
110
|
+
"error:cleared",
|
|
111
|
+
`navigate:${LOGOUT_URL}`,
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@plugin "@tailwindcss/typography";
|
|
3
|
-
|
|
3
|
+
/* Tailwind must scan puck-css's own components for the utility classes they use.
|
|
4
|
+
node_modules-relative so the same path resolves in this monorepo (pnpm symlink)
|
|
5
|
+
and in a scaffolded project; dist rather than src because the published package
|
|
6
|
+
ships only compiled output. */
|
|
7
|
+
@source "../node_modules/@pantheon-systems/puck-css/dist";
|
|
4
8
|
|
|
5
9
|
/* Scoped via :has() rather than a bare `body {...}` selector so this reset
|
|
6
|
-
stays out of Puck's canvas-preview iframe
|
|
10
|
+
stays out of Puck's canvas-preview iframe.
|
|
7
11
|
Puck's collectStyles() copies every parent <style>/<link> into the canvas
|
|
8
12
|
iframe verbatim (querySelectorAll('style, link[rel="stylesheet"]'), no
|
|
9
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 = [
|