primo-cli 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +111 -39
  2. package/dist/commands/build.js +488 -272
  3. package/dist/commands/deploy.d.ts +1 -1
  4. package/dist/commands/deploy.js +293 -141
  5. package/dist/commands/dev.d.ts +2 -0
  6. package/dist/commands/dev.js +2007 -150
  7. package/dist/commands/init.d.ts +2 -2
  8. package/dist/commands/init.js +65 -43
  9. package/dist/commands/login.d.ts +1 -2
  10. package/dist/commands/login.js +24 -6
  11. package/dist/commands/new.js +161 -274
  12. package/dist/commands/pull-library.d.ts +7 -0
  13. package/dist/commands/pull-library.js +92 -0
  14. package/dist/commands/pull.d.ts +0 -1
  15. package/dist/commands/pull.js +160 -165
  16. package/dist/commands/push-library.d.ts +7 -0
  17. package/dist/commands/push-library.js +88 -0
  18. package/dist/commands/push.d.ts +2 -0
  19. package/dist/commands/push.js +358 -51
  20. package/dist/commands/validate.d.ts +1 -1
  21. package/dist/commands/validate.js +379 -161
  22. package/dist/index.js +110 -20
  23. package/dist/utils/binary.js +1 -1
  24. package/dist/utils/format.d.ts +12 -0
  25. package/dist/utils/format.js +98 -0
  26. package/dist/utils/head-svelte.d.ts +2 -0
  27. package/dist/utils/head-svelte.js +53 -0
  28. package/dist/utils/server-config.d.ts +19 -0
  29. package/dist/utils/server-config.js +49 -0
  30. package/dist/utils/site-config.d.ts +11 -0
  31. package/dist/utils/site-config.js +14 -0
  32. package/package.json +8 -4
  33. package/dist/commands/export.d.ts +0 -8
  34. package/dist/commands/export.js +0 -163
  35. package/dist/commands/import.d.ts +0 -9
  36. package/dist/commands/import.js +0 -118
  37. package/dist/commands/publish.d.ts +0 -6
  38. package/dist/commands/publish.js +0 -239
