@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
package/README.md
CHANGED
|
@@ -45,12 +45,12 @@ After scaffolding:
|
|
|
45
45
|
|
|
46
46
|
| Variable | Purpose |
|
|
47
47
|
| --- | --- |
|
|
48
|
-
| `PCC_SITE_ID` | Content Cloud site ID |
|
|
49
|
-
| `PCC_TOKEN` | API token for your site |
|
|
50
|
-
| `NEXT_PUBLIC_CSS_BASE_URL` | CSS API base URL |
|
|
51
48
|
| `NEXT_PUBLIC_CSS_SITE_ID` | Site identifier (UUID) |
|
|
52
49
|
| `CSS_API_KEY` | Server-side API key |
|
|
53
50
|
|
|
51
|
+
Everything else in `.env.example` is commented out and optional, including the
|
|
52
|
+
`PCC_SITE_ID`/`PCC_TOKEN` pair for Content Publisher.
|
|
53
|
+
|
|
54
54
|
2. **Start the dev server:**
|
|
55
55
|
|
|
56
56
|
```bash
|
package/lib/cli.js
CHANGED
|
@@ -7,31 +7,80 @@ import { copyTemplate } from './copy-template.js';
|
|
|
7
7
|
import { detectPackageManager, isPackageManagerAvailable, installDependencies } from './install-deps.js';
|
|
8
8
|
import { showWelcome, showSuccess, showInstallHelp, showError } from './messages.js';
|
|
9
9
|
|
|
10
|
+
const PROJECT_NAME_RULE =
|
|
11
|
+
'Project name must be lowercase and can only contain letters, numbers, hyphens, and underscores';
|
|
12
|
+
|
|
13
|
+
function validateProjectName(value) {
|
|
14
|
+
if (!value) return 'Please enter a project name';
|
|
15
|
+
if (!/^[a-z0-9-_]+$/.test(value)) return PROJECT_NAME_RULE;
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PACKAGE_MANAGERS = new Set(['pnpm', 'npm', 'yarn']);
|
|
20
|
+
|
|
21
|
+
export function parseArgs(args) {
|
|
22
|
+
const parsed = { projectName: undefined, yes: false, pm: undefined, git: undefined, install: undefined };
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const arg = args[i];
|
|
26
|
+
if (arg === '--yes' || arg === '-y') {
|
|
27
|
+
parsed.yes = true;
|
|
28
|
+
} else if (arg === '--pm' || arg.startsWith('--pm=')) {
|
|
29
|
+
const value = arg === '--pm' ? args[++i] : arg.slice('--pm='.length);
|
|
30
|
+
if (!PACKAGE_MANAGERS.has(value)) {
|
|
31
|
+
throw new Error(`--pm must be one of: ${[...PACKAGE_MANAGERS].join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
parsed.pm = value;
|
|
34
|
+
} else if (arg === '--git' || arg === '--no-git') {
|
|
35
|
+
parsed.git = arg === '--git';
|
|
36
|
+
} else if (arg === '--install' || arg === '--no-install') {
|
|
37
|
+
parsed.install = arg === '--install';
|
|
38
|
+
} else if (arg.startsWith('-')) {
|
|
39
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
40
|
+
} else if (parsed.projectName === undefined) {
|
|
41
|
+
parsed.projectName = arg;
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
10
50
|
export async function runCLI() {
|
|
11
51
|
showWelcome();
|
|
12
52
|
|
|
13
|
-
|
|
14
|
-
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = parseArgs(process.argv.slice(2));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
showError(error.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
15
60
|
|
|
16
61
|
clack.intro(pc.bgCyan(pc.black(' P1 Starter Kit Setup ')));
|
|
17
62
|
|
|
18
63
|
// Get project name
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
64
|
+
let projectName;
|
|
65
|
+
if (parsed.yes) {
|
|
66
|
+
projectName = parsed.projectName || 'my-p1-app';
|
|
67
|
+
const problem = validateProjectName(projectName);
|
|
68
|
+
if (problem) {
|
|
69
|
+
showError(problem);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
projectName = await clack.text({
|
|
74
|
+
message: 'What is your project named?',
|
|
75
|
+
placeholder: 'my-p1-app',
|
|
76
|
+
initialValue: parsed.projectName || 'my-p1-app',
|
|
77
|
+
validate: validateProjectName,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (clack.isCancel(projectName)) {
|
|
81
|
+
clack.cancel('Operation cancelled');
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
35
84
|
}
|
|
36
85
|
|
|
37
86
|
const targetDir = path.resolve(process.cwd(), projectName);
|
|
@@ -44,47 +93,67 @@ export async function runCLI() {
|
|
|
44
93
|
|
|
45
94
|
// Detect package manager
|
|
46
95
|
const detectedPM = detectPackageManager();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
let packageManager;
|
|
97
|
+
if (parsed.pm) {
|
|
98
|
+
packageManager = parsed.pm;
|
|
99
|
+
} else if (parsed.yes) {
|
|
100
|
+
packageManager = detectedPM;
|
|
101
|
+
} else {
|
|
102
|
+
packageManager = await clack.select({
|
|
103
|
+
message: 'Which package manager do you want to use?',
|
|
104
|
+
options: [
|
|
105
|
+
{ value: 'pnpm', label: 'pnpm', hint: detectedPM === 'pnpm' ? 'detected' : '' },
|
|
106
|
+
{ value: 'npm', label: 'npm', hint: detectedPM === 'npm' ? 'detected' : '' },
|
|
107
|
+
{ value: 'yarn', label: 'yarn', hint: detectedPM === 'yarn' ? 'detected' : '' },
|
|
108
|
+
],
|
|
109
|
+
initialValue: detectedPM,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (clack.isCancel(packageManager)) {
|
|
113
|
+
clack.cancel('Operation cancelled');
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
66
116
|
}
|
|
67
117
|
|
|
68
118
|
// Git init?
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
clack.
|
|
76
|
-
|
|
119
|
+
let shouldInitGit;
|
|
120
|
+
if (parsed.git !== undefined) {
|
|
121
|
+
shouldInitGit = parsed.git;
|
|
122
|
+
} else if (parsed.yes) {
|
|
123
|
+
shouldInitGit = true;
|
|
124
|
+
} else {
|
|
125
|
+
shouldInitGit = await clack.confirm({
|
|
126
|
+
message: 'Initialize a git repository?',
|
|
127
|
+
initialValue: true,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (clack.isCancel(shouldInitGit)) {
|
|
131
|
+
clack.cancel('Operation cancelled');
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
77
134
|
}
|
|
78
135
|
|
|
79
136
|
// Install deps?
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
clack.
|
|
87
|
-
|
|
137
|
+
let shouldInstall;
|
|
138
|
+
if (parsed.install !== undefined) {
|
|
139
|
+
shouldInstall = parsed.install;
|
|
140
|
+
} else if (parsed.yes) {
|
|
141
|
+
shouldInstall = true;
|
|
142
|
+
} else {
|
|
143
|
+
shouldInstall = await clack.confirm({
|
|
144
|
+
message: 'Install dependencies now?',
|
|
145
|
+
initialValue: true,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (clack.isCancel(shouldInstall)) {
|
|
149
|
+
clack.cancel('Operation cancelled');
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (shouldInstall && !isPackageManagerAvailable(packageManager)) {
|
|
155
|
+
showError(`${packageManager} is not installed. Please install it first or choose a different package manager.`);
|
|
156
|
+
process.exit(1);
|
|
88
157
|
}
|
|
89
158
|
|
|
90
159
|
const s = clack.spinner();
|
package/lib/cli.test.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseArgs } from './cli.js';
|
|
3
|
+
|
|
4
|
+
describe('parseArgs', () => {
|
|
5
|
+
it('treats the first bare argument as the project name', () => {
|
|
6
|
+
expect(parseArgs(['my-app'])).toMatchObject({ projectName: 'my-app', yes: false });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('defaults every choice to undefined so prompts still run', () => {
|
|
10
|
+
expect(parseArgs([])).toEqual({
|
|
11
|
+
projectName: undefined,
|
|
12
|
+
yes: false,
|
|
13
|
+
pm: undefined,
|
|
14
|
+
git: undefined,
|
|
15
|
+
install: undefined,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('accepts --yes and -y', () => {
|
|
20
|
+
expect(parseArgs(['--yes']).yes).toBe(true);
|
|
21
|
+
expect(parseArgs(['-y']).yes).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('accepts --pm as a separate value or with =', () => {
|
|
25
|
+
expect(parseArgs(['--pm', 'pnpm']).pm).toBe('pnpm');
|
|
26
|
+
expect(parseArgs(['--pm=npm']).pm).toBe('npm');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects an unknown package manager', () => {
|
|
30
|
+
expect(() => parseArgs(['--pm', 'bun'])).toThrow(/--pm must be one of/);
|
|
31
|
+
expect(() => parseArgs(['--pm'])).toThrow(/--pm must be one of/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('parses git and install toggles', () => {
|
|
35
|
+
expect(parseArgs(['--git']).git).toBe(true);
|
|
36
|
+
expect(parseArgs(['--no-git']).git).toBe(false);
|
|
37
|
+
expect(parseArgs(['--install']).install).toBe(true);
|
|
38
|
+
expect(parseArgs(['--no-install']).install).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects unknown options and extra positionals', () => {
|
|
42
|
+
expect(() => parseArgs(['--frobnicate'])).toThrow(/Unknown option/);
|
|
43
|
+
expect(() => parseArgs(['app-one', 'app-two'])).toThrow(/Unexpected argument/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('parses a full non-interactive invocation', () => {
|
|
47
|
+
expect(parseArgs(['my-app', '--yes', '--pm', 'pnpm', '--no-git', '--no-install'])).toEqual({
|
|
48
|
+
projectName: 'my-app',
|
|
49
|
+
yes: true,
|
|
50
|
+
pm: 'pnpm',
|
|
51
|
+
git: false,
|
|
52
|
+
install: false,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
package/lib/copy-template.js
CHANGED
|
@@ -14,6 +14,40 @@ export function getScaffolderVersion() {
|
|
|
14
14
|
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// npm strips files named `.gitignore` from published tarballs, so the template
|
|
18
|
+
// ships it undotted (see scripts/build-template.js) and the name is restored here
|
|
19
|
+
// — before the CLI's initial `git add -A`.
|
|
20
|
+
const GITIGNORE_TEMPLATE_NAME = 'gitignore';
|
|
21
|
+
const PROJECT_NAME_PLACEHOLDER = 'PLACEHOLDER_PROJECT_NAME';
|
|
22
|
+
|
|
23
|
+
// Acted on directly rather than guarded by an existsSync: a check-then-use pair
|
|
24
|
+
// is a file-system race, and the failure it would catch is exactly what the
|
|
25
|
+
// rename already reports.
|
|
26
|
+
export function restoreGitignore(targetDir) {
|
|
27
|
+
try {
|
|
28
|
+
fs.renameSync(
|
|
29
|
+
path.join(targetDir, GITIGNORE_TEMPLATE_NAME),
|
|
30
|
+
path.join(targetDir, '.gitignore')
|
|
31
|
+
);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code !== 'ENOENT') throw error;
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Template is missing ${GITIGNORE_TEMPLATE_NAME}; scaffolding without it would commit node_modules.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stampProjectName(filePath, projectName) {
|
|
41
|
+
let contents;
|
|
42
|
+
try {
|
|
43
|
+
contents = fs.readFileSync(filePath, 'utf-8');
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== 'ENOENT') throw error;
|
|
46
|
+
throw new Error(`Template is missing ${path.basename(filePath)}.`);
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(filePath, contents.replaceAll(PROJECT_NAME_PLACEHOLDER, projectName));
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
export function copyTemplate(targetDir, projectName) {
|
|
18
52
|
const templatePath = getTemplatePath();
|
|
19
53
|
|
|
@@ -22,6 +56,9 @@ export function copyTemplate(targetDir, projectName) {
|
|
|
22
56
|
}
|
|
23
57
|
|
|
24
58
|
copyRecursive(templatePath, targetDir);
|
|
59
|
+
restoreGitignore(targetDir);
|
|
60
|
+
|
|
61
|
+
stampProjectName(path.join(targetDir, 'README.md'), projectName);
|
|
25
62
|
|
|
26
63
|
// Stamp the project name and scaffolder version into package.json
|
|
27
64
|
const packageJsonPath = path.join(targetDir, 'package.json');
|
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
5
|
-
import { copyTemplate, getScaffolderVersion } from './copy-template.js';
|
|
5
|
+
import { copyTemplate, getScaffolderVersion, restoreGitignore } from './copy-template.js';
|
|
6
6
|
|
|
7
7
|
describe('copyTemplate', () => {
|
|
8
8
|
let targetDir;
|
|
@@ -32,3 +32,44 @@ describe('getScaffolderVersion', () => {
|
|
|
32
32
|
expect(getScaffolderVersion()).toBe(manifest.version);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
describe('gitignore', () => {
|
|
37
|
+
let targetDir;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
targetDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'scaffold-')), 'my-app');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
fs.rmSync(path.dirname(targetDir), { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('restores the dotted name so the initial commit excludes node_modules', () => {
|
|
48
|
+
copyTemplate(targetDir, 'my-app');
|
|
49
|
+
|
|
50
|
+
expect(fs.existsSync(path.join(targetDir, 'gitignore'))).toBe(false);
|
|
51
|
+
expect(fs.readFileSync(path.join(targetDir, '.gitignore'), 'utf-8')).toContain('/node_modules');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('refuses to scaffold from a template with no gitignore', () => {
|
|
55
|
+
const stripped = fs.mkdtempSync(path.join(os.tmpdir(), 'no-gitignore-'));
|
|
56
|
+
|
|
57
|
+
expect(() => restoreGitignore(stripped)).toThrow(/missing gitignore/);
|
|
58
|
+
|
|
59
|
+
fs.rmSync(stripped, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('README', () => {
|
|
64
|
+
it('stamps the project name into the scaffolded README', () => {
|
|
65
|
+
const targetDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'scaffold-')), 'acme-site');
|
|
66
|
+
|
|
67
|
+
copyTemplate(targetDir, 'acme-site');
|
|
68
|
+
const readme = fs.readFileSync(path.join(targetDir, 'README.md'), 'utf-8');
|
|
69
|
+
|
|
70
|
+
expect(readme).toContain('# acme-site');
|
|
71
|
+
expect(readme).not.toContain('PLACEHOLDER_PROJECT_NAME');
|
|
72
|
+
|
|
73
|
+
fs.rmSync(path.dirname(targetDir), { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
});
|
package/lib/messages.js
CHANGED
|
@@ -12,7 +12,7 @@ export function showSuccess(projectName, _projectPath, packageManager) {
|
|
|
12
12
|
console.log(` ${pc.cyan('cd')} ${projectName}`);
|
|
13
13
|
console.log(` ${pc.dim('# Copy .env.example to .env and fill in your credentials:')}`);
|
|
14
14
|
console.log(` ${pc.cyan('cp')} .env.example .env`);
|
|
15
|
-
console.log(` ${pc.dim('# Edit .env with your
|
|
15
|
+
console.log(` ${pc.dim('# Edit .env with your NEXT_PUBLIC_CSS_SITE_ID and CSS_API_KEY')}\n`);
|
|
16
16
|
const devCmd = packageManager === 'npm' ? 'npm run dev' : `${packageManager} dev`;
|
|
17
17
|
console.log(` ${pc.dim('# Start the dev server:')}`);
|
|
18
18
|
console.log(` ${pc.cyan(devCmd)}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pantheon-systems/create-p1-starter-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Scaffold a new P1 starter project",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"isomorphic-dompurify": "^3.18.0",
|
|
39
39
|
"react": "^19.2.5",
|
|
40
40
|
"vitest": "^4.1.5",
|
|
41
|
-
"@pantheon-systems/css-client": "0.
|
|
41
|
+
"@pantheon-systems/css-client": "0.13.0",
|
|
42
42
|
"@pantheon-systems/eslint-config": "0.1.0",
|
|
43
|
-
"@pantheon-systems/puck-css": "0.
|
|
43
|
+
"@pantheon-systems/puck-css": "0.13.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@clack/prompts": "^1.5.1",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"clean": "rm -rf template node_modules/.vite",
|
|
54
54
|
"lint": "eslint index.js lib scripts",
|
|
55
55
|
"lint:fix": "eslint index.js lib scripts --fix",
|
|
56
|
-
"test": "vitest run"
|
|
56
|
+
"test": "vitest run",
|
|
57
|
+
"test:scaffold": "node scripts/validate-scaffold.js"
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# PLACEHOLDER_PROJECT_NAME
|
|
2
|
+
|
|
3
|
+
A Next.js site built on Pantheon's Content Publisher — a Puck-based visual page editor with
|
|
4
|
+
server-side datasource resolution, so authors build dynamic pages without writing code.
|
|
5
|
+
|
|
6
|
+
Scaffolded with [`@pantheon-systems/create-p1-starter-kit`](https://www.npmjs.com/package/@pantheon-systems/create-p1-starter-kit).
|
|
7
|
+
|
|
8
|
+
## Getting started
|
|
9
|
+
|
|
10
|
+
1. Copy the environment file and fill in your credentials:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cp .env.example .env
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Two variables are required:
|
|
17
|
+
|
|
18
|
+
| Variable | Description |
|
|
19
|
+
| ------------------------- | -------------------------------------------------------------------- |
|
|
20
|
+
| `NEXT_PUBLIC_CSS_SITE_ID` | The Pantheon Site ID (Next.js, etc.) for this P1 site |
|
|
21
|
+
| `CSS_API_KEY` | The P1 API Token generated by the P1 platform to access your P1 site |
|
|
22
|
+
|
|
23
|
+
`.env.example` documents the optional ones inline — the backend URL
|
|
24
|
+
(`NEXT_PUBLIC_CSS_BASE_URL`) for staging or local backends.
|
|
25
|
+
|
|
26
|
+
2. Install dependencies and start the dev server:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install
|
|
30
|
+
npm run dev
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
3. Open http://localhost:3000 for the site, or http://localhost:3000/p1 for the page dashboard.
|
|
34
|
+
|
|
35
|
+
Additional step-by-step details are in the [P1 developer guide](https://live-p1-docs.pantheonsite.io/dev-guide/dev-guide-getting-started).
|
|
36
|
+
|
|
37
|
+
## Scripts
|
|
38
|
+
|
|
39
|
+
| Script | What it does |
|
|
40
|
+
| ----------------------- | ----------------------------------------------- |
|
|
41
|
+
| `npm run dev` | Start the Next.js dev server |
|
|
42
|
+
| `npm run build` | Production build |
|
|
43
|
+
| `npm start` | Serve the production build |
|
|
44
|
+
| `npm test` | Run the Vitest suite |
|
|
45
|
+
| `npm run lint` | Lint with the bundled flat ESLint config |
|
|
46
|
+
| `npm run typecheck` | Type-check without emitting |
|
|
47
|
+
| `npm run sync:registry` | Push the Puck component registry to the backend |
|
|
48
|
+
|
|
49
|
+
## Project structure
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
app/
|
|
53
|
+
page.tsx # Site root (renders via Puck)
|
|
54
|
+
layout.tsx # Root layout
|
|
55
|
+
styles.css # Tailwind entry point
|
|
56
|
+
[...puckPath]/ # Catch-all route for published pages
|
|
57
|
+
p1/
|
|
58
|
+
(editor)/[[...p1]]/ # Dashboard at /p1, editor at /p1/<path>
|
|
59
|
+
api/[...p1]/route.ts # API handler for page data
|
|
60
|
+
auth/[...action]/route.ts # Login / logout callbacks
|
|
61
|
+
merge/ # Branch merge review UI
|
|
62
|
+
components/puck/ # Block definitions (Heading, Paragraph, Image, …)
|
|
63
|
+
lib/remote-datasources.ts # Datasource registry (REMOTE_DATASOURCE_REGISTRY)
|
|
64
|
+
lib/remote-datasource-fetchers.ts # Fetchers backing those datasources
|
|
65
|
+
puck.config.tsx # Puck editor configuration — block registry & categories
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Customizing
|
|
69
|
+
|
|
70
|
+
- **Add a block** — create a component in `components/puck/`, then register it in
|
|
71
|
+
`puck.config.tsx`.
|
|
72
|
+
- **Add a datasource** — add an entry to `REMOTE_DATASOURCE_REGISTRY` in
|
|
73
|
+
`lib/remote-datasources.ts`, with its fetcher in `lib/remote-datasource-fetchers.ts`, to make
|
|
74
|
+
external data available to block fields through `{{ datasource.field }}` expressions.
|
|
75
|
+
- **Change styling** — the project uses Tailwind CSS v4; edit `app/styles.css` or the individual
|
|
76
|
+
block components.
|
|
77
|
+
|
|
78
|
+
## Updating
|
|
79
|
+
|
|
80
|
+
The scaffolder records the version it generated from under `p1.templateVersion` in
|
|
81
|
+
`package.json`. Compare it against the latest published `@pantheon-systems/create-p1-starter-kit`
|
|
82
|
+
to see whether newer starter-kit changes are available.
|
|
@@ -14,7 +14,7 @@ describe("auth route does not force re-authentication on every login", () => {
|
|
|
14
14
|
|
|
15
15
|
it("does not hardcode an OAuth prompt override", () => {
|
|
16
16
|
// prompt: 'login' forces Google's full re-auth screen on every broker
|
|
17
|
-
// login, even with a live Google session
|
|
17
|
+
// login, even with a live Google session.
|
|
18
18
|
expect(content).not.toMatch(/prompt\s*:\s*['"]login['"]/);
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { buildPageMetadata } from "../lib/seo-metadata";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* buildPageMetadata maps the head metadata inputs (client-derived
|
|
6
6
|
* title/description/canonical plus the backend-supplied siteName) onto the
|
|
7
7
|
* Next.js Metadata object that renders the per-page <head> tags. Next replaces
|
|
8
8
|
* (not deep-merges) a page's openGraph over the layout's, so og:type and the
|
|
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const appDir = resolve(__dirname, "..");
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// Puck's collectStyles() copies every parent
|
|
10
10
|
// <style>/<link> element into the canvas-preview iframe verbatim (there is
|
|
11
11
|
// no exclusion API), and separately its CopyHostStyles helper syncs this
|
|
12
12
|
// document's <body> attributes (including `class`) onto the iframe's own
|
|
@@ -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
|
+
});
|