@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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/lib/cli.js +122 -53
  3. package/lib/cli.test.js +55 -0
  4. package/lib/copy-template.js +37 -0
  5. package/lib/copy-template.test.js +42 -1
  6. package/lib/messages.js +1 -1
  7. package/package.json +7 -4
  8. package/template/README.md +82 -0
  9. package/template/__tests__/auth-route.test.ts +1 -1
  10. package/template/__tests__/chatbot-flag-context.test.ts +55 -0
  11. package/template/__tests__/data-list-block-sub-components.test.tsx +246 -0
  12. package/template/__tests__/data-list-block-utils.test.ts +84 -0
  13. package/template/__tests__/data-list-block.test.ts +198 -0
  14. package/template/__tests__/published-page-routes.test.ts +86 -0
  15. package/template/__tests__/seo-metadata.test.ts +1 -1
  16. package/template/__tests__/styles-canvas-scope.test.ts +1 -1
  17. package/template/__tests__/widget-logout.test.ts +114 -0
  18. package/template/app/[...puckPath]/client.tsx +38 -10
  19. package/template/app/[...puckPath]/page.tsx +10 -117
  20. package/template/app/[...puckPath]/widget-logout.ts +36 -0
  21. package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +33 -140
  22. package/template/app/page.tsx +11 -69
  23. package/template/app/published-pages.tsx +23 -0
  24. package/template/app/styles.css +1 -1
  25. package/template/app/welcome-block.tsx +25 -0
  26. package/template/ci-examples/github-actions-sync-puck-registry.yml +15 -7
  27. package/template/components/ChatbotFlagProvider.tsx +3 -8
  28. package/template/components/p1-lockup.tsx +2 -1
  29. package/template/components/puck/image-block.tsx +5 -2
  30. package/template/components/puck/welcome-block-render.tsx +30 -37
  31. package/template/components/welcome-block.module.css +124 -0
  32. package/template/eslint.config.js +72 -1
  33. package/template/gitignore +43 -0
  34. package/template/lib/chatbot-flag/ai-generate.ts +1 -1
  35. package/template/lib/chatbot-flag/flag-context.ts +32 -0
  36. package/template/lib/page-seo.ts +1 -1
  37. package/template/package.json +5 -5
  38. package/template/scripts/__tests__/sync-puck-registry.test.ts +3 -3
  39. package/template/scripts/sync-puck-registry.ts +18 -18
  40. package/template/vitest.config.ts +17 -20
  41. package/template/CHANGELOG.md +0 -76
  42. package/template/__tests__/published-page-404.test.ts +0 -65
@@ -1,77 +1,19 @@
1
- import {
2
- resolveDataTemplates,
3
- extractReferencedDatasourceIds,
4
- loadRemoteDatasourceContext,
5
- } from "@pantheon-systems/puck-css/server";
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 async function generateMetadata(): Promise<Metadata> {
24
- const result = await loadPublishedPage("/");
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
+ });
@@ -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 (PCC-3499 / PCC-3513).
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 CSS backend
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 CSS branch from the pushed
16
- # git branch's name: the repo's default branch always targets the site's main
17
- # CSS branch (whatever the git branch is called), any other ref matches a CSS
18
- # branch by name. A push on a non-default branch with no matching CSS branch
19
- # is not an error: the script logs a skip and exits 0.
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: 'CSS branch ID/name to sync against (blank = match the current git branch, else site main)'
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="h-7 w-auto block mb-6"
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
- src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80",
20
- alt: "Mountain landscape",
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
- '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline">$1</a>',
62
+ `<a href="$2" target="_blank" rel="noopener noreferrer" class="${styles.link}">$1</a>`,
68
63
  );
69
64
 
70
65
  return (
71
- <div className="w-full max-w-[620px] mx-auto flex flex-col items-center text-center px-8 py-16 font-['Inter',system-ui,sans-serif] text-[#1a1a2e] min-h-screen justify-center">
72
- {props.showLogo !== false && <P1Lockup />}
73
- <h1 className="text-[2.5rem] leading-[1.08] font-semibold m-0 mb-4">
74
- {activeHeading}
75
- </h1>
76
- <p className="text-base leading-6 text-[#5a5a6e] max-w-[54ch] m-0">
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={BTN_SECONDARY}
73
+ className={styles.button}
94
74
  onClick={() => {
95
- window.open(LOGGED_IN_DEFAULTS.secondaryHref, "_blank", "noopener,noreferrer");
75
+ if (!isLoggedIn) {
76
+ localStorage.setItem("p1_return_to", window.location.pathname);
77
+ }
78
+ window.location.href = activeCtaHref || "/";
96
79
  }}
97
80
  >
98
- {secondaryLabel}
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
- 'import/order': 'warn',
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 (PCC-3555). Until then the request carries the title and path the dialog collected.
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
+ }