@@ -3,81 +3,91 @@ import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import { load as load_yaml } from 'js-yaml';
6
+ import { compile } from 'svelte/compiler';
7
+ import * as esbuild from 'esbuild';
8
+ import { fileURLToPath } from 'url';
9
+ import { read_site_config, SITE_CONFIG_FILE } from '../utils/site-config.js';
10
+ import { validate_head_svelte_content } from '../utils/head-svelte.js';
11
+ // CSS reset applied to all sites by default
12
+ const CSS_RESET = `*, *::before, *::after { box-sizing: border-box; }
13
+ * { margin: 0; }
14
+ body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
15
+ img, picture, video, canvas, svg { display: block; max-width: 100%; }
16
+ input, button, textarea, select { font: inherit; }
17
+ p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }`;
6
18
  export async function build_site(options) {
7
19
  const spinner = ora('Building site...').start();
8
20
  try {
9
21
  const site_dir = path.resolve(options.dir);
10
22
  const output_dir = path.resolve(options.output);
23
+ const temp_dir = path.join(site_dir, '.primo', 'build-temp');
11
24
  // Read site config
12
- const config_path = path.join(site_dir, 'primo.json');
13
25
  let config;
14
26
  try {
15
- const config_data = await fs.readFile(config_path, 'utf-8');
16
- config = JSON.parse(config_data);
27
+ config = await read_site_config(site_dir);
17
28
  }
18
29
  catch {
19
- spinner.fail('No primo.json found. Run `primo new` first.');
30
+ spinner.fail(`No ${SITE_CONFIG_FILE} found. Run \`primo new\` first.`);
20
31
  process.exit(1);
21
32
  }
22
- // Clean and create output directory
33
+ // Clean and create directories
23
34
  await fs.rm(output_dir, { recursive: true, force: true });
24
35
  await fs.mkdir(output_dir, { recursive: true });
25
- // Read head.svelte for global styles
36
+ await fs.rm(temp_dir, { recursive: true, force: true });
37
+ await fs.mkdir(temp_dir, { recursive: true });
38
+ // Read head.svelte for global head content
26
39
  let head_content = '';
27
40
  try {
28
41
  const head_path = path.join(site_dir, 'site', 'head.svelte');
29
42
  head_content = await fs.readFile(head_path, 'utf-8');
43
+ validate_head_svelte_content(head_content, 'site/head.svelte');
30
44
  }
31
- catch {
32
- // No head.svelte, that's fine
33
- }
34
- // Extract CSS from head.svelte
35
- let global_css = '';
36
- if (head_content) {
37
- const style_match = head_content.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
38
- if (style_match) {
39
- global_css = style_match[1];
45
+ catch (error) {
46
+ if (error?.code !== 'ENOENT') {
47
+ throw error;
40
48
  }
49
+ // No head.svelte, that's fine
41
50
  }
42
51
  // Find all pages
43
52
  const pages_dir = path.join(site_dir, 'pages');
44
- const pages = await find_pages(pages_dir);
45
- spinner.text = `Building ${pages.length} page${pages.length !== 1 ? 's' : ''}...`;
46
- // Collect all CSS from blocks
47
- const block_css_cache = new Map();
48
- const all_css = [global_css];
53
+ const page_files = await find_pages(pages_dir);
54
+ spinner.text = `Building ${page_files.length} page${page_files.length !== 1 ? 's' : ''}...`;
55
+ // Compile all blocks once and cache them
56
+ const block_cache = new Map();
57
+ // Cache layouts per page type
58
+ const layout_cache = new Map();
59
+ // Cache per-page-type head fragments (mirrors site head behavior; empty
60
+ // string = no head.svelte for that page type).
61
+ const page_type_head_cache = new Map();
62
+ // Load site data (fields and content)
63
+ const site_data = await load_site_data(site_dir);
49
64
  // Build each page
50
- for (const page_file of pages) {
65
+ for (const page_file of page_files) {
51
66
  const page_content = await fs.readFile(page_file, 'utf-8');
52
67
  const page = load_yaml(page_content);
53
- // Render page sections
54
- let page_html = '';
55
- for (const section of page.sections || []) {
56
- const block_name = section.block;
57
- const content = normalize_content(section.content);
58
- // Get or compile block
59
- let block_css = block_css_cache.get(block_name);
60
- if (block_css === undefined) {
61
- const result = await compile_block(site_dir, block_name);
62
- block_css = result.css;
63
- block_css_cache.set(block_name, block_css);
64
- if (block_css) {
65
- all_css.push(block_css);
66
- }
67
- }
68
- // Render block HTML
69
- const block_html = await render_block(site_dir, block_name, content);
70
- page_html += block_html;
68
+ const page_path = get_page_path_from_file(site_dir, page_file);
69
+ spinner.text = `Building ${page.name || page_path || 'home'}...`;
70
+ const result = await build_page({
71
+ page,
72
+ page_path,
73
+ site_dir,
74
+ temp_dir,
75
+ head_content,
76
+ site_name: config.name,
77
+ block_cache,
78
+ layout_cache,
79
+ page_type_head_cache,
80
+ site_data
81
+ });
82
+ if (result.error) {
83
+ console.log(chalk.yellow(` Warning: ${page.name}: ${result.error}`));
71
84
  }
72
- // Generate full HTML document
73
- const html = generate_html(config.name, page.name, page_html, all_css.join('\n'));
74
- // Determine output path
75
- const slug = page.slug || path.basename(page_file, '.yaml');
76
- const out_path = slug === '' || slug === 'index'
85
+ // Determine output path from the page file path.
86
+ const out_path = page_path === ''
77
87
  ? path.join(output_dir, 'index.html')
78
- : path.join(output_dir, slug, 'index.html');
88
+ : path.join(output_dir, page_path, 'index.html');
79
89
  await fs.mkdir(path.dirname(out_path), { recursive: true });
80
- await fs.writeFile(out_path, html);
90
+ await fs.writeFile(out_path, result.html);
81
91
  }
82
92
  // Copy uploads directory
83
93
  const uploads_src = path.join(site_dir, 'uploads');
@@ -88,14 +98,17 @@ export async function build_site(options) {
88
98
  catch {
89
99
  // No uploads directory, that's fine
90
100
  }
91
- spinner.succeed(`Built ${pages.length} page${pages.length !== 1 ? 's' : ''} to ${chalk.cyan(output_dir)}`);
101
+ // Clean up temp directory
102
+ await fs.rm(temp_dir, { recursive: true, force: true });
103
+ spinner.succeed(`Built ${page_files.length} page${page_files.length !== 1 ? 's' : ''} to ${chalk.cyan(output_dir)}`);
92
104
  console.log('');
93
105
  console.log(chalk.dim(' Preview locally:'));
94
106
  console.log(chalk.dim(` npx serve ${options.output}`));
95
107
  console.log('');
96
108
  console.log(chalk.dim(' Deploy:'));
97
- console.log(chalk.dim(' npx netlify deploy --prod --dir=' + options.output));
98
- console.log(chalk.dim(' npx vercel ' + options.output));
109
+ console.log(chalk.dim(` npx vercel deploy ${options.output} --prod`));
110
+ console.log(chalk.dim(` npx netlify deploy --prod --dir=${options.output}`));
111
+ console.log(chalk.dim(` npx wrangler pages deploy ${options.output}`));
99
112
  console.log('');
100
113
  }
101
114
  catch (error) {
@@ -106,224 +119,298 @@ export async function build_site(options) {
106
119
  process.exit(1);
107
120
  }
108
121
  }
109
- async function find_pages(pages_dir) {
110
- const pages = [];
111
- async function scan(dir) {
112
- let entries;
113
- try {
114
- entries = await fs.readdir(dir, { withFileTypes: true });
115
- }
116
- catch {
117
- return;
118
- }
119
- for (const entry of entries) {
120
- const full_path = path.join(dir, entry.name);
121
- if (entry.isDirectory()) {
122
- await scan(full_path);
123
- }
124
- else if (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) {
125
- pages.push(full_path);
126
- }
127
- }
128
- }
129
- await scan(pages_dir);
130
- return pages;
131
- }
132
- async function compile_block(site_dir, block_name) {
133
- const component_path = path.join(site_dir, 'blocks', block_name, 'component.svelte');
122
+ // Lazily load and validate a page type's head.svelte. Cached value is the raw
123
+ // fragment (may include <style>); empty string means "no head file present".
124
+ async function load_page_type_head(site_dir, page_type, cache) {
125
+ const cached = cache.get(page_type);
126
+ if (cached !== undefined)
127
+ return cached;
128
+ const head_path = path.join(site_dir, 'page-types', page_type, 'head.svelte');
129
+ let content = '';
134
130
  try {
135
- const source = await fs.readFile(component_path, 'utf-8');
136
- // Extract CSS from the component
137
- const style_match = source.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
138
- const css = style_match ? style_match[1] : '';
139
- return { css };
131
+ content = await fs.readFile(head_path, 'utf-8');
132
+ validate_head_svelte_content(content, `page-types/${page_type}/head.svelte`);
140
133
  }
141
- catch {
142
- return { css: '' };
134
+ catch (error) {
135
+ if (error?.code !== 'ENOENT')
136
+ throw error;
137
+ content = '';
143
138
  }
139
+ cache.set(page_type, content);
140
+ return content;
144
141
  }
145
- async function render_block(site_dir, block_name, content) {
146
- const component_path = path.join(site_dir, 'blocks', block_name, 'component.svelte');
142
+ async function build_page(options) {
143
+ const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data } = options;
147
144
  try {
148
- let source = await fs.readFile(component_path, 'utf-8');
149
- // Remove script tags for static render
150
- source = source.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
151
- // Remove style tags (CSS is collected separately)
152
- source = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
153
- // Simple template rendering - replace {variable} with content values
154
- let html = source.trim();
155
- // Handle {#if condition}...{/if} blocks
156
- html = process_if_blocks(html, content);
157
- // Handle {#each items as item}...{/each} blocks
158
- html = process_each_blocks(html, content);
159
- // Replace simple variable interpolations {variable}
160
- html = html.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (match, varName) => {
161
- const value = content[varName];
162
- if (value === undefined || value === null)
163
- return '';
164
- return escape_html(String(value));
165
- });
166
- // Replace property access {obj.prop} or {obj?.prop}
167
- html = html.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\??\.([\w.?]+)\}/g, (match, objName, propPath) => {
168
- const obj = content[objName];
169
- if (!obj || typeof obj !== 'object')
170
- return '';
171
- const value = get_nested_value(obj, propPath.replace(/\?/g, ''));
172
- if (value === undefined || value === null)
173
- return '';
174
- return escape_html(String(value));
175
- });
176
- // Replace {obj && obj.prop} patterns
177
- html = html.replace(/\{[^}]+&&\s*([^}]+)\}/g, (match, expr) => {
178
- // Try to evaluate simple property access
179
- const prop_match = expr.trim().match(/([a-zA-Z_][a-zA-Z0-9_]*)\.(\w+)/);
180
- if (prop_match) {
181
- const obj = content[prop_match[1]];
182
- if (obj && typeof obj === 'object') {
183
- const value = obj[prop_match[2]];
184
- if (value !== undefined && value !== null) {
185
- return escape_html(String(value));
186
- }
187
- }
145
+ const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
146
+ // Load layout for this page type
147
+ const page_type = page.page_type || 'default';
148
+ let layout = layout_cache.get(page_type);
149
+ if (layout === undefined) {
150
+ layout = await load_layout(site_dir, page_type);
151
+ layout_cache.set(page_type, layout);
152
+ }
153
+ // Page-type head is concatenated after the site head, mirroring server
154
+ // publish behavior (site.head + page_type.head).
155
+ const page_type_head = await load_page_type_head(site_dir, page_type, page_type_head_cache);
156
+ const combined_head_content = page_type_head ? `${head_content}\n${page_type_head}` : head_content;
157
+ // Combine header + page sections + footer
158
+ const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data);
159
+ const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data);
160
+ const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data);
161
+ const all_sections = [...header_sections, ...page_sections, ...footer_sections];
162
+ if (all_sections.length === 0) {
163
+ return { html: generate_empty_page(site_name, page.name, combined_head_content) };
164
+ }
165
+ const sections = all_sections;
166
+ // Compile each block and collect CSS
167
+ const all_css = [];
168
+ const section_components = [];
169
+ for (let i = 0; i < sections.length; i++) {
170
+ const section = sections[i];
171
+ const block_name = section.block;
172
+ // Check cache first
173
+ let compiled = block_cache.get(block_name);
174
+ if (!compiled) {
175
+ compiled = await compile_block(site_dir, block_name, temp_dir);
176
+ block_cache.set(block_name, compiled);
177
+ }
178
+ if (compiled.css) {
179
+ all_css.push(compiled.css);
188
180
  }
189
- return '';
181
+ // Create import and usage for this section
182
+ const component_name = `Section_${i}_${block_name.replace(/-/g, '_')}`;
183
+ section_components.push({
184
+ name: component_name,
185
+ block_name,
186
+ props: section.content || {}
187
+ });
188
+ }
189
+ // Create a page component that renders all sections
190
+ const page_component = generate_page_component(section_components, sections);
191
+ const page_component_path = path.join(temp_dir, `page_${page_build_id}.svelte`);
192
+ await fs.writeFile(page_component_path, page_component);
193
+ // Compile the page component
194
+ const page_source = await fs.readFile(page_component_path, 'utf-8');
195
+ const page_compiled = compile(page_source, {
196
+ generate: 'server',
197
+ filename: page_component_path,
198
+ css: 'external'
190
199
  });
191
- // Replace (obj && obj.prop) || 'default' patterns
192
- html = html.replace(/\{[^}]*\|\|\s*['"]([^'"]+)['"]\}/g, (match, defaultVal) => {
193
- // Check if the first part evaluates to something
194
- const var_match = match.match(/\{([a-zA-Z_][a-zA-Z0-9_]*)/);
195
- if (var_match) {
196
- const value = content[var_match[1]];
197
- if (value !== undefined && value !== null && value !== '') {
198
- return escape_html(String(value));
200
+ if (page_compiled.warnings.length > 0) {
201
+ for (const warning of page_compiled.warnings) {
202
+ if (!warning.message.includes('unused')) {
203
+ console.log(chalk.yellow(` Svelte warning: ${warning.message}`));
199
204
  }
200
205
  }
201
- return escape_html(defaultVal);
206
+ }
207
+ // Write compiled JS and bundle with esbuild
208
+ const compiled_path = path.join(temp_dir, `page_${page_build_id}.js`);
209
+ await fs.writeFile(compiled_path, page_compiled.js.code);
210
+ // Copy all block compiled JS files to temp for bundling
211
+ for (const section of sections) {
212
+ const block_js_path = path.join(temp_dir, `${section.block}.compiled.js`);
213
+ const cached = block_cache.get(section.block);
214
+ if (cached?.js) {
215
+ await fs.writeFile(block_js_path, cached.js);
216
+ }
217
+ }
218
+ // Bundle with esbuild - include svelte runtime
219
+ const bundle_path = path.join(temp_dir, `bundle_${page_build_id}.mjs`);
220
+ // Find where svelte is installed (could be in primo-cli's node_modules or globally)
221
+ const svelte_base = await find_svelte_path();
222
+ await esbuild.build({
223
+ entryPoints: [compiled_path],
224
+ bundle: true,
225
+ format: 'esm',
226
+ platform: 'node',
227
+ outfile: bundle_path,
228
+ logLevel: 'silent',
229
+ alias: {
230
+ 'svelte/internal/server': path.join(svelte_base, 'src/internal/server/index.js'),
231
+ 'svelte/internal/shared': path.join(svelte_base, 'src/internal/shared/index.js'),
232
+ 'svelte/internal/client': path.join(svelte_base, 'src/internal/client/index.js'),
233
+ 'svelte': path.join(svelte_base, 'src/index.js')
234
+ }
202
235
  });
203
- // Handle {@html content} - render raw HTML
204
- html = html.replace(/\{@html\s+([^}]+)\}/g, (match, expr) => {
205
- const var_name = expr.trim();
206
- const value = content[var_name];
207
- if (value === undefined || value === null)
208
- return '';
209
- return String(value); // Don't escape - it's raw HTML
236
+ // Import and render
237
+ const bundle_url = `file://${bundle_path}`;
238
+ const { default: PageComponent } = await import(bundle_url);
239
+ // Import render from svelte/server
240
+ const { render } = await import('svelte/server');
241
+ // Build props for all sections
242
+ const props = {};
243
+ sections.forEach((section, i) => {
244
+ props[`section_${i}_props`] = section.content || {};
210
245
  });
211
- // Clean up any remaining unresolved template expressions
212
- html = html.replace(/\{[^}]+\}/g, '');
213
- return html;
246
+ const rendered = render(PageComponent, { props });
247
+ // Extract CSS from combined head fragments (site + page-type).
248
+ let head_css = '';
249
+ let head_html = combined_head_content;
250
+ const style_matches = combined_head_content.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi);
251
+ const head_css_parts = [];
252
+ for (const m of style_matches) {
253
+ head_css_parts.push(m[1]);
254
+ }
255
+ if (head_css_parts.length > 0) {
256
+ head_css = head_css_parts.join('\n');
257
+ head_html = combined_head_content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
258
+ }
259
+ // Combine all CSS (reset first, then head, then blocks)
260
+ const combined_css = [CSS_RESET, head_css, ...all_css].filter(Boolean).join('\n');
261
+ // Generate final HTML
262
+ const title = page.name === 'Home' ? site_name : `${page.name} | ${site_name}`;
263
+ const html = `<!DOCTYPE html>
264
+ <html lang="en">
265
+ <head>
266
+ <meta charset="UTF-8">
267
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
268
+ <title>${escape_html(title)}</title>
269
+ ${head_html}
270
+ <style>
271
+ ${combined_css}
272
+ </style>
273
+ ${rendered.head || ''}
274
+ </head>
275
+ <body>
276
+ ${rendered.body || ''}
277
+ </body>
278
+ </html>`;
279
+ return { html };
214
280
  }
215
281
  catch (error) {
216
- console.log(chalk.yellow(` Warning: Could not render block "${block_name}": ${error}`));
217
- return `<!-- Block "${block_name}" could not be rendered -->`;
282
+ const error_msg = error instanceof Error ? error.message : String(error);
283
+ return {
284
+ html: generate_error_page(site_name, page.name, error_msg, head_content),
285
+ error: error_msg,
286
+ };
218
287
  }
219
288
  }
220
- function process_if_blocks(html, content) {
221
- // Handle {#if condition}...{:else}...{/if} and {#if condition}...{/if}
222
- const if_regex = /\{#if\s+([^}]+)\}([\s\S]*?)\{\/if\}/g;
223
- return html.replace(if_regex, (match, condition, inner) => {
224
- // Split by {:else} if present
225
- const parts = inner.split(/\{:else\}/);
226
- const if_content = parts[0];
227
- const else_content = parts[1] || '';
228
- // Evaluate condition
229
- const is_truthy = evaluate_condition(condition.trim(), content);
230
- return is_truthy ? if_content : else_content;
231
- });
232
- }
233
- function evaluate_condition(condition, content) {
234
- // Handle obj?.prop patterns
235
- const optional_match = condition.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\?\.(\w+)$/);
236
- if (optional_match) {
237
- const obj = content[optional_match[1]];
238
- if (!obj || typeof obj !== 'object')
239
- return false;
240
- return !!obj[optional_match[2]];
241
- }
242
- // Handle simple variable check
243
- const simple_match = condition.match(/^([a-zA-Z_][a-zA-Z0-9_]*)$/);
244
- if (simple_match) {
245
- const value = content[simple_match[1]];
246
- return !!value;
247
- }
248
- // Handle obj.prop patterns
249
- const prop_match = condition.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\.(\w+)$/);
250
- if (prop_match) {
251
- const obj = content[prop_match[1]];
252
- if (!obj || typeof obj !== 'object')
253
- return false;
254
- return !!obj[prop_match[2]];
289
+ async function compile_block(site_dir, block_name, temp_dir) {
290
+ const component_path = path.join(site_dir, 'blocks', block_name, 'component.svelte');
291
+ try {
292
+ const source = await fs.readFile(component_path, 'utf-8');
293
+ // Compile with Svelte
294
+ const compiled = compile(source, {
295
+ generate: 'server',
296
+ filename: component_path,
297
+ css: 'external',
298
+ name: block_name.replace(/-/g, '_')
299
+ });
300
+ // Extract CSS
301
+ const css = compiled.css?.code || '';
302
+ // Write compiled JS for importing
303
+ const output_path = path.join(temp_dir, `${block_name}.compiled.js`);
304
+ await fs.writeFile(output_path, compiled.js.code);
305
+ return { js: compiled.js.code, css };
255
306
  }
256
- // Default to checking if any referenced variable is truthy
257
- const var_match = condition.match(/([a-zA-Z_][a-zA-Z0-9_]*)/);
258
- if (var_match) {
259
- return !!content[var_match[1]];
307
+ catch (error) {
308
+ console.log(chalk.yellow(` Warning: Could not compile block "${block_name}": ${error}`));
309
+ return { js: '', css: '' };
260
310
  }
261
- return false;
262
311
  }
263
- function process_each_blocks(html, content) {
264
- // Handle {#each items as item}...{/each}
265
- const each_regex = /\{#each\s+([^\s]+)\s+as\s+([^}]+)\}([\s\S]*?)\{\/each\}/g;
266
- return html.replace(each_regex, (match, arrayName, itemName, inner) => {
267
- const array = content[arrayName.trim()];
268
- if (!Array.isArray(array))
269
- return '';
270
- // Handle "item, index" pattern
271
- const [item_var, index_var] = itemName.split(',').map((s) => s.trim());
272
- return array.map((item, index) => {
273
- let result = inner;
274
- // Replace {item.prop} patterns
275
- if (typeof item === 'object' && item !== null) {
276
- result = result.replace(new RegExp(`\\{${item_var}\\.(\\w+)\\}`, 'g'), (_, prop) => {
277
- const value = item[prop];
278
- if (value === undefined || value === null)
279
- return '';
280
- return escape_html(String(value));
281
- });
282
- // Replace {@html item.prop} patterns
283
- result = result.replace(new RegExp(`\\{@html\\s+${item_var}\\.(\\w+)\\}`, 'g'), (_, prop) => {
284
- const value = item[prop];
285
- if (value === undefined || value === null)
286
- return '';
287
- return String(value);
288
- });
289
- }
290
- // Replace {item} if item is a primitive
291
- if (typeof item !== 'object') {
292
- result = result.replace(new RegExp(`\\{${item_var}\\}`, 'g'), escape_html(String(item)));
293
- }
294
- // Replace index variable if present
295
- if (index_var) {
296
- result = result.replace(new RegExp(`\\{${index_var}\\}`, 'g'), String(index));
297
- }
298
- return result;
299
- }).join('');
300
- });
312
+ function generate_page_component(components, sections) {
313
+ const imports = sections.map((section, i) => {
314
+ const safe_name = section.block.replace(/-/g, '_');
315
+ return `import Section_${i} from './${section.block}.compiled.js'`;
316
+ }).join('\n');
317
+ const props_declarations = sections.map((_, i) => {
318
+ return `section_${i}_props = {}`;
319
+ }).join(',\n\t');
320
+ const section_renders = sections.map((_, i) => {
321
+ return `<Section_${i} {...section_${i}_props} />`;
322
+ }).join('\n\t\t');
323
+ return `<script module>
324
+ ${imports}
325
+ </script>
326
+
327
+ <script>
328
+ let {
329
+ ${props_declarations}
330
+ } = $props()
331
+ </script>
332
+
333
+ <main>
334
+ ${section_renders}
335
+ </main>`;
301
336
  }
302
- function get_nested_value(obj, path) {
303
- const parts = path.split('.');
304
- let current = obj;
305
- for (const part of parts) {
306
- if (current === null || current === undefined)
307
- return undefined;
308
- if (typeof current !== 'object')
309
- return undefined;
310
- current = current[part];
337
+ function generate_empty_page(site_name, page_name, head_content) {
338
+ const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
339
+ return `<!DOCTYPE html>
340
+ <html lang="en">
341
+ <head>
342
+ <meta charset="UTF-8">
343
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
344
+ <title>${escape_html(title)}</title>
345
+ <style>${CSS_RESET}</style>
346
+ ${head_content}
347
+ </head>
348
+ <body>
349
+ </body>
350
+ </html>`;
351
+ }
352
+ function generate_error_page(site_name, page_name, error, head_content) {
353
+ const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
354
+ // Extract just the CSS from head_content
355
+ let head_css = '';
356
+ const style_match = head_content.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
357
+ if (style_match) {
358
+ head_css = style_match[1];
311
359
  }
312
- return current;
360
+ return `<!DOCTYPE html>
361
+ <html lang="en">
362
+ <head>
363
+ <meta charset="UTF-8">
364
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
365
+ <title>${escape_html(title)}</title>
366
+ <style>
367
+ ${CSS_RESET}
368
+ ${head_css}
369
+ </style>
370
+ </head>
371
+ <body>
372
+ <div style="padding: 2rem; text-align: center;">
373
+ <h1>Build Error</h1>
374
+ <p style="color: #888;">${escape_html(error)}</p>
375
+ </div>
376
+ </body>
377
+ </html>`;
313
378
  }
314
- function normalize_content(content) {
315
- // Some content values come as arrays (from CMS), normalize to single values
316
- const normalized = {};
317
- for (const [key, value] of Object.entries(content)) {
318
- if (Array.isArray(value) && value.length > 0) {
319
- // Use first item if it's an array of primitives or objects
320
- normalized[key] = value[0];
379
+ async function find_pages(pages_dir) {
380
+ const pages = [];
381
+ async function scan(dir) {
382
+ let entries;
383
+ try {
384
+ entries = await fs.readdir(dir, { withFileTypes: true });
321
385
  }
322
- else {
323
- normalized[key] = value;
386
+ catch {
387
+ return;
388
+ }
389
+ for (const entry of entries) {
390
+ const full_path = path.join(dir, entry.name);
391
+ if (entry.isDirectory()) {
392
+ await scan(full_path);
393
+ }
394
+ else if (entry.name.endsWith('.yaml')) {
395
+ pages.push(full_path);
396
+ }
324
397
  }
325
398
  }
326
- return normalized;
399
+ await scan(pages_dir);
400
+ return pages;
401
+ }
402
+ function get_page_path_from_file(site_dir, page_file) {
403
+ const pages_dir = path.join(site_dir, 'pages');
404
+ let relative_path = path.relative(pages_dir, page_file);
405
+ relative_path = relative_path.replaceAll('\\', '/');
406
+ relative_path = relative_path.replace(/\.yaml$/i, '');
407
+ if (relative_path === 'index') {
408
+ return '';
409
+ }
410
+ if (relative_path.endsWith('/index')) {
411
+ return relative_path.slice(0, -'/index'.length);
412
+ }
413
+ return relative_path;
327
414
  }
328
415
  function escape_html(str) {
329
416
  return str
@@ -333,35 +420,8 @@ function escape_html(str) {
333
420
  .replace(/"/g, '&quot;')
334
421
  .replace(/'/g, '&#039;');
335
422
  }
336
- function generate_html(site_name, page_name, body, css) {
337
- const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
338
- return `<!DOCTYPE html>
339
- <html lang="en">
340
- <head>
341
- <meta charset="UTF-8">
342
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
343
- <title>${escape_html(title)}</title>
344
- <style>
345
- *, *::before, *::after {
346
- box-sizing: border-box;
347
- }
348
- body {
349
- margin: 0;
350
- font-family: system-ui, -apple-system, sans-serif;
351
- line-height: 1.5;
352
- }
353
- img {
354
- max-width: 100%;
355
- height: auto;
356
- }
357
- ${css}
358
- </style>
359
- </head>
360
- <body>
361
- ${body}
362
- </body>
363
- </html>
364
- `;
423
+ function safe_temp_id(value) {
424
+ return value.replace(/[^a-zA-Z0-9_-]/g, '_') || 'page';
365
425
  }
366
426
  async function copy_dir(src, dest) {
367
427
  await fs.mkdir(dest, { recursive: true });
@@ -377,3 +437,159 @@ async function copy_dir(src, dest) {
377
437
  }
378
438
  }
379
439
  }
440
+ async function load_layout(site_dir, page_type) {
441
+ const layout_path = path.join(site_dir, 'page-types', page_type, 'layout.yaml');
442
+ try {
443
+ const content = await fs.readFile(layout_path, 'utf-8');
444
+ return load_yaml(content);
445
+ }
446
+ catch {
447
+ // No layout file, return empty layout
448
+ return {};
449
+ }
450
+ }
451
+ async function resolve_layout_sections(sections, site_dir, site_data) {
452
+ // For layout sections without content, load from block's content.yaml
453
+ const resolved = [];
454
+ for (const section of sections) {
455
+ let content;
456
+ if (section.content && Object.keys(section.content).length > 0) {
457
+ content = section.content;
458
+ }
459
+ else {
460
+ // Try to load default content from block's content.yaml
461
+ content = await load_block_defaults(site_dir, section.block);
462
+ }
463
+ // Resolve any site-field references in the content
464
+ const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
465
+ resolved.push({ ...section, content: resolved_content });
466
+ }
467
+ return resolved;
468
+ }
469
+ async function resolve_page_sections(sections, site_dir, site_data) {
470
+ // Resolve site-field references in page sections
471
+ const resolved = [];
472
+ for (const section of sections) {
473
+ const content = section.content || {};
474
+ const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
475
+ resolved.push({ ...section, content: resolved_content });
476
+ }
477
+ return resolved;
478
+ }
479
+ async function load_block_defaults(site_dir, block_name) {
480
+ const content_path = path.join(site_dir, 'blocks', block_name, 'content.yaml');
481
+ try {
482
+ const content = await fs.readFile(content_path, 'utf-8');
483
+ return load_yaml(content) || {};
484
+ }
485
+ catch {
486
+ return {};
487
+ }
488
+ }
489
+ async function load_fields_file(file_path) {
490
+ const data = await fs.readFile(file_path, 'utf-8');
491
+ return load_yaml(data);
492
+ }
493
+ function extract_fields_array(data) {
494
+ if (Array.isArray(data)) {
495
+ return data;
496
+ }
497
+ if (data && typeof data === 'object' && Array.isArray(data.fields)) {
498
+ return data.fields;
499
+ }
500
+ return [];
501
+ }
502
+ function get_field_id(field) {
503
+ return field._id || field.id;
504
+ }
505
+ async function load_site_data(site_dir) {
506
+ const fields_path = path.join(site_dir, 'site', 'fields.yaml');
507
+ const content_path = path.join(site_dir, 'site', 'content.yaml');
508
+ let fields = [];
509
+ let content = {};
510
+ try {
511
+ fields = extract_fields_array(await load_fields_file(fields_path));
512
+ }
513
+ catch {
514
+ // No site fields defined
515
+ }
516
+ try {
517
+ const content_data = await fs.readFile(content_path, 'utf-8');
518
+ content = load_yaml(content_data) || {};
519
+ }
520
+ catch {
521
+ // No site content defined
522
+ }
523
+ return { fields, content };
524
+ }
525
+ async function load_block_fields(site_dir, block_name) {
526
+ const fields_path = path.join(site_dir, 'blocks', block_name, 'fields.yaml');
527
+ try {
528
+ return extract_fields_array(await load_fields_file(fields_path));
529
+ }
530
+ catch {
531
+ return [];
532
+ }
533
+ }
534
+ async function resolve_site_fields(site_dir, block_name, content, site_data) {
535
+ // Load block field definitions to check for site-field types
536
+ const block_fields = await load_block_fields(site_dir, block_name);
537
+ // Create maps for site field lookup
538
+ const site_field_id_map = new Map(); // ID -> name
539
+ const site_field_name_set = new Set(); // names that exist
540
+ for (const site_field of site_data.fields) {
541
+ const site_field_id = get_field_id(site_field);
542
+ if (site_field_id) {
543
+ site_field_id_map.set(site_field_id, site_field.name);
544
+ }
545
+ site_field_name_set.add(site_field.name);
546
+ }
547
+ // Resolve site-field references in content
548
+ const resolved = { ...content };
549
+ // For each block field that is type site-field, resolve its value
550
+ for (const field of block_fields) {
551
+ if (field.type !== 'site-field')
552
+ continue;
553
+ const field_ref = field.config?.field;
554
+ if (field_ref) {
555
+ // Field reference can be either a name or an ID
556
+ // Try as name first (new format)
557
+ if (site_field_name_set.has(field_ref) && site_data.content[field_ref] !== undefined) {
558
+ resolved[field.name] = site_data.content[field_ref];
559
+ continue;
560
+ }
561
+ // Try as ID (old format)
562
+ const site_field_name = site_field_id_map.get(field_ref);
563
+ if (site_field_name && site_data.content[site_field_name] !== undefined) {
564
+ resolved[field.name] = site_data.content[site_field_name];
565
+ continue;
566
+ }
567
+ }
568
+ // Fallback: if block field name matches a site field name, use that value
569
+ if (site_field_name_set.has(field.name) && site_data.content[field.name] !== undefined) {
570
+ resolved[field.name] = site_data.content[field.name];
571
+ }
572
+ }
573
+ return resolved;
574
+ }
575
+ async function find_svelte_path() {
576
+ // Try to find svelte in various locations
577
+ const possible_paths = [
578
+ // In primo-cli's node_modules (when npm linked or installed globally)
579
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '../../node_modules/svelte'),
580
+ // In the current project's node_modules
581
+ path.join(process.cwd(), 'node_modules/svelte'),
582
+ // Try resolving from this file's location
583
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../node_modules/svelte')
584
+ ];
585
+ for (const p of possible_paths) {
586
+ try {
587
+ await fs.access(p);
588
+ return p;
589
+ }
590
+ catch {
591
+ // Continue to next path
592
+ }
593
+ }
594
+ throw new Error('Could not find svelte package. Make sure svelte is installed.');
595
+ }