primo-cli 0.1.20 → 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.
@@ -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 all_sections = [...header_sections, ...page_sections, ...footer_sections];
168
- if (all_sections.length === 0) {
169
- return { html: generate_empty_page(site_name, page.name, combined_head_content) };
170
- }
171
- const sections = all_sections;
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
- const page_component = generate_page_component(section_components, sections);
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
- // Extract CSS from combined head fragments (site + page-type).
254
- let head_css = '';
255
- let head_html = combined_head_content;
256
- const style_matches = combined_head_content.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi);
257
- const head_css_parts = [];
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
- // Combine all CSS (reset first, then head, then blocks)
266
- const combined_css = [CSS_RESET, head_css, ...all_css].filter(Boolean).join('\n');
267
- // Generate final HTML
268
- const title = page.name === 'Home' ? site_name : `${page.name} | ${site_name}`;
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
- ${combined_css}
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 = sections.map((_, i) => {
324
- return `section_${i}_props = {}`;
325
- }).join(',\n\t');
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {