primo-cli 0.1.19 → 0.1.21
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/build.js +92 -48
- package/dist/commands/deploy.js +10 -2
- package/dist/commands/push.d.ts +1 -1
- package/dist/commands/push.js +23 -4
- package/dist/index.js +11 -2
- package/package.json +1 -1
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/deploy.js
CHANGED
|
@@ -479,18 +479,26 @@ async function wait_for_ready(url) {
|
|
|
479
479
|
// Run the equivalent of `primo push` against the local workspace. push_site
|
|
480
480
|
// resolves the server URL from server.yaml (which we just wrote), so no extra
|
|
481
481
|
// flags are needed. We surface failures but don't re-throw — the deploy already
|
|
482
|
-
// succeeded and the user can rerun push manually.
|
|
482
|
+
// succeeded and the user can rerun push manually. Only a fully clean push
|
|
483
|
+
// counts as success: a partial upload must not be reported as "complete".
|
|
483
484
|
async function run_auto_push(inventory) {
|
|
484
485
|
console.log('');
|
|
485
486
|
console.log(chalk.cyan('Uploading your workspace...'));
|
|
486
487
|
try {
|
|
487
|
-
await push_site({ dir: inventory.root_dir });
|
|
488
|
+
const failed = await push_site({ dir: inventory.root_dir });
|
|
489
|
+
if (failed.length > 0) {
|
|
490
|
+
// push_site already printed the per-item errors, the summary, and
|
|
491
|
+
// set the exit code. The server itself is fine, so finish the
|
|
492
|
+
// deploy flow — just not as "complete".
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
488
495
|
return true;
|
|
489
496
|
}
|
|
490
497
|
catch (error) {
|
|
491
498
|
console.log('');
|
|
492
499
|
console.log(chalk.yellow(`Auto-push failed: ${error instanceof Error ? error.message : error}`));
|
|
493
500
|
console.log(chalk.dim(' Your server is live — rerun `primo push` once the issue is sorted.'));
|
|
501
|
+
process.exitCode = 1;
|
|
494
502
|
return false;
|
|
495
503
|
}
|
|
496
504
|
}
|
package/dist/commands/push.d.ts
CHANGED
package/dist/commands/push.js
CHANGED
|
@@ -39,6 +39,8 @@ async function resolve_group_name(site_dir, group_id) {
|
|
|
39
39
|
}
|
|
40
40
|
return undefined;
|
|
41
41
|
}
|
|
42
|
+
// Returns the labels (site slugs / 'library') that failed to push so callers
|
|
43
|
+
// like `primo deploy` can tell a clean run from a partial one. Empty = success.
|
|
42
44
|
export async function push_site(options) {
|
|
43
45
|
const root_dir = path.resolve(options.dir);
|
|
44
46
|
const has_site_yaml = await path_exists(get_site_config_path(root_dir));
|
|
@@ -61,17 +63,27 @@ export async function push_site(options) {
|
|
|
61
63
|
: options;
|
|
62
64
|
if (options.dryRun) {
|
|
63
65
|
await print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, effective_options);
|
|
64
|
-
return;
|
|
66
|
+
return [];
|
|
65
67
|
}
|
|
66
68
|
// Server-folder mode: walk site subfolders + push library
|
|
67
69
|
if (!has_site_yaml && has_server_yaml) {
|
|
68
|
-
await push_server(root_dir, effective_options);
|
|
69
|
-
|
|
70
|
+
const failed = await push_server(root_dir, effective_options);
|
|
71
|
+
if (failed.length > 0) {
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(chalk.red(`Push incomplete — failed: ${failed.join(', ')}`));
|
|
74
|
+
console.log(chalk.dim(' Fix the errors above and rerun `primo push` (completed pushes are safe to repeat).'));
|
|
75
|
+
console.log('');
|
|
76
|
+
// exitCode (not process.exit) so an in-process caller like `primo
|
|
77
|
+
// deploy` can still finish its own reporting before the process ends.
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
}
|
|
80
|
+
return failed;
|
|
70
81
|
}
|
|
71
82
|
// Single-site mode (cwd is a site folder, or --dir points at one)
|
|
72
83
|
const spinner = ora('Reading local files...').start();
|
|
73
84
|
try {
|
|
74
85
|
await push_single_site(root_dir, effective_options, spinner);
|
|
86
|
+
return [];
|
|
75
87
|
}
|
|
76
88
|
catch (error) {
|
|
77
89
|
spinner.fail(`Push failed: ${error instanceof Error ? error.message : error}`);
|
|
@@ -166,6 +178,9 @@ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, opti
|
|
|
166
178
|
console.log(chalk.dim(' No requests sent. Run without --dry-run to push.'));
|
|
167
179
|
console.log('');
|
|
168
180
|
}
|
|
181
|
+
// Pushes every site folder plus the library, continuing past individual
|
|
182
|
+
// failures. Returns the labels that failed — the caller decides how loudly a
|
|
183
|
+
// partial push should fail.
|
|
169
184
|
async function push_server(root_dir, options) {
|
|
170
185
|
// Sites live under sites/<slug>/
|
|
171
186
|
const sites_root = path.join(root_dir, 'sites');
|
|
@@ -204,9 +219,10 @@ async function push_server(root_dir, options) {
|
|
|
204
219
|
print_auth_hint();
|
|
205
220
|
process.exit(1);
|
|
206
221
|
}
|
|
207
|
-
return;
|
|
222
|
+
return [];
|
|
208
223
|
}
|
|
209
224
|
let saw_auth_error = false;
|
|
225
|
+
const failed = [];
|
|
210
226
|
// Push each site
|
|
211
227
|
for (const site_dir of site_dirs) {
|
|
212
228
|
const spinner = ora(`Pushing ${chalk.cyan(path.basename(site_dir))}...`).start();
|
|
@@ -217,6 +233,7 @@ async function push_server(root_dir, options) {
|
|
|
217
233
|
spinner.fail(`${path.basename(site_dir)}: ${error instanceof Error ? error.message : error}`);
|
|
218
234
|
if (is_auth_error(error))
|
|
219
235
|
saw_auth_error = true;
|
|
236
|
+
failed.push(path.basename(site_dir));
|
|
220
237
|
// Continue to remaining sites rather than abort the whole push
|
|
221
238
|
}
|
|
222
239
|
}
|
|
@@ -231,10 +248,12 @@ async function push_server(root_dir, options) {
|
|
|
231
248
|
spinner.fail(`library: ${error instanceof Error ? error.message : error}`);
|
|
232
249
|
if (is_auth_error(error))
|
|
233
250
|
saw_auth_error = true;
|
|
251
|
+
failed.push('library');
|
|
234
252
|
}
|
|
235
253
|
}
|
|
236
254
|
if (saw_auth_error)
|
|
237
255
|
print_auth_hint();
|
|
256
|
+
return failed;
|
|
238
257
|
}
|
|
239
258
|
async function push_single_site(site_dir, options, spinner) {
|
|
240
259
|
let config = null;
|
package/dist/index.js
CHANGED
|
@@ -95,7 +95,7 @@ ${chalk.bold('See also')}
|
|
|
95
95
|
primo deploy Stand up a new hosted Primo server
|
|
96
96
|
primo login Authenticate with a hosted Primo server
|
|
97
97
|
`)
|
|
98
|
-
.action((server, options) => push_site({ ...options, server: server || options.server }));
|
|
98
|
+
.action(async (server, options) => { await push_site({ ...options, server: server || options.server }); });
|
|
99
99
|
program
|
|
100
100
|
.command('pull [server] [dir]')
|
|
101
101
|
.description('Pull entire server (all sites + library) to local files (defaults to ./<server-hostname>)')
|
|
@@ -169,4 +169,13 @@ program.on('command:*', (operands) => {
|
|
|
169
169
|
console.error('');
|
|
170
170
|
process.exit(1);
|
|
171
171
|
});
|
|
172
|
-
|
|
172
|
+
// parseAsync so async action handlers are awaited inside commander's
|
|
173
|
+
// lifecycle — with plain parse() a rejected handler becomes an unhandled
|
|
174
|
+
// rejection (ugly stack, engine-dependent exit) instead of the clean
|
|
175
|
+
// message + exit(1) below.
|
|
176
|
+
program.parseAsync().catch((error) => {
|
|
177
|
+
console.error('');
|
|
178
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
179
|
+
console.error('');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|