primo-cli 0.1.20 → 0.1.23
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/dist/commands/add.d.ts +6 -0
- package/dist/commands/add.js +345 -0
- package/dist/commands/build.js +92 -48
- package/dist/commands/dev.d.ts +24 -0
- package/dist/commands/dev.js +127 -43
- package/dist/commands/new.d.ts +1 -0
- package/dist/commands/new.js +2 -1
- package/dist/commands/pull.js +13 -0
- package/dist/index.js +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { randomInt } from 'crypto';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { ensure_binary, ensure_data_dir } from '../utils/binary.js';
|
|
8
|
+
import { read_site_config, write_site_config } from '../utils/site-config.js';
|
|
9
|
+
import { SERVER_CONFIG_FILE, read_server_config, write_server_config } from '../utils/server-config.js';
|
|
10
|
+
import { normalize_site } from './validate.js';
|
|
11
|
+
import { import_site_files, site_exists, wait_for_ready, kill_process } from './dev.js';
|
|
12
|
+
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
13
|
+
function generate_id() {
|
|
14
|
+
let id = '';
|
|
15
|
+
for (let i = 0; i < 15; i++) {
|
|
16
|
+
id += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
|
|
17
|
+
}
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
// Mirror of new.ts's display-name derivation so `primo add maison-verde`
|
|
21
|
+
// and `primo new maison-verde` produce the same site name.
|
|
22
|
+
function derive_display_name(folder_name) {
|
|
23
|
+
return folder_name.includes('.')
|
|
24
|
+
? folder_name.split('.')[0].charAt(0).toUpperCase() + folder_name.split('.')[0].slice(1)
|
|
25
|
+
: folder_name.charAt(0).toUpperCase() + folder_name.slice(1).replace(/-/g, ' ');
|
|
26
|
+
}
|
|
27
|
+
// Register an existing sites/<name> folder with the workspace CMS: ensure
|
|
28
|
+
// site.yaml has a site_id, then import the site's records into the workspace
|
|
29
|
+
// database — via the running dev server when there is one, or a short-lived
|
|
30
|
+
// headless CMS process otherwise. One-time action; exits when done.
|
|
31
|
+
export async function add_site(target, options) {
|
|
32
|
+
const base_dir = path.resolve(options.dir);
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(path.join(base_dir, SERVER_CONFIG_FILE));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
console.log(chalk.red(`No ${SERVER_CONFIG_FILE} found in ${base_dir}.`));
|
|
38
|
+
console.log(chalk.dim('Run `primo add` from the workspace root, or pass --dir <workspace>.'));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const sites_root = path.join(base_dir, 'sites');
|
|
42
|
+
const site_dir = resolve_site_dir(base_dir, sites_root, target);
|
|
43
|
+
const folder_name = path.basename(site_dir);
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = await fs.stat(site_dir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
console.log(chalk.red(`sites/${folder_name} not found.`));
|
|
50
|
+
console.log(chalk.dim('Create the folder first, or use `primo new` to scaffold a fresh site.'));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (!stat.isDirectory()) {
|
|
54
|
+
console.log(chalk.red(`sites/${folder_name} is not a directory.`));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (!await looks_like_site(site_dir)) {
|
|
58
|
+
console.log(chalk.red(`sites/${folder_name} doesn't look like a Primo site.`));
|
|
59
|
+
console.log(chalk.dim('Expected at least one of: site.yaml, pages/, blocks/, page-types/, site/'));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
let server_config = await read_server_config(base_dir);
|
|
63
|
+
const { config, created, minted } = await ensure_site_config(site_dir, folder_name);
|
|
64
|
+
if (created) {
|
|
65
|
+
console.log(chalk.dim(` created site.yaml (site_id ${config.site_id})`));
|
|
66
|
+
}
|
|
67
|
+
else if (minted) {
|
|
68
|
+
console.log(chalk.dim(` stamped site_id ${config.site_id} into site.yaml`));
|
|
69
|
+
}
|
|
70
|
+
// `primo new` guarantees the default group exists in server.yaml; when we
|
|
71
|
+
// assign group: default ourselves, give it the same guarantee so the
|
|
72
|
+
// dashboard doesn't invent an ad-hoc group id for it.
|
|
73
|
+
if (config.group === 'default') {
|
|
74
|
+
const site_groups = server_config.site_groups ?? [];
|
|
75
|
+
if (!site_groups.some((group) => group.id === 'default')) {
|
|
76
|
+
server_config = {
|
|
77
|
+
...server_config,
|
|
78
|
+
site_groups: [...site_groups, { id: 'default', name: 'Default', index: site_groups.length }]
|
|
79
|
+
};
|
|
80
|
+
await write_server_config(base_dir, server_config);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const port = server_config.port ?? parseInt(options.port, 10);
|
|
84
|
+
const api_url = `http://127.0.0.1:${port}`;
|
|
85
|
+
if (await is_server_running(port)) {
|
|
86
|
+
// A dev server is up — ask it to discover and import the site, exactly
|
|
87
|
+
// like `primo new` does, so its watchers attach too.
|
|
88
|
+
const reload = await request_dev_reload(port);
|
|
89
|
+
if (reload.status === 'unreachable') {
|
|
90
|
+
// Older dev server or hot reload disabled (port+1 in use). Import
|
|
91
|
+
// directly against the running CMS — records land, but the dev
|
|
92
|
+
// server won't watch this site until it's restarted.
|
|
93
|
+
let timings = null;
|
|
94
|
+
try {
|
|
95
|
+
timings = await register_site(site_dir, api_url, config, port, server_config, base_dir);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
console.log(chalk.red(` ✗ ${config.name} could not be imported: ${message}`));
|
|
100
|
+
console.log(chalk.dim(` Fix the problem, then re-run \`primo add ${target}\`.`));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
report_import(config.name, timings);
|
|
104
|
+
console.log(chalk.yellow(' The running dev server couldn\'t be reloaded — restart `primo dev` to watch this site.'));
|
|
105
|
+
console.log('');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// The health check only proves *a* Primo server is on this port — it
|
|
109
|
+
// could belong to a different workspace. The reload response's `known`
|
|
110
|
+
// list is the only reliable way to tell: PocketBase 404s record reads
|
|
111
|
+
// pre-setup even when the record exists, so we can't just probe the
|
|
112
|
+
// site_id. (Older dev servers don't send `known` — trust the reload.)
|
|
113
|
+
const known = reload.known?.find(site => site.site_id === config.site_id);
|
|
114
|
+
if (reload.known && !known) {
|
|
115
|
+
console.log(chalk.red(` A Primo server is running on port ${port}, but it isn't serving this workspace.`));
|
|
116
|
+
console.log(chalk.dim(' Stop it (or start `primo dev` in this workspace) and re-run `primo add`.'));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
if (known?.blocked || reload.quarantined.includes(config.name)) {
|
|
120
|
+
console.log(chalk.yellow(` ${config.name} was found, but the dev server couldn't import it (e.g. duplicate IDs or a missing pages/index.yaml).`));
|
|
121
|
+
console.log(chalk.dim(' Check the `primo dev` logs, fix the problem, then re-run `primo add`.'));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
console.log('');
|
|
125
|
+
if (reload.loaded === 0 && reload.known) {
|
|
126
|
+
console.log(` ${chalk.cyan(config.name)} is already registered with the running dev server.`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
console.log(chalk.green(` ✓ ${config.name} registered`));
|
|
130
|
+
}
|
|
131
|
+
const host = local_dev_host(config.name, port);
|
|
132
|
+
console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
|
|
133
|
+
console.log(` ${chalk.dim('Preview:')} http://${host}/`);
|
|
134
|
+
console.log('');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// No dev server running: boot the CMS binary headlessly against the
|
|
138
|
+
// workspace database, import, and shut it back down.
|
|
139
|
+
const spinner = ora('Starting CMS for one-time import...').start();
|
|
140
|
+
const binary_path = await ensure_binary();
|
|
141
|
+
const data_dir = await ensure_data_dir(base_dir);
|
|
142
|
+
const cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
|
|
143
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
+
env: { ...process.env, PRIMO_DEV_MODE: '1', PRIMO_AUTHOR_MODE: 'files' }
|
|
145
|
+
});
|
|
146
|
+
let stderr_output = '';
|
|
147
|
+
cms_process.stderr?.on('data', (data) => {
|
|
148
|
+
stderr_output += data.toString();
|
|
149
|
+
});
|
|
150
|
+
let timings = null;
|
|
151
|
+
let boot_failed = false;
|
|
152
|
+
let import_error = null;
|
|
153
|
+
try {
|
|
154
|
+
const ready = await wait_for_ready(api_url, 30000);
|
|
155
|
+
// A ready health check only proves *something* answered on the port.
|
|
156
|
+
// If our child lost the bind race to another CMS and exited, that
|
|
157
|
+
// other server is answering — importing would land this site's
|
|
158
|
+
// records in a different workspace's database. Require our own
|
|
159
|
+
// process to still be alive before trusting the port.
|
|
160
|
+
if (!ready || cms_process.exitCode !== null || cms_process.killed) {
|
|
161
|
+
boot_failed = true;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
spinner.text = `Importing ${config.name}...`;
|
|
165
|
+
try {
|
|
166
|
+
timings = await register_site(site_dir, api_url, config, port, server_config, base_dir);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
import_error = err instanceof Error ? err.message : String(err);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
// Always tear the CMS down before exiting — process.exit skips
|
|
175
|
+
// finally blocks, so no exit() may appear inside this try.
|
|
176
|
+
await kill_process(cms_process);
|
|
177
|
+
}
|
|
178
|
+
if (boot_failed) {
|
|
179
|
+
spinner.fail('CMS failed to start');
|
|
180
|
+
if (stderr_output)
|
|
181
|
+
console.log(chalk.red(stderr_output));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
if (import_error !== null) {
|
|
185
|
+
spinner.fail(`${config.name} could not be imported: ${import_error}`);
|
|
186
|
+
console.log(chalk.dim(` Fix the problem, then re-run \`primo add ${target}\`.`));
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
if (timings === null || !timings.ok) {
|
|
190
|
+
// Duplicate _ids or a failed bootstrap+import — details already printed.
|
|
191
|
+
spinner.fail(`${config.name} could not be imported — see the errors above.`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
spinner.succeed(`${config.name} registered (${timings.mode})`);
|
|
195
|
+
report_warnings(timings);
|
|
196
|
+
console.log('');
|
|
197
|
+
console.log(chalk.dim(' Run `primo dev` to serve it.'));
|
|
198
|
+
console.log('');
|
|
199
|
+
}
|
|
200
|
+
async function register_site(site_dir, api_url, config, port, server_config, base_dir) {
|
|
201
|
+
await normalize_site(site_dir);
|
|
202
|
+
const use_bootstrap = !await site_exists(api_url, config.site_id);
|
|
203
|
+
return await import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap, base_dir);
|
|
204
|
+
}
|
|
205
|
+
function report_import(site_name, timings) {
|
|
206
|
+
if (timings === null || !timings.ok) {
|
|
207
|
+
console.log(chalk.red(` ✗ ${site_name} could not be imported — see the errors above.`));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
console.log(chalk.green(` ✓ ${site_name} registered (${timings.mode})`));
|
|
211
|
+
report_warnings(timings);
|
|
212
|
+
}
|
|
213
|
+
function report_warnings(timings) {
|
|
214
|
+
if (timings.warning_count > 0) {
|
|
215
|
+
console.log(chalk.yellow(` ⚠ imported with ${timings.warning_count} warning${timings.warning_count === 1 ? '' : 's'} (details above)`));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function resolve_site_dir(base_dir, sites_root, target) {
|
|
219
|
+
// A bare name resolves under sites/; anything with a separator is taken
|
|
220
|
+
// as a path. Either way the result must be directly under sites/ — that's
|
|
221
|
+
// the only directory site discovery and the dashboard scan, so registering
|
|
222
|
+
// a folder anywhere else would import records no later `primo dev` run
|
|
223
|
+
// can match back to files.
|
|
224
|
+
const resolved = !target.includes('/') && !target.includes(path.sep)
|
|
225
|
+
? path.resolve(sites_root, target)
|
|
226
|
+
: path.resolve(base_dir, target);
|
|
227
|
+
if (path.dirname(resolved) !== sites_root) {
|
|
228
|
+
console.log(chalk.red(`Sites must live directly under sites/ — got "${target}".`));
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
return resolved;
|
|
232
|
+
}
|
|
233
|
+
async function looks_like_site(site_dir) {
|
|
234
|
+
for (const marker of ['site.yaml', 'pages', 'blocks', 'page-types', 'site']) {
|
|
235
|
+
try {
|
|
236
|
+
await fs.stat(path.join(site_dir, marker));
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// keep looking
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
// Make sure site.yaml exists with a name and a site_id, minting what's
|
|
246
|
+
// missing. The site_id is the registration key: discovery, import, and the
|
|
247
|
+
// dashboard all match records by it, so a folder without one can never come
|
|
248
|
+
// online no matter how many times the dev server scans it.
|
|
249
|
+
async function ensure_site_config(site_dir, folder_name) {
|
|
250
|
+
let existing = null;
|
|
251
|
+
try {
|
|
252
|
+
const parsed = await read_site_config(site_dir);
|
|
253
|
+
if (parsed && typeof parsed === 'object') {
|
|
254
|
+
existing = parsed;
|
|
255
|
+
}
|
|
256
|
+
else if (parsed !== null && parsed !== undefined) {
|
|
257
|
+
// site.yaml exists but isn't a mapping (e.g. a bare string).
|
|
258
|
+
// Overwriting it would silently discard whatever the user meant
|
|
259
|
+
// to keep — an empty file is the only non-mapping we fill in.
|
|
260
|
+
console.log(chalk.red(`sites/${folder_name}/site.yaml is malformed (expected key: value mappings).`));
|
|
261
|
+
console.log(chalk.dim('Fix or delete it, then re-run `primo add`.'));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error.code !== 'ENOENT') {
|
|
267
|
+
// Unreadable or unparseable site.yaml — never overwrite it with a
|
|
268
|
+
// fresh config; that would mint a new site_id and drop the user's
|
|
269
|
+
// server/group for a site that may already be registered.
|
|
270
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
271
|
+
console.log(chalk.red(`sites/${folder_name}/site.yaml could not be read: ${message}`));
|
|
272
|
+
console.log(chalk.dim('Fix or delete it, then re-run `primo add`.'));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
// ENOENT — no site.yaml yet; create one below.
|
|
276
|
+
}
|
|
277
|
+
const created = existing === null;
|
|
278
|
+
const config = existing ?? { group: 'default' };
|
|
279
|
+
let changed = created;
|
|
280
|
+
if (!config.name) {
|
|
281
|
+
config.name = derive_display_name(folder_name);
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
const minted = !config.site_id;
|
|
285
|
+
if (minted) {
|
|
286
|
+
config.site_id = generate_id();
|
|
287
|
+
changed = true;
|
|
288
|
+
}
|
|
289
|
+
if (changed) {
|
|
290
|
+
await write_site_config(site_dir, config);
|
|
291
|
+
}
|
|
292
|
+
return { config, created, minted };
|
|
293
|
+
}
|
|
294
|
+
async function request_dev_reload(port) {
|
|
295
|
+
try {
|
|
296
|
+
// Bound the request: the reload handler runs discovery + import
|
|
297
|
+
// synchronously before responding.
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
300
|
+
let res;
|
|
301
|
+
try {
|
|
302
|
+
res = await fetch(`http://127.0.0.1:${port + 1}/reload`, {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
signal: controller.signal
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
clearTimeout(timeout);
|
|
309
|
+
}
|
|
310
|
+
if (!res.ok)
|
|
311
|
+
return { status: 'unreachable' };
|
|
312
|
+
const result = await res.json().catch(() => null);
|
|
313
|
+
if (!result)
|
|
314
|
+
return { status: 'unreachable' };
|
|
315
|
+
return {
|
|
316
|
+
status: 'ok',
|
|
317
|
+
loaded: result.loaded ?? 0,
|
|
318
|
+
quarantined: result.quarantined ?? [],
|
|
319
|
+
known: Array.isArray(result.known) ? result.known : undefined
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return { status: 'unreachable' };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Mirror of dev.ts's local_dev_host so the links printed here match the ones
|
|
327
|
+
// `primo dev` prints for the same site.
|
|
328
|
+
function local_dev_host(name, port) {
|
|
329
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
|
|
330
|
+
return `${slug}.localhost:${port}`;
|
|
331
|
+
}
|
|
332
|
+
async function is_server_running(port) {
|
|
333
|
+
try {
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
336
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
337
|
+
signal: controller.signal
|
|
338
|
+
});
|
|
339
|
+
clearTimeout(timeout);
|
|
340
|
+
return response.ok;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
package/dist/commands/build.js
CHANGED
|
@@ -48,6 +48,18 @@ export async function build_site(options) {
|
|
|
48
48
|
}
|
|
49
49
|
// No head.svelte, that's fine
|
|
50
50
|
}
|
|
51
|
+
// site/foot.html is verbatim HTML appended before </body> on every page —
|
|
52
|
+
// no templating, matching server publish. page-types/*/foot.html is
|
|
53
|
+
// intentionally not included (it isn't in server publish either).
|
|
54
|
+
let foot_content = '';
|
|
55
|
+
try {
|
|
56
|
+
foot_content = await fs.readFile(path.join(site_dir, 'site', 'foot.html'), 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error?.code !== 'ENOENT') {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
51
63
|
// Find all pages
|
|
52
64
|
const pages_dir = path.join(site_dir, 'pages');
|
|
53
65
|
const page_files = await find_pages(pages_dir);
|
|
@@ -78,6 +90,7 @@ export async function build_site(options) {
|
|
|
78
90
|
site_dir,
|
|
79
91
|
temp_dir,
|
|
80
92
|
head_content,
|
|
93
|
+
foot_content,
|
|
81
94
|
site_name: config.name,
|
|
82
95
|
block_cache,
|
|
83
96
|
layout_cache,
|
|
@@ -125,6 +138,23 @@ export async function build_site(options) {
|
|
|
125
138
|
process.exit(1);
|
|
126
139
|
}
|
|
127
140
|
}
|
|
141
|
+
// Field keys exposed to head fragments as bare identifiers. Anything that
|
|
142
|
+
// can't be a `let` binding (or would collide with the page component's own
|
|
143
|
+
// props) is skipped — the field just isn't available in head scope.
|
|
144
|
+
const RESERVED_HEAD_KEYS = new Set([
|
|
145
|
+
'arguments', 'eval', 'implements', 'interface', 'package', 'private', 'protected', 'public',
|
|
146
|
+
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
|
|
147
|
+
'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
|
|
148
|
+
'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', 'return',
|
|
149
|
+
'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var',
|
|
150
|
+
'void', 'while', 'with', 'yield', 'await', 'head_props'
|
|
151
|
+
]);
|
|
152
|
+
function head_identifier_keys(keys) {
|
|
153
|
+
return [...new Set(keys)].filter((key) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) &&
|
|
154
|
+
!RESERVED_HEAD_KEYS.has(key) &&
|
|
155
|
+
!/^section_\d+_props$/.test(key) &&
|
|
156
|
+
!/^Section_\d+/.test(key));
|
|
157
|
+
}
|
|
128
158
|
// Lazily load and validate a page type's head.svelte. Cached value is the raw
|
|
129
159
|
// fragment (may include <style>); empty string means "no head file present".
|
|
130
160
|
async function load_page_type_head(site_dir, page_type, cache) {
|
|
@@ -146,7 +176,7 @@ async function load_page_type_head(site_dir, page_type, cache) {
|
|
|
146
176
|
return content;
|
|
147
177
|
}
|
|
148
178
|
async function build_page(options) {
|
|
149
|
-
const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
|
|
179
|
+
const { page, page_path, site_dir, temp_dir, head_content, foot_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
|
|
150
180
|
try {
|
|
151
181
|
const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
|
|
152
182
|
// Load layout for this page type
|
|
@@ -164,11 +194,19 @@ async function build_page(options) {
|
|
|
164
194
|
const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data, page_url_map);
|
|
165
195
|
const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data, page_url_map);
|
|
166
196
|
const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data, page_url_map);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
197
|
+
const sections = [...header_sections, ...page_sections, ...footer_sections];
|
|
198
|
+
// Head fragments see site fields merged with the page's own fields (page
|
|
199
|
+
// wins), each pre-declared as a bare identifier — same scope as server
|
|
200
|
+
// publish. Every DEFINED field key is declared even when no value is set
|
|
201
|
+
// (binding to undefined), so `{seo_title || fallback}` works on pages
|
|
202
|
+
// that leave the field empty instead of throwing ReferenceError.
|
|
203
|
+
const head_data = { ...site_data.content, ...(page.fields || {}) };
|
|
204
|
+
const page_type_fields = await load_page_type_fields(site_dir, page_type);
|
|
205
|
+
const head_keys = head_identifier_keys([
|
|
206
|
+
...site_data.fields.map((field) => field.name),
|
|
207
|
+
...page_type_fields.map((field) => field.name),
|
|
208
|
+
...Object.keys(head_data)
|
|
209
|
+
]);
|
|
172
210
|
// Compile each block and collect CSS
|
|
173
211
|
const all_css = [];
|
|
174
212
|
const section_components = [];
|
|
@@ -192,8 +230,11 @@ async function build_page(options) {
|
|
|
192
230
|
props: section.content || {}
|
|
193
231
|
});
|
|
194
232
|
}
|
|
195
|
-
// Create a page component that renders all sections
|
|
196
|
-
|
|
233
|
+
// Create a page component that renders all sections and the head. The
|
|
234
|
+
// head rides through <svelte:head> so its Svelte syntax ({expression},
|
|
235
|
+
// {@html}, {#if}) is actually evaluated — pasting the fragment into the
|
|
236
|
+
// output verbatim leaked raw template syntax into deployed pages.
|
|
237
|
+
const page_component = generate_page_component(section_components, sections, combined_head_content, head_keys);
|
|
197
238
|
const page_component_path = path.join(temp_dir, `page_${page_build_id}.svelte`);
|
|
198
239
|
await fs.writeFile(page_component_path, page_component);
|
|
199
240
|
// Compile the page component
|
|
@@ -244,43 +285,36 @@ async function build_page(options) {
|
|
|
244
285
|
const { default: PageComponent } = await import(bundle_url);
|
|
245
286
|
// Import render from svelte/server
|
|
246
287
|
const { render } = await import('svelte/server');
|
|
247
|
-
// Build props for all sections
|
|
288
|
+
// Build props for all sections + the head fragment's field data
|
|
248
289
|
const props = {};
|
|
249
290
|
sections.forEach((section, i) => {
|
|
250
291
|
props[`section_${i}_props`] = section.content || {};
|
|
251
292
|
});
|
|
293
|
+
props.head_props = head_data;
|
|
252
294
|
const rendered = render(PageComponent, { props });
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
for (const m of style_matches) {
|
|
259
|
-
head_css_parts.push(m[1]);
|
|
260
|
-
}
|
|
261
|
-
if (head_css_parts.length > 0) {
|
|
262
|
-
head_css = head_css_parts.join('\n');
|
|
263
|
-
head_html = combined_head_content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
295
|
+
// No automatic <title> — server publish emits none, and an injected
|
|
296
|
+
// title would suppress any title a page-type head renders (Svelte keeps
|
|
297
|
+
// the first <title> it encounters). Warn so the omission is visible.
|
|
298
|
+
if (!/<title[\s>]/i.test(rendered.head || '')) {
|
|
299
|
+
console.log(chalk.yellow(` Warning: ${page.name || page_path || 'home'}: no <title> — render one from a head fragment (see the head-and-seo doc)`));
|
|
264
300
|
}
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
const
|
|
301
|
+
// Head <style> tags flow through rendered.head as real global style
|
|
302
|
+
// elements (matching server publish). Reset first so head styles can
|
|
303
|
+
// override it; block CSS last, as component styles land during render.
|
|
304
|
+
const block_css = all_css.filter(Boolean).join('\n');
|
|
269
305
|
const html = `<!DOCTYPE html>
|
|
270
306
|
<html lang="en">
|
|
271
307
|
<head>
|
|
272
308
|
<meta charset="UTF-8">
|
|
273
309
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
274
|
-
<title>${escape_html(title)}</title>
|
|
275
|
-
${head_html}
|
|
276
310
|
<style>
|
|
277
|
-
${
|
|
311
|
+
${CSS_RESET}
|
|
278
312
|
</style>
|
|
279
313
|
${rendered.head || ''}
|
|
280
|
-
</head>
|
|
314
|
+
${block_css ? ` <style>\n${block_css}\n </style>\n` : ''}</head>
|
|
281
315
|
<body>
|
|
282
316
|
${rendered.body || ''}
|
|
283
|
-
</body>
|
|
317
|
+
${foot_content}</body>
|
|
284
318
|
</html>`;
|
|
285
319
|
return { html };
|
|
286
320
|
}
|
|
@@ -315,14 +349,18 @@ async function compile_block(site_dir, block_name, temp_dir) {
|
|
|
315
349
|
return { js: '', css: '' };
|
|
316
350
|
}
|
|
317
351
|
}
|
|
318
|
-
function generate_page_component(components, sections) {
|
|
352
|
+
function generate_page_component(components, sections, head_content, head_keys) {
|
|
319
353
|
const imports = sections.map((section, i) => {
|
|
320
354
|
const safe_name = section.block.replace(/-/g, '_');
|
|
321
355
|
return `import Section_${i} from './${section.block}.compiled.js'`;
|
|
322
356
|
}).join('\n');
|
|
323
|
-
const props_declarations =
|
|
324
|
-
|
|
325
|
-
|
|
357
|
+
const props_declarations = [
|
|
358
|
+
...sections.map((_, i) => `section_${i}_props = {}`),
|
|
359
|
+
'head_props = {}'
|
|
360
|
+
].join(',\n\t');
|
|
361
|
+
// Bare identifiers for head scope; keys are pre-filtered to valid, safe
|
|
362
|
+
// binding names by head_identifier_keys.
|
|
363
|
+
const head_declarations = head_keys.map((key) => `let ${key} = head_props['${key}']`).join('\n');
|
|
326
364
|
const section_renders = sections.map((_, i) => {
|
|
327
365
|
return `<Section_${i} {...section_${i}_props} />`;
|
|
328
366
|
}).join('\n\t\t');
|
|
@@ -334,27 +372,17 @@ ${imports}
|
|
|
334
372
|
let {
|
|
335
373
|
${props_declarations}
|
|
336
374
|
} = $props()
|
|
375
|
+
${head_declarations}
|
|
337
376
|
</script>
|
|
338
377
|
|
|
378
|
+
<svelte:head>
|
|
379
|
+
${head_content}
|
|
380
|
+
</svelte:head>
|
|
381
|
+
|
|
339
382
|
<main>
|
|
340
383
|
${section_renders}
|
|
341
384
|
</main>`;
|
|
342
385
|
}
|
|
343
|
-
function generate_empty_page(site_name, page_name, head_content) {
|
|
344
|
-
const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
|
|
345
|
-
return `<!DOCTYPE html>
|
|
346
|
-
<html lang="en">
|
|
347
|
-
<head>
|
|
348
|
-
<meta charset="UTF-8">
|
|
349
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
350
|
-
<title>${escape_html(title)}</title>
|
|
351
|
-
<style>${CSS_RESET}</style>
|
|
352
|
-
${head_content}
|
|
353
|
-
</head>
|
|
354
|
-
<body>
|
|
355
|
-
</body>
|
|
356
|
-
</html>`;
|
|
357
|
-
}
|
|
358
386
|
function generate_error_page(site_name, page_name, error, head_content) {
|
|
359
387
|
const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
|
|
360
388
|
// Extract just the CSS from head_content
|
|
@@ -590,6 +618,22 @@ async function load_block_fields(site_dir, block_name) {
|
|
|
590
618
|
return [];
|
|
591
619
|
}
|
|
592
620
|
}
|
|
621
|
+
const page_type_fields_cache = new Map();
|
|
622
|
+
async function load_page_type_fields(site_dir, page_type) {
|
|
623
|
+
const cache_key = `${site_dir}:${page_type}`;
|
|
624
|
+
const cached = page_type_fields_cache.get(cache_key);
|
|
625
|
+
if (cached !== undefined)
|
|
626
|
+
return cached;
|
|
627
|
+
let fields = [];
|
|
628
|
+
try {
|
|
629
|
+
fields = extract_fields_array(await load_fields_file(path.join(site_dir, 'page-types', page_type, 'fields.yaml')));
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// No fields file for this page type
|
|
633
|
+
}
|
|
634
|
+
page_type_fields_cache.set(cache_key, fields);
|
|
635
|
+
return fields;
|
|
636
|
+
}
|
|
593
637
|
async function resolve_site_fields(site_dir, block_name, content, site_data) {
|
|
594
638
|
// Load block field definitions to check for site-field types
|
|
595
639
|
const block_fields = await load_block_fields(site_dir, block_name);
|
package/dist/commands/dev.d.ts
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
+
import { type ChildProcess } from 'child_process';
|
|
2
|
+
import { type SiteConfig } from '../utils/site-config.js';
|
|
3
|
+
import { type ServerConfig } from '../utils/server-config.js';
|
|
1
4
|
interface DevOptions {
|
|
2
5
|
dir: string;
|
|
3
6
|
port: string;
|
|
4
7
|
force?: boolean;
|
|
5
8
|
author?: string;
|
|
6
9
|
}
|
|
10
|
+
export type ImportTimings = {
|
|
11
|
+
zip_ms: number;
|
|
12
|
+
request_ms: number;
|
|
13
|
+
mode: 'bootstrap' | 'import' | 'bootstrap+import';
|
|
14
|
+
ok: boolean;
|
|
15
|
+
warning_count: number;
|
|
16
|
+
dropped_field_count: number;
|
|
17
|
+
warning_details: ImportWarning[];
|
|
18
|
+
};
|
|
19
|
+
export declare function kill_process(proc: ChildProcess): Promise<void>;
|
|
7
20
|
export declare function dev_server(options: DevOptions): Promise<void>;
|
|
21
|
+
export declare function wait_for_ready(url: string, timeout_ms: number): Promise<boolean>;
|
|
22
|
+
export declare function site_exists(api_url: string, site_id: string): Promise<boolean>;
|
|
23
|
+
export type ImportWarning = {
|
|
24
|
+
kind: string;
|
|
25
|
+
file: string;
|
|
26
|
+
path: string;
|
|
27
|
+
field: string;
|
|
28
|
+
block: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function import_site_files(site_dir: string, api_url: string, config: SiteConfig, port: number, server_config: ServerConfig, use_bootstrap?: boolean, workspace_dir?: string): Promise<ImportTimings | null>;
|
|
8
32
|
export {};
|
package/dist/commands/dev.js
CHANGED
|
@@ -554,7 +554,7 @@ async function fetch_with_timeout(url, options = {}, timeout_ms = 10000) {
|
|
|
554
554
|
}
|
|
555
555
|
}
|
|
556
556
|
// Kill process with escalation to SIGKILL
|
|
557
|
-
async function kill_process(proc) {
|
|
557
|
+
export async function kill_process(proc) {
|
|
558
558
|
if (!proc || proc.killed)
|
|
559
559
|
return;
|
|
560
560
|
proc.kill('SIGTERM');
|
|
@@ -622,6 +622,13 @@ export async function dev_server(options) {
|
|
|
622
622
|
// Single site mode
|
|
623
623
|
try {
|
|
624
624
|
const config = await read_site_config(base_dir);
|
|
625
|
+
if (typeof config.site_id !== 'string' || !config.site_id.trim()) {
|
|
626
|
+
// Without a site_id the import posts site_id=undefined and
|
|
627
|
+
// fails cryptically — stop with the actual fix instead.
|
|
628
|
+
spinner.fail(`${SITE_CONFIG_FILE} has no site_id — this site was never registered.`);
|
|
629
|
+
console.log(chalk.dim(` Run \`primo add ${path.basename(base_dir)}\` from the workspace root to register it.`));
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
625
632
|
sites = [{ dir: base_dir, config }];
|
|
626
633
|
}
|
|
627
634
|
catch {
|
|
@@ -792,8 +799,8 @@ export async function dev_server(options) {
|
|
|
792
799
|
}
|
|
793
800
|
schedule_library_push();
|
|
794
801
|
};
|
|
795
|
-
const on_event = (full_path) => {
|
|
796
|
-
if (should_skip_synced_delete(full_path))
|
|
802
|
+
const on_event = (event, full_path) => {
|
|
803
|
+
if (should_skip_synced_delete(full_path, event))
|
|
797
804
|
return;
|
|
798
805
|
if (synced_files.has(full_path)) {
|
|
799
806
|
// We last wrote this file from a CMS pull. If the
|
|
@@ -876,11 +883,11 @@ export async function dev_server(options) {
|
|
|
876
883
|
}
|
|
877
884
|
}, LOCAL_PUSH_DEBOUNCE_MS);
|
|
878
885
|
};
|
|
879
|
-
watcher.on('add', on_event);
|
|
880
|
-
watcher.on('change', on_event);
|
|
881
|
-
watcher.on('unlink', on_event);
|
|
882
|
-
watcher.on('addDir', on_event);
|
|
883
|
-
watcher.on('unlinkDir', on_event);
|
|
886
|
+
watcher.on('add', p => on_event('add', p));
|
|
887
|
+
watcher.on('change', p => on_event('change', p));
|
|
888
|
+
watcher.on('unlink', p => on_event('unlink', p));
|
|
889
|
+
watcher.on('addDir', p => on_event('addDir', p));
|
|
890
|
+
watcher.on('unlinkDir', p => on_event('unlinkDir', p));
|
|
884
891
|
watchers.push(watcher);
|
|
885
892
|
}
|
|
886
893
|
catch {
|
|
@@ -1018,8 +1025,8 @@ export async function dev_server(options) {
|
|
|
1018
1025
|
mark_pending_local_change();
|
|
1019
1026
|
continue_event(full_path);
|
|
1020
1027
|
};
|
|
1021
|
-
const on_event = (full_path) => {
|
|
1022
|
-
if (should_skip_synced_delete(full_path))
|
|
1028
|
+
const on_event = (event, full_path) => {
|
|
1029
|
+
if (should_skip_synced_delete(full_path, event))
|
|
1023
1030
|
return;
|
|
1024
1031
|
if (synced_files.has(full_path)) {
|
|
1025
1032
|
// We last wrote this file from a CMS pull. Compare
|
|
@@ -1045,11 +1052,11 @@ export async function dev_server(options) {
|
|
|
1045
1052
|
}
|
|
1046
1053
|
push_or_ignore_file_change(full_path);
|
|
1047
1054
|
};
|
|
1048
|
-
watcher.on('add', on_event);
|
|
1049
|
-
watcher.on('change', on_event);
|
|
1050
|
-
watcher.on('unlink', on_event);
|
|
1051
|
-
watcher.on('addDir', on_event);
|
|
1052
|
-
watcher.on('unlinkDir', on_event);
|
|
1055
|
+
watcher.on('add', p => on_event('add', p));
|
|
1056
|
+
watcher.on('change', p => on_event('change', p));
|
|
1057
|
+
watcher.on('unlink', p => on_event('unlink', p));
|
|
1058
|
+
watcher.on('addDir', p => on_event('addDir', p));
|
|
1059
|
+
watcher.on('unlinkDir', p => on_event('unlinkDir', p));
|
|
1053
1060
|
watchers.push(watcher);
|
|
1054
1061
|
}
|
|
1055
1062
|
catch {
|
|
@@ -1078,37 +1085,52 @@ export async function dev_server(options) {
|
|
|
1078
1085
|
continue;
|
|
1079
1086
|
known_sites.add(site.dir);
|
|
1080
1087
|
sites.push(site);
|
|
1081
|
-
const use_bootstrap = !await site_exists(api_url, site.config.site_id);
|
|
1082
1088
|
let import_ok = true;
|
|
1083
|
-
|
|
1084
|
-
await
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
await normalize_site(site.dir);
|
|
1088
|
-
const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
|
|
1089
|
-
if (import_timings === null) {
|
|
1090
|
-
// Duplicate _ids on a freshly discovered site — quarantine
|
|
1091
|
-
// it from CMS→file polling until a later import succeeds.
|
|
1092
|
-
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1093
|
-
quarantined.push(site.config.name);
|
|
1094
|
-
import_ok = false;
|
|
1089
|
+
try {
|
|
1090
|
+
const use_bootstrap = !await site_exists(api_url, site.config.site_id);
|
|
1091
|
+
if (sync_policy.mode === 'cms' && !use_bootstrap) {
|
|
1092
|
+
await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
|
|
1095
1093
|
}
|
|
1096
1094
|
else {
|
|
1097
|
-
await
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
// /reload body's `quarantined` list.
|
|
1095
|
+
await normalize_site(site.dir);
|
|
1096
|
+
const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
|
|
1097
|
+
if (import_timings === null) {
|
|
1098
|
+
// Duplicate _ids on a freshly discovered site — quarantine
|
|
1099
|
+
// it from CMS→file polling until a later import succeeds.
|
|
1103
1100
|
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1104
1101
|
quarantined.push(site.config.name);
|
|
1105
1102
|
import_ok = false;
|
|
1106
1103
|
}
|
|
1107
|
-
else
|
|
1108
|
-
await
|
|
1104
|
+
else {
|
|
1105
|
+
await write_import_sync_status(site.dir, import_timings, dev_location);
|
|
1106
|
+
if (!import_timings.ok) {
|
|
1107
|
+
// Bootstrap + fallback both failed (only logged). Quarantine
|
|
1108
|
+
// and skip the success path so we don't announce a site that
|
|
1109
|
+
// didn't actually load — and so `primo new` sees it in the
|
|
1110
|
+
// /reload body's `quarantined` list.
|
|
1111
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1112
|
+
quarantined.push(site.config.name);
|
|
1113
|
+
import_ok = false;
|
|
1114
|
+
}
|
|
1115
|
+
else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
|
|
1116
|
+
await update_site_sync_baseline(site, api_url, server_config, base_dir);
|
|
1117
|
+
}
|
|
1109
1118
|
}
|
|
1110
1119
|
}
|
|
1111
1120
|
}
|
|
1121
|
+
catch (err) {
|
|
1122
|
+
// A malformed site (e.g. missing pages/index.yaml, which makes
|
|
1123
|
+
// normalize_site throw) must not take the whole dev server down:
|
|
1124
|
+
// before this guard, the throw escaped the handler as an
|
|
1125
|
+
// unhandledRejection and tripped process-level cleanup().
|
|
1126
|
+
// Quarantine the site and keep serving; the watcher retries it
|
|
1127
|
+
// on the next file change.
|
|
1128
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1129
|
+
quarantined.push(site.config.name);
|
|
1130
|
+
import_ok = false;
|
|
1131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1132
|
+
console.log(chalk.red(` ✗ ${site.config.name}: ${message}`));
|
|
1133
|
+
}
|
|
1112
1134
|
setup_site_watchers(site);
|
|
1113
1135
|
loaded_count++;
|
|
1114
1136
|
if (!import_ok) {
|
|
@@ -1124,9 +1146,18 @@ export async function dev_server(options) {
|
|
|
1124
1146
|
// just scaffolded actually imported. A quarantined site (duplicate
|
|
1125
1147
|
// _ids) returns 200 with loaded:false so a 2xx no longer implies
|
|
1126
1148
|
// success — the caller checks the body, not just the status.
|
|
1149
|
+
// `known` lists every site this server tracks so `primo add` can
|
|
1150
|
+
// tell "already registered here" from "this server is serving a
|
|
1151
|
+
// different workspace" — record-level reads can't make that call
|
|
1152
|
+
// (PocketBase 404s them pre-setup even when the record exists).
|
|
1127
1153
|
const body = JSON.stringify({
|
|
1128
1154
|
loaded: loaded_count,
|
|
1129
|
-
quarantined
|
|
1155
|
+
quarantined,
|
|
1156
|
+
known: sites.map(site => ({
|
|
1157
|
+
name: site.config.name,
|
|
1158
|
+
site_id: site.config.site_id,
|
|
1159
|
+
blocked: blocked_site_keys.has(get_site_sync_key(site.dir, site.config))
|
|
1160
|
+
}))
|
|
1130
1161
|
});
|
|
1131
1162
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1132
1163
|
res.end(body);
|
|
@@ -1279,6 +1310,33 @@ async function register_primo_mcp_server(base_dir) {
|
|
|
1279
1310
|
function is_plain_record(value) {
|
|
1280
1311
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1281
1312
|
}
|
|
1313
|
+
// Folders that already got their unregistered/unreadable warning, so
|
|
1314
|
+
// rediscovery (which runs on every /reload) doesn't repeat it.
|
|
1315
|
+
const warned_unregistered_dirs = new Set();
|
|
1316
|
+
// Site-shaped = has any of the content dirs a real site carries. Used to
|
|
1317
|
+
// tell an authored-but-unregistered site (warn) from a random folder that
|
|
1318
|
+
// happens to live under sites/ (ignore silently, as before).
|
|
1319
|
+
async function is_site_shaped(site_dir) {
|
|
1320
|
+
for (const marker of ['pages', 'blocks', 'page-types', 'site']) {
|
|
1321
|
+
try {
|
|
1322
|
+
// Must be a directory — a plain file named e.g. `pages` in a
|
|
1323
|
+
// non-site folder shouldn't earn the unregistered warning.
|
|
1324
|
+
const stat = await fs.stat(path.join(site_dir, marker));
|
|
1325
|
+
if (stat.isDirectory())
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
catch {
|
|
1329
|
+
// keep looking
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
function warn_once(site_dir, message) {
|
|
1335
|
+
if (warned_unregistered_dirs.has(site_dir))
|
|
1336
|
+
return;
|
|
1337
|
+
warned_unregistered_dirs.add(site_dir);
|
|
1338
|
+
console.log(chalk.yellow(message));
|
|
1339
|
+
}
|
|
1282
1340
|
async function discover_sites(base_dir) {
|
|
1283
1341
|
const sites = [];
|
|
1284
1342
|
const sites_root = await get_sites_root(base_dir);
|
|
@@ -1288,10 +1346,26 @@ async function discover_sites(base_dir) {
|
|
|
1288
1346
|
const site_dir = path.join(sites_root, entry.name);
|
|
1289
1347
|
try {
|
|
1290
1348
|
const config = await read_site_config(site_dir);
|
|
1349
|
+
if (typeof config.site_id !== 'string' || !config.site_id.trim()) {
|
|
1350
|
+
// An authored site with no registration key. Importing it
|
|
1351
|
+
// would post site_id=undefined and die with a buried
|
|
1352
|
+
// error, so skip it and say what to run instead.
|
|
1353
|
+
warn_once(site_dir, ` ⚠ sites/${entry.name} isn't registered — run \`primo add ${entry.name}\` to import it.`);
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1291
1356
|
sites.push({ dir: site_dir, config });
|
|
1292
1357
|
}
|
|
1293
|
-
catch {
|
|
1294
|
-
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
if (error.code === 'ENOENT') {
|
|
1360
|
+
// No site.yaml. Only warn when the folder actually looks
|
|
1361
|
+
// like a site — anything else is not our business.
|
|
1362
|
+
if (await is_site_shaped(site_dir)) {
|
|
1363
|
+
warn_once(site_dir, ` ⚠ sites/${entry.name} isn't registered — run \`primo add ${entry.name}\` to import it.`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
else {
|
|
1367
|
+
warn_once(site_dir, ` ⚠ sites/${entry.name}/site.yaml could not be read — fix it, then run \`primo add ${entry.name}\`.`);
|
|
1368
|
+
}
|
|
1295
1369
|
}
|
|
1296
1370
|
}
|
|
1297
1371
|
}
|
|
@@ -1337,7 +1411,7 @@ function resolve_site_group(config, server_config) {
|
|
|
1337
1411
|
index: 0
|
|
1338
1412
|
});
|
|
1339
1413
|
}
|
|
1340
|
-
async function wait_for_ready(url, timeout_ms) {
|
|
1414
|
+
export async function wait_for_ready(url, timeout_ms) {
|
|
1341
1415
|
const start = Date.now();
|
|
1342
1416
|
const health_url = `${url}/api/health`;
|
|
1343
1417
|
while (Date.now() - start < timeout_ms) {
|
|
@@ -1374,7 +1448,7 @@ async function verify_site_ready(api_url, site_id) {
|
|
|
1374
1448
|
}
|
|
1375
1449
|
return false;
|
|
1376
1450
|
}
|
|
1377
|
-
async function site_exists(api_url, site_id) {
|
|
1451
|
+
export async function site_exists(api_url, site_id) {
|
|
1378
1452
|
// Only 404 means the site genuinely doesn't exist. Any other non-ok status
|
|
1379
1453
|
// (401/403 from auth, 5xx, rate limits) leaves us uncertain — default to
|
|
1380
1454
|
// "exists" so we take the additive `import` path instead of the destructive
|
|
@@ -1462,11 +1536,21 @@ function mark_written_file(file_path, content) {
|
|
|
1462
1536
|
function mark_deleted_path(file_path) {
|
|
1463
1537
|
synced_deleted_paths.set(file_path, Date.now());
|
|
1464
1538
|
}
|
|
1465
|
-
function
|
|
1539
|
+
function is_delete_event(event) {
|
|
1540
|
+
return event === 'unlink' || event === 'unlinkDir';
|
|
1541
|
+
}
|
|
1542
|
+
function should_skip_synced_delete(file_path, event) {
|
|
1466
1543
|
const deleted_at = synced_deleted_paths.get(file_path);
|
|
1467
1544
|
if (!deleted_at) {
|
|
1468
1545
|
return false;
|
|
1469
1546
|
}
|
|
1547
|
+
// A re-creation cancels the pending delete-echo suppression and must be
|
|
1548
|
+
// allowed through to import. Clear the marker so a later real delete of the
|
|
1549
|
+
// same path isn't mistaken for our echo.
|
|
1550
|
+
if (!is_delete_event(event)) {
|
|
1551
|
+
synced_deleted_paths.delete(file_path);
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1470
1554
|
if (Date.now() - deleted_at < 10_000) {
|
|
1471
1555
|
synced_deleted_paths.delete(file_path);
|
|
1472
1556
|
return true;
|
|
@@ -2026,7 +2110,7 @@ function print_import_warnings(site_name, warnings) {
|
|
|
2026
2110
|
console.log('');
|
|
2027
2111
|
return list.length;
|
|
2028
2112
|
}
|
|
2029
|
-
async function import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap = true, workspace_dir = path.dirname(path.dirname(site_dir))) {
|
|
2113
|
+
export async function import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap = true, workspace_dir = path.dirname(path.dirname(site_dir))) {
|
|
2030
2114
|
const site_name = config.name || 'My Site';
|
|
2031
2115
|
const site_id = config.site_id;
|
|
2032
2116
|
const site_group = resolve_site_group(config, server_config);
|
package/dist/commands/new.d.ts
CHANGED
package/dist/commands/new.js
CHANGED
|
@@ -375,7 +375,7 @@ async function is_server_running(port) {
|
|
|
375
375
|
return false;
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
|
-
function generate_agent_md() {
|
|
378
|
+
export function generate_agent_md() {
|
|
379
379
|
return `# Primo workspace
|
|
380
380
|
|
|
381
381
|
Primo workspace for local development. Each subdirectory under \`sites/\` is an independent Primo site.
|
|
@@ -394,6 +394,7 @@ Without the MCP server, read \`sites/*/blocks/*/fields.yaml\` and \`sites/*/page
|
|
|
394
394
|
|
|
395
395
|
- \`primo dev\` — start the local CMS and dev server. Run from the workspace root.
|
|
396
396
|
- \`primo new [name]\` — scaffold a new site under \`sites/\`.
|
|
397
|
+
- \`primo add <name>\` — register an existing \`sites/<name>\` folder with the CMS (mints its site_id and imports its records). Creating the folder alone doesn't register it.
|
|
397
398
|
- File edits sync automatically while \`primo dev\` is running. Structural changes (block schema, component) may trigger a browser reload.
|
|
398
399
|
|
|
399
400
|
## Source of truth
|
package/dist/commands/pull.js
CHANGED
|
@@ -8,6 +8,7 @@ import { get_auth_token } from '../utils/auth.js';
|
|
|
8
8
|
import { authenticate_interactively } from './login.js';
|
|
9
9
|
import { write_site_config } from '../utils/site-config.js';
|
|
10
10
|
import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
|
|
11
|
+
import { generate_agent_md } from './new.js';
|
|
11
12
|
// Directories owned by the server export. Local files under these that no
|
|
12
13
|
// longer exist in the export are stale (e.g. a page that gained children
|
|
13
14
|
// moved from pages/foo.yaml to pages/foo/index.yaml) and get trashed.
|
|
@@ -182,6 +183,18 @@ export async function pull_site(options) {
|
|
|
182
183
|
site_groups: site_groups.length > 0 ? site_groups : existing?.site_groups,
|
|
183
184
|
server: existing?.server ?? (is_remote_server(server) ? server : undefined)
|
|
184
185
|
});
|
|
186
|
+
// Give pulled workspaces the same root AGENTS.md that `primo new`
|
|
187
|
+
// writes — a pulled export is exactly where agents author new site
|
|
188
|
+
// folders and need to know registration is `primo add`, not a side
|
|
189
|
+
// effect of `primo dev`. Never clobber an existing (possibly edited)
|
|
190
|
+
// one.
|
|
191
|
+
const agents_path = path.join(root_dir, 'AGENTS.md');
|
|
192
|
+
try {
|
|
193
|
+
await fs.access(agents_path);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
await fs.writeFile(agents_path, generate_agent_md());
|
|
197
|
+
}
|
|
185
198
|
spinner.succeed(`Server pulled to ${chalk.cyan(root_dir)}`);
|
|
186
199
|
console.log('');
|
|
187
200
|
console.log(chalk.dim(' Sites:'));
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Command } from 'commander';
|
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { init_workspace } from './commands/init.js';
|
|
8
8
|
import { new_site } from './commands/new.js';
|
|
9
|
+
import { add_site } from './commands/add.js';
|
|
9
10
|
import { pull_site } from './commands/pull.js';
|
|
10
11
|
import { push_site } from './commands/push.js';
|
|
11
12
|
import { pull_library } from './commands/pull-library.js';
|
|
@@ -28,6 +29,7 @@ program
|
|
|
28
29
|
program.addHelpText('before', `
|
|
29
30
|
${chalk.bold('Local development')}
|
|
30
31
|
${chalk.cyan('primo dev')} Run the local CMS on this workspace
|
|
32
|
+
${chalk.cyan('primo add')} Register a hand-authored sites/ folder with the CMS
|
|
31
33
|
|
|
32
34
|
${chalk.bold('Going live — pick one')}
|
|
33
35
|
Want others to edit content? .................. ${chalk.cyan('primo deploy')}
|
|
@@ -44,6 +46,22 @@ program
|
|
|
44
46
|
.option('-t, --template <template>', 'Starter template')
|
|
45
47
|
.option('--skip-dev', 'Create files only, don\'t start CMS')
|
|
46
48
|
.action((name, options) => new_site({ name, ...options }));
|
|
49
|
+
program
|
|
50
|
+
.command('add <site>')
|
|
51
|
+
.description('Register an existing sites/ folder with the workspace CMS, then exit')
|
|
52
|
+
.option('-d, --dir <dir>', 'Workspace directory', '.')
|
|
53
|
+
.option('-p, --port <port>', 'Port', '3000')
|
|
54
|
+
.addHelpText('after', `
|
|
55
|
+
Creating a folder under ${chalk.cyan('sites/')} isn't enough to make it appear in the
|
|
56
|
+
dashboard — its records must be imported into the workspace database. This
|
|
57
|
+
command does that one-time import (minting a site_id into site.yaml if needed)
|
|
58
|
+
and exits. If ${chalk.cyan('primo dev')} is already running, it picks the site up live.
|
|
59
|
+
|
|
60
|
+
${chalk.bold('Examples')}
|
|
61
|
+
primo add maison-verde
|
|
62
|
+
primo add sites/maison-verde
|
|
63
|
+
`)
|
|
64
|
+
.action((site, options) => add_site(site, options));
|
|
47
65
|
program
|
|
48
66
|
.command('dev')
|
|
49
67
|
.description('Start local CMS')
|
|
@@ -163,6 +181,14 @@ program.on('command:*', (operands) => {
|
|
|
163
181
|
console.error('');
|
|
164
182
|
console.error(` Run: ${chalk.cyan('primo deploy --help')}`);
|
|
165
183
|
}
|
|
184
|
+
else if (cmd === 'register' || cmd === 'import') {
|
|
185
|
+
// Likely guesses for site registration. Deliberately not aliases:
|
|
186
|
+
// one canonical name keeps docs/transcripts consistent, and leaves
|
|
187
|
+
// `register` free for a future account/signup meaning.
|
|
188
|
+
console.error(` To register an existing sites/ folder with the CMS, run ${chalk.cyan('primo add <site>')}.`);
|
|
189
|
+
console.error('');
|
|
190
|
+
console.error(` Run: ${chalk.cyan('primo add --help')}`);
|
|
191
|
+
}
|
|
166
192
|
else {
|
|
167
193
|
console.error(` Run ${chalk.cyan('primo --help')} to see available commands.`);
|
|
168
194
|
}
|