@pantheon-systems/create-p1-starter-kit 0.12.0 → 0.14.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 +7 -4
- package/template/README.md +82 -0
- package/template/__tests__/auth-route.test.ts +1 -1
- package/template/__tests__/chatbot-flag-context.test.ts +55 -0
- package/template/__tests__/data-list-block-sub-components.test.tsx +246 -0
- package/template/__tests__/data-list-block-utils.test.ts +84 -0
- package/template/__tests__/data-list-block.test.ts +198 -0
- package/template/__tests__/published-page-routes.test.ts +86 -0
- 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 +10 -117
- package/template/app/[...puckPath]/widget-logout.ts +36 -0
- package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +33 -140
- package/template/app/page.tsx +11 -69
- package/template/app/published-pages.tsx +23 -0
- package/template/app/styles.css +1 -1
- package/template/app/welcome-block.tsx +25 -0
- package/template/ci-examples/github-actions-sync-puck-registry.yml +15 -7
- package/template/components/ChatbotFlagProvider.tsx +3 -8
- package/template/components/p1-lockup.tsx +2 -1
- package/template/components/puck/image-block.tsx +5 -2
- package/template/components/puck/welcome-block-render.tsx +30 -37
- package/template/components/welcome-block.module.css +124 -0
- package/template/eslint.config.js +72 -1
- package/template/gitignore +43 -0
- package/template/lib/chatbot-flag/ai-generate.ts +1 -1
- package/template/lib/chatbot-flag/flag-context.ts +32 -0
- 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/vitest.config.ts +17 -20
- package/template/CHANGELOG.md +0 -76
- package/template/__tests__/published-page-404.test.ts +0 -65
package/template/app/page.tsx
CHANGED
|
@@ -1,77 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
loadPublishedPage,
|
|
8
|
-
loadRouteTemplateKeys,
|
|
9
|
-
} from "@pantheon-systems/p1-next-sdk/server";
|
|
10
|
-
import type { Metadata } from "next";
|
|
11
|
-
import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
|
|
12
|
-
import { resolvePageMetadata } from "../lib/page-seo";
|
|
13
|
-
import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
|
|
14
|
-
import { Client } from "./[...puckPath]/client";
|
|
1
|
+
/**
|
|
2
|
+
* Home route. The pipeline lives in the SDK — see createPublishedPage in
|
|
3
|
+
* app/published-pages.tsx for what this route is made of.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { published } from "./published-pages";
|
|
15
7
|
|
|
16
8
|
/**
|
|
17
9
|
* Backstop only: publishing calls revalidatePath("/"), so the home page
|
|
18
10
|
* normally refreshes the moment its content changes. This bounds how long an
|
|
19
11
|
* edit made outside that path can stay stale.
|
|
12
|
+
*
|
|
13
|
+
* Must stay a literal here: Next.js statically analyzes segment-config exports,
|
|
14
|
+
* so a value re-exported from the factory would go undetected.
|
|
20
15
|
*/
|
|
21
16
|
export const revalidate = 300;
|
|
22
17
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
if (result.status !== "ok") {
|
|
26
|
-
return { title: "P1 Starter Kit" };
|
|
27
|
-
}
|
|
28
|
-
return resolvePageMetadata({ pageData: result.data, path: "/" });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export default async function HomePage() {
|
|
32
|
-
const result = await loadPublishedPage("/");
|
|
33
|
-
|
|
34
|
-
// Unlike the catch-all, "/" never 404s: it is a single fixed URL rather than
|
|
35
|
-
// an unbounded crawler surface, and the welcome block is the correct state for
|
|
36
|
-
// a freshly scaffolded site — including one with no backend configured yet.
|
|
37
|
-
if (result.status === "ok") {
|
|
38
|
-
const data = result.data;
|
|
39
|
-
const routeTemplateKeys = await loadRouteTemplateKeys();
|
|
40
|
-
const referencedDatasourceIds = extractReferencedDatasourceIds(data);
|
|
41
|
-
const context = await loadRemoteDatasourceContext({
|
|
42
|
-
fetchImpl: fetch,
|
|
43
|
-
pagePath: "/",
|
|
44
|
-
routeTemplateKeys,
|
|
45
|
-
builtinFetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
46
|
-
referencedDatasourceIds,
|
|
47
|
-
});
|
|
48
|
-
const resolvedData = await resolveDataTemplates(data, context);
|
|
49
|
-
return (
|
|
50
|
-
<Client
|
|
51
|
-
data={resolvedData}
|
|
52
|
-
pageMetadata={{
|
|
53
|
-
route: "/",
|
|
54
|
-
documentName: data.root.props?.title as string | undefined,
|
|
55
|
-
pageType: "page",
|
|
56
|
-
}}
|
|
57
|
-
/>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<WelcomeBlockRender
|
|
63
|
-
heading="Welcome to your new Pantheon P1 Site."
|
|
64
|
-
description="You just created this new site from Pantheon P1 starter kit, congrats! You'll need a Pantheon P1 user account to edit it and create new pages."
|
|
65
|
-
ctaLabel="Sign-in to P1"
|
|
66
|
-
ctaHref="/p1"
|
|
67
|
-
footnote="Visit [P1 documentation](https://docs.pantheon.io) for more information."
|
|
68
|
-
loggedInHeading="Welcome to your new Pantheon P1 Site."
|
|
69
|
-
loggedInDescription="You just created this new site from Pantheon P1 starter kit, congrats! Start editing this page or visit the P1 dashboard to manage your site."
|
|
70
|
-
loggedInCtaLabel="Edit this page with P1 Visual Editor"
|
|
71
|
-
loggedInCtaHref="/p1"
|
|
72
|
-
loggedInSecondaryLabel="Go to P1 Dashboard"
|
|
73
|
-
loggedInFootnote="Visit [P1 documentation](https://docs.pantheon.io) for more information."
|
|
74
|
-
showLogo={true}
|
|
75
|
-
/>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
18
|
+
export const generateMetadata = published.generateHomeMetadata;
|
|
19
|
+
export default published.HomePage;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's published-page routes, built once and shared by both route files.
|
|
3
|
+
*
|
|
4
|
+
* One instance on purpose: the factory memoizes per-request work (the CCR query
|
|
5
|
+
* fetchers), and "/" and the catch-all should share that rather than each
|
|
6
|
+
* building their own.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createPublishedPage } from "@pantheon-systems/p1-next-sdk/server";
|
|
10
|
+
import { REMOTE_DATASOURCE_FETCHERS } from "../lib/remote-datasource-fetchers";
|
|
11
|
+
import { ContentUnavailable } from "../components/content-unavailable";
|
|
12
|
+
import { resolvePageMetadata } from "../lib/page-seo";
|
|
13
|
+
import { Client } from "./[...puckPath]/client";
|
|
14
|
+
import { WelcomeBlock } from "./welcome-block";
|
|
15
|
+
|
|
16
|
+
export const published = createPublishedPage({
|
|
17
|
+
Client,
|
|
18
|
+
Unavailable: ContentUnavailable,
|
|
19
|
+
Fallback: WelcomeBlock,
|
|
20
|
+
fetchers: REMOTE_DATASOURCE_FETCHERS,
|
|
21
|
+
resolveMetadata: resolvePageMetadata,
|
|
22
|
+
titles: { home: "P1 Starter Kit" },
|
|
23
|
+
});
|
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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home page's state before any content exists — a freshly scaffolded site,
|
|
3
|
+
* or one with no backend configured yet.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WelcomeBlockRender } from "../components/puck/welcome-block-render";
|
|
7
|
+
|
|
8
|
+
export function WelcomeBlock() {
|
|
9
|
+
return (
|
|
10
|
+
<WelcomeBlockRender
|
|
11
|
+
heading="Welcome to your new Pantheon P1 Site."
|
|
12
|
+
description="You just created this new site from Pantheon P1 starter kit, congrats! You'll need a Pantheon P1 user account to edit it and create new pages."
|
|
13
|
+
ctaLabel="Sign-in to P1"
|
|
14
|
+
ctaHref="/p1"
|
|
15
|
+
footnote="Visit [P1 documentation](https://docs.pantheon.io) for more information."
|
|
16
|
+
loggedInHeading="Welcome to your new Pantheon P1 Site."
|
|
17
|
+
loggedInDescription="You just created this new site from Pantheon P1 starter kit, congrats! Start editing this page or visit the P1 dashboard to manage your site."
|
|
18
|
+
loggedInCtaLabel="Edit this page with P1 Visual Editor"
|
|
19
|
+
loggedInCtaHref="/p1"
|
|
20
|
+
loggedInSecondaryLabel="Go to P1 Dashboard"
|
|
21
|
+
loggedInFootnote="Visit [P1 documentation](https://docs.pantheon.io) for more information."
|
|
22
|
+
showLogo={true}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -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
|
|
@@ -4,6 +4,8 @@ import React from "react";
|
|
|
4
4
|
import { LDProvider } from "launchdarkly-react-client-sdk";
|
|
5
5
|
import { useP1Auth } from "@pantheon-systems/puck-css";
|
|
6
6
|
|
|
7
|
+
import { buildFlagContext } from "../lib/chatbot-flag/flag-context";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Wraps the editor with a LaunchDarkly client-side provider so the `p1-chatbot`
|
|
9
11
|
* flag can be evaluated at runtime. The client-side ID is public by design.
|
|
@@ -28,14 +30,7 @@ export function ChatbotFlagProvider({
|
|
|
28
30
|
// re-identify on context change. This provider mounts inside <P1App> (after
|
|
29
31
|
// auth), so the authenticated user is available here; the anonymous fallback
|
|
30
32
|
// only applies if it ever renders pre-auth.
|
|
31
|
-
|
|
32
|
-
// Key on the always-present, stable user id — email is optional on AuthUser, so
|
|
33
|
-
// keying on it would silently drop emailless users into the anonymous branch and
|
|
34
|
-
// lose per-user rollout stickiness. (LaunchDarkly also favors a non-PII key.)
|
|
35
|
-
// Email is kept as a targeting attribute.
|
|
36
|
-
const context = user
|
|
37
|
-
? { kind: "user" as const, key: user.id, email: user.email }
|
|
38
|
-
: { kind: "user" as const, key: "anonymous", anonymous: true };
|
|
33
|
+
const context = buildFlagContext(user);
|
|
39
34
|
|
|
40
35
|
return (
|
|
41
36
|
<LDProvider
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { P1_ASSETS } from "../constants/assets";
|
|
4
|
+
import styles from "./welcome-block.module.css";
|
|
4
5
|
|
|
5
6
|
export function P1Lockup() {
|
|
6
7
|
return (
|
|
7
8
|
<img
|
|
8
9
|
src={P1_ASSETS.LOGO_URL}
|
|
9
10
|
alt="Pantheon P1"
|
|
10
|
-
className=
|
|
11
|
+
className={styles.lockup}
|
|
11
12
|
/>
|
|
12
13
|
);
|
|
13
14
|
}
|
|
@@ -16,8 +16,11 @@ export const imageBlock = {
|
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
defaultProps: {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Placeholder art inline as a data URI, so a fresh block loads nothing off
|
|
20
|
+
// the network. Authors replace it with their own image.
|
|
21
|
+
src:
|
|
22
|
+
"data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%221200%22%20height%3D%22675%22%20viewBox%3D%220%200%201200%20675%22%3E%3Crect%20width%3D%221200%22%20height%3D%22675%22%20fill%3D%22%23f1f1f3%22%2F%3E%3Cpath%20d%3D%22M0%20675L1200%200%22%20stroke%3D%22%23c8c8ce%22%20stroke-width%3D%2284%22%2F%3E%3C%2Fsvg%3E",
|
|
23
|
+
alt: "",
|
|
21
24
|
caption: "",
|
|
22
25
|
loading: "lazy",
|
|
23
26
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { P1Lockup } from "../p1-lockup";
|
|
5
|
+
import styles from "../welcome-block.module.css";
|
|
5
6
|
|
|
6
7
|
export interface WelcomeBlockRenderProps {
|
|
7
8
|
heading?: string;
|
|
@@ -30,12 +31,6 @@ const LOGGED_IN_DEFAULTS = {
|
|
|
30
31
|
"Visit [P1 documentation](https://docs.pantheon.io) for more information.",
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
const BTN_PRIMARY =
|
|
34
|
-
"inline-flex items-center justify-center h-12 px-6 gap-2 rounded-full border border-[#1a1a2e] bg-[#1a1a2e] text-white font-['Inter',system-ui,sans-serif] text-lg font-medium leading-none whitespace-nowrap cursor-pointer transition-colors duration-200 hover:bg-[#2d2d44] hover:border-[#2d2d44] focus-visible:outline focus-visible:outline-1 focus-visible:outline-blue-600 focus-visible:outline-offset-1 disabled:opacity-40 disabled:cursor-not-allowed";
|
|
35
|
-
|
|
36
|
-
const BTN_SECONDARY =
|
|
37
|
-
"inline-flex items-center justify-center h-12 px-6 gap-2 rounded-full border border-[#d0d0d8] bg-transparent text-[#1a1a2e] font-['Inter',system-ui,sans-serif] text-lg font-medium leading-none whitespace-nowrap cursor-pointer transition-colors duration-200 hover:bg-[rgba(26,26,46,0.06)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-blue-600 focus-visible:outline-offset-1";
|
|
38
|
-
|
|
39
34
|
export function WelcomeBlockRender(props: WelcomeBlockRenderProps) {
|
|
40
35
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
41
36
|
|
|
@@ -64,47 +59,45 @@ export function WelcomeBlockRender(props: WelcomeBlockRenderProps) {
|
|
|
64
59
|
|
|
65
60
|
const footnoteHtml = (activeFootnote ?? "").replace(
|
|
66
61
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
67
|
-
|
|
62
|
+
`<a href="$2" target="_blank" rel="noopener noreferrer" class="${styles.link}">$1</a>`,
|
|
68
63
|
);
|
|
69
64
|
|
|
70
65
|
return (
|
|
71
|
-
<div className=
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
{activeHeading}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{activeDescription}
|
|
78
|
-
</p>
|
|
79
|
-
<div className="flex gap-3 mt-8 justify-center">
|
|
80
|
-
<button
|
|
81
|
-
className={BTN_PRIMARY}
|
|
82
|
-
onClick={() => {
|
|
83
|
-
if (!isLoggedIn) {
|
|
84
|
-
localStorage.setItem("p1_return_to", window.location.pathname);
|
|
85
|
-
}
|
|
86
|
-
window.location.href = activeCtaHref || "/";
|
|
87
|
-
}}
|
|
88
|
-
>
|
|
89
|
-
{activeCtaLabel}
|
|
90
|
-
</button>
|
|
91
|
-
{secondaryLabel && (
|
|
66
|
+
<div className={styles.surface}>
|
|
67
|
+
<div className={styles.inner}>
|
|
68
|
+
{props.showLogo !== false && <P1Lockup />}
|
|
69
|
+
<h1 className={styles.heading}>{activeHeading}</h1>
|
|
70
|
+
<p className={styles.description}>{activeDescription}</p>
|
|
71
|
+
<div className={styles.actions}>
|
|
92
72
|
<button
|
|
93
|
-
className={
|
|
73
|
+
className={styles.button}
|
|
94
74
|
onClick={() => {
|
|
95
|
-
|
|
75
|
+
if (!isLoggedIn) {
|
|
76
|
+
localStorage.setItem("p1_return_to", window.location.pathname);
|
|
77
|
+
}
|
|
78
|
+
window.location.href = activeCtaHref || "/";
|
|
96
79
|
}}
|
|
97
80
|
>
|
|
98
|
-
{
|
|
81
|
+
{activeCtaLabel}
|
|
99
82
|
</button>
|
|
83
|
+
{secondaryLabel && (
|
|
84
|
+
<button
|
|
85
|
+
className={`${styles.button} ${styles.buttonSecondary}`}
|
|
86
|
+
onClick={() => {
|
|
87
|
+
window.open(LOGGED_IN_DEFAULTS.secondaryHref, "_blank", "noopener,noreferrer");
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{secondaryLabel}
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
{activeFootnote && (
|
|
95
|
+
<p
|
|
96
|
+
className={styles.footnote}
|
|
97
|
+
dangerouslySetInnerHTML={{ __html: footnoteHtml }}
|
|
98
|
+
/>
|
|
100
99
|
)}
|
|
101
100
|
</div>
|
|
102
|
-
{activeFootnote && (
|
|
103
|
-
<p
|
|
104
|
-
className="mt-12 text-sm text-[#5a5a6e]"
|
|
105
|
-
dangerouslySetInnerHTML={{ __html: footnoteHtml }}
|
|
106
|
-
/>
|
|
107
|
-
)}
|
|
108
101
|
</div>
|
|
109
102
|
);
|
|
110
103
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Styling for the welcome block and the sign-in screen that shares its layout.
|
|
2
|
+
Both render inside a host site whose own body styles they must not inherit —
|
|
3
|
+
a dark-themed host would otherwise leave this dark text on a dark background —
|
|
4
|
+
so the surface sets its own background as well as its foreground. */
|
|
5
|
+
|
|
6
|
+
.surface {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
width: 100%;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
padding: 4rem 2rem;
|
|
14
|
+
background: #ffffff;
|
|
15
|
+
color: #1a1a2e;
|
|
16
|
+
font-family: "Inter", system-ui, sans-serif;
|
|
17
|
+
text-align: center;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.inner {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
align-items: center;
|
|
24
|
+
width: 100%;
|
|
25
|
+
max-width: 620px;
|
|
26
|
+
margin: 0 auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.lockup {
|
|
30
|
+
display: block;
|
|
31
|
+
height: 1.75rem;
|
|
32
|
+
width: auto;
|
|
33
|
+
margin-bottom: 1.5rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.heading {
|
|
37
|
+
margin: 0 0 1rem;
|
|
38
|
+
font-size: 2.5rem;
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
line-height: 1.08;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.description {
|
|
44
|
+
max-width: 54ch;
|
|
45
|
+
margin: 0;
|
|
46
|
+
font-size: 1rem;
|
|
47
|
+
line-height: 1.5rem;
|
|
48
|
+
color: #5a5a6e;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.actions {
|
|
52
|
+
display: flex;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
gap: 0.75rem;
|
|
55
|
+
margin-top: 2rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.button {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
gap: 0.5rem;
|
|
63
|
+
height: 3rem;
|
|
64
|
+
padding: 0 1.5rem;
|
|
65
|
+
border: 1px solid #1a1a2e;
|
|
66
|
+
border-radius: 9999px;
|
|
67
|
+
background: #1a1a2e;
|
|
68
|
+
color: #ffffff;
|
|
69
|
+
font-family: "Inter", system-ui, sans-serif;
|
|
70
|
+
font-size: 1.125rem;
|
|
71
|
+
font-weight: 500;
|
|
72
|
+
line-height: 1;
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
transition:
|
|
76
|
+
background-color 0.2s,
|
|
77
|
+
border-color 0.2s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.button:hover {
|
|
81
|
+
background: #2d2d44;
|
|
82
|
+
border-color: #2d2d44;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.button:focus-visible {
|
|
86
|
+
outline: 1px solid #2563eb;
|
|
87
|
+
outline-offset: 1px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.button:disabled {
|
|
91
|
+
opacity: 0.4;
|
|
92
|
+
cursor: not-allowed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.buttonSecondary {
|
|
96
|
+
border-color: #d0d0d8;
|
|
97
|
+
background: #ffffff;
|
|
98
|
+
color: #1a1a2e;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.buttonSecondary:hover {
|
|
102
|
+
background: rgba(26, 26, 46, 0.06);
|
|
103
|
+
border-color: #d0d0d8;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.footnote {
|
|
107
|
+
margin-top: 3rem;
|
|
108
|
+
font-size: 0.875rem;
|
|
109
|
+
color: #5a5a6e;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.link {
|
|
113
|
+
color: #2563eb;
|
|
114
|
+
text-decoration: underline;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.error {
|
|
118
|
+
margin-top: 1rem;
|
|
119
|
+
padding: 0.5rem 0.75rem;
|
|
120
|
+
border-radius: 0.375rem;
|
|
121
|
+
background: #fef2f2;
|
|
122
|
+
color: #dc2626;
|
|
123
|
+
font-size: 0.875rem;
|
|
124
|
+
}
|
|
@@ -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,
|
|
@@ -14,6 +38,9 @@ export default tseslint.config(
|
|
|
14
38
|
{
|
|
15
39
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs'],
|
|
16
40
|
plugins: { import: importPlugin },
|
|
41
|
+
// Packages alias their own source as @/… in tsconfig paths. Without this the
|
|
42
|
+
// import plugin reads those as third-party and sorts them after relative ones.
|
|
43
|
+
settings: { 'import/internal-regex': '^@/' },
|
|
17
44
|
languageOptions: {
|
|
18
45
|
ecmaVersion: 'latest',
|
|
19
46
|
sourceType: 'module',
|
|
@@ -42,12 +69,19 @@ export default tseslint.config(
|
|
|
42
69
|
'@typescript-eslint/consistent-type-imports': 'warn',
|
|
43
70
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
44
71
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
72
|
+
|
|
45
73
|
// Import rules
|
|
46
74
|
'import/extensions': 'off',
|
|
47
75
|
'import/prefer-default-export': 'off',
|
|
48
76
|
'import/no-unresolved': 'off',
|
|
49
|
-
'
|
|
77
|
+
// 'internal' is absent from the rule's default group list, so aliased
|
|
78
|
+
// imports would otherwise rank below every relative import.
|
|
79
|
+
'import/order': [
|
|
80
|
+
'warn',
|
|
81
|
+
{ groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'] },
|
|
82
|
+
],
|
|
50
83
|
'import/no-extraneous-dependencies': 'warn',
|
|
84
|
+
|
|
51
85
|
// General ESLint rules
|
|
52
86
|
'no-console': 'off',
|
|
53
87
|
'no-debugger': 'error',
|
|
@@ -75,6 +109,7 @@ export default tseslint.config(
|
|
|
75
109
|
],
|
|
76
110
|
},
|
|
77
111
|
],
|
|
112
|
+
|
|
78
113
|
// Stricter rules (warn to resolve over time)
|
|
79
114
|
'@typescript-eslint/consistent-generic-constructors': 'warn',
|
|
80
115
|
'@typescript-eslint/consistent-indexed-object-style': 'warn',
|
|
@@ -146,6 +181,7 @@ export default tseslint.config(
|
|
|
146
181
|
'**/.puppeteerrc.cjs',
|
|
147
182
|
],
|
|
148
183
|
},
|
|
184
|
+
// @pantheon-systems/eslint-config/react
|
|
149
185
|
{
|
|
150
186
|
files: ['**/*.{ts,tsx,jsx}'],
|
|
151
187
|
plugins: {
|
|
@@ -173,5 +209,40 @@ export default tseslint.config(
|
|
|
173
209
|
'react-hooks/exhaustive-deps': 'warn',
|
|
174
210
|
},
|
|
175
211
|
},
|
|
212
|
+
// @pantheon-systems/eslint-config/prettier
|
|
176
213
|
prettierConfig,
|
|
214
|
+
// @pantheon-systems/eslint-config/tests
|
|
215
|
+
{
|
|
216
|
+
files: TEST_FILES,
|
|
217
|
+
rules: {
|
|
218
|
+
// `x!` after a known-good arrange step asserts the fixture, it doesn't hide a bug.
|
|
219
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
220
|
+
// `() => {}` is the entire point of a stub.
|
|
221
|
+
'@typescript-eslint/no-empty-function': 'off',
|
|
222
|
+
// Passing an unbound method to vi.spyOn / expect is the documented API.
|
|
223
|
+
'@typescript-eslint/unbound-method': 'off',
|
|
224
|
+
// Test helpers are read at the call site; annotating their returns is noise.
|
|
225
|
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
226
|
+
// `async` with no await is how you satisfy an interface in a fake.
|
|
227
|
+
'@typescript-eslint/require-await': 'off',
|
|
228
|
+
// Mock payloads are structurally untyped by nature.
|
|
229
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
230
|
+
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
231
|
+
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
232
|
+
'@typescript-eslint/no-unsafe-call': 'off',
|
|
233
|
+
'@typescript-eslint/no-unsafe-argument': 'off',
|
|
234
|
+
'@typescript-eslint/no-unsafe-return': 'off',
|
|
235
|
+
'@typescript-eslint/no-useless-constructor': 'off',
|
|
236
|
+
// Stand-ins for `cloudflare:*` built-ins have to be classes to stand in
|
|
237
|
+
// for classes, even with nothing in them.
|
|
238
|
+
'@typescript-eslint/no-extraneous-class': 'off',
|
|
239
|
+
// `vi.importActual<typeof import('mod')>('mod')` is vitest's documented
|
|
240
|
+
// shape and cannot be hoisted to a top-level type import.
|
|
241
|
+
'@typescript-eslint/consistent-type-imports': ['warn', { disallowTypeAnnotations: false }],
|
|
242
|
+
// Inline fixtures and assertion chains run long. The formatter will own
|
|
243
|
+
// line length once it lands; until then this is the only rule that would
|
|
244
|
+
// force hand-wrapping code a formatter is about to rewrite.
|
|
245
|
+
'max-len': 'off',
|
|
246
|
+
},
|
|
247
|
+
},
|
|
177
248
|
);
|
|
@@ -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,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AuthUser } from "@pantheon-systems/puck-css";
|
|
2
|
+
|
|
3
|
+
export interface FlagContext {
|
|
4
|
+
kind: "user";
|
|
5
|
+
key: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
anonymous?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the LaunchDarkly evaluation context for the signed-in user.
|
|
12
|
+
*
|
|
13
|
+
* Keyed on the lowercased email because LaunchDarkly matches individual targets on
|
|
14
|
+
* the key alone, and the rest of P1 identifies people to LaunchDarkly the same way.
|
|
15
|
+
* Keying on anything else makes an email entered in the targeting UI match nothing,
|
|
16
|
+
* with no error to say so.
|
|
17
|
+
*
|
|
18
|
+
* Falls back to the user id when email is absent, which AuthUser allows, so those
|
|
19
|
+
* users stay individually targetable instead of sharing the anonymous context.
|
|
20
|
+
*/
|
|
21
|
+
export function buildFlagContext(user: AuthUser | null): FlagContext {
|
|
22
|
+
if (!user) {
|
|
23
|
+
return { kind: "user", key: "anonymous", anonymous: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const email = user.email?.trim();
|
|
27
|
+
return {
|
|
28
|
+
kind: "user",
|
|
29
|
+
key: email ? email.toLowerCase() : user.id,
|
|
30
|
+
...(email && { email }),
|
|
31
|
+
};
|
|
32
|
+
}
|