primo-cli 0.1.2 → 0.1.4

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 (39) hide show
  1. package/README.md +113 -41
  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 +9 -5
  33. package/scripts/postinstall.js +1 -1
  34. package/dist/commands/export.d.ts +0 -8
  35. package/dist/commands/export.js +0 -163
  36. package/dist/commands/import.d.ts +0 -9
  37. package/dist/commands/import.js +0 -118
  38. package/dist/commands/publish.d.ts +0 -6
  39. package/dist/commands/publish.js +0 -239
@@ -1,22 +1,8 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
- // Auto-fix common issues without full validation output
5
- export async function normalize_site(site_dir) {
6
- const pages_dir = path.join(site_dir, 'pages');
7
- const index_path = path.join(pages_dir, 'index.yaml');
8
- try {
9
- let content = await fs.readFile(index_path, 'utf-8');
10
- const slug_index_pattern = /^slug:\s*index\s*$/m;
11
- if (slug_index_pattern.test(content)) {
12
- content = content.replace(slug_index_pattern, "slug: ''");
13
- await fs.writeFile(index_path, content, 'utf-8');
14
- }
15
- }
16
- catch {
17
- // File doesn't exist, skip
18
- }
19
- }
4
+ import { load as load_yaml } from 'js-yaml';
5
+ import { get_head_svelte_validation_error } from '../utils/head-svelte.js';
20
6
  const VALID_FIELD_TYPES = [
21
7
  'text',
22
8
  'rich-text',
@@ -38,6 +24,184 @@ const VALID_FIELD_TYPES = [
38
24
  'date',
39
25
  'info'
40
26
  ];
27
+ const MAX_FIELDS_FILE_BYTES = 256 * 1024;
28
+ const MAX_TOTAL_FIELDS = 5000;
29
+ // Warn-only normalization - logs issues but doesn't modify files
30
+ export async function normalize_site(site_dir) {
31
+ const warnings = [];
32
+ const pages_dir = path.join(site_dir, 'pages');
33
+ const homepage_path = path.join(pages_dir, 'index.yaml');
34
+ try {
35
+ await fs.access(homepage_path);
36
+ }
37
+ catch {
38
+ throw new Error(`Missing required homepage file: pages/index.yaml`);
39
+ }
40
+ // Note: Missing IDs are normal for new entities - they will be assigned during import.
41
+ // We only log the warnings array if other checks added warnings.
42
+ if (warnings.length > 0) {
43
+ for (const warning of warnings) {
44
+ console.log(chalk.yellow(` ⚠ ${warning}`));
45
+ }
46
+ }
47
+ }
48
+ async function parse_fields_file(file_path) {
49
+ const stat = await fs.stat(file_path);
50
+ if (stat.size > MAX_FIELDS_FILE_BYTES) {
51
+ throw new Error(`fields file is too large (${stat.size} bytes). This usually indicates corrupted duplicated schema data.`);
52
+ }
53
+ const content = await fs.readFile(file_path, 'utf-8');
54
+ const parsed = load_yaml(content);
55
+ const total_fields = count_fields_recursive(get_fields_array(parsed));
56
+ if (total_fields > MAX_TOTAL_FIELDS) {
57
+ throw new Error(`fields file defines too many fields (${total_fields}). This usually indicates corrupted duplicated schema data.`);
58
+ }
59
+ return parsed;
60
+ }
61
+ function get_field_id(field) {
62
+ return field?._id || field?.id;
63
+ }
64
+ function get_fields_array(data) {
65
+ if (Array.isArray(data)) {
66
+ return data;
67
+ }
68
+ if (data && typeof data === 'object' && Array.isArray(data.fields)) {
69
+ return data.fields;
70
+ }
71
+ return [];
72
+ }
73
+ function count_fields_recursive(fields) {
74
+ let count = 0;
75
+ for (const field of fields) {
76
+ count += 1;
77
+ if (Array.isArray(field?.subfields)) {
78
+ count += count_fields_recursive(field.subfields);
79
+ }
80
+ }
81
+ return count;
82
+ }
83
+ function is_plain_object(value) {
84
+ return !!value && typeof value === 'object' && !Array.isArray(value);
85
+ }
86
+ function validate_field_config(field, field_name, file_path) {
87
+ const errors = [];
88
+ if (!Object.prototype.hasOwnProperty.call(field, 'config')) {
89
+ return errors;
90
+ }
91
+ const config = field.config;
92
+ if (config === null || config === undefined) {
93
+ return errors;
94
+ }
95
+ if (typeof config === 'string') {
96
+ if (config.trim() === '') {
97
+ errors.push({
98
+ file: file_path,
99
+ field: field_name,
100
+ message: 'Deprecated empty string "config". Remove it or replace it with a config object.',
101
+ severity: 'warning'
102
+ });
103
+ return errors;
104
+ }
105
+ errors.push({
106
+ file: file_path,
107
+ field: field_name,
108
+ message: '"config" must be an object, null, or omitted',
109
+ severity: 'error'
110
+ });
111
+ return errors;
112
+ }
113
+ if (!is_plain_object(config)) {
114
+ errors.push({
115
+ file: file_path,
116
+ field: field_name,
117
+ message: '"config" must be an object, null, or omitted',
118
+ severity: 'error'
119
+ });
120
+ }
121
+ return errors;
122
+ }
123
+ function collect_field_names(fields, names = new Set()) {
124
+ for (const field of fields) {
125
+ if (field?.name) {
126
+ names.add(field.name);
127
+ }
128
+ if (Array.isArray(field?.subfields)) {
129
+ collect_field_names(field.subfields, names);
130
+ }
131
+ }
132
+ return names;
133
+ }
134
+ function validate_field_recursive(field, field_path, file_path, field_ids, field_names) {
135
+ const errors = [];
136
+ const field_id = get_field_id(field);
137
+ const field_name = field.name || field_id || '(unnamed)';
138
+ const display_name = field_path || field_name;
139
+ // IDs are optional - they're assigned by the system during import.
140
+ // Only check for duplicates if IDs are present.
141
+ if (field_id) {
142
+ if (field_ids.has(field_id)) {
143
+ errors.push({
144
+ file: file_path,
145
+ field: display_name,
146
+ message: `Duplicate field ID: "${field_id}"`,
147
+ severity: 'error'
148
+ });
149
+ }
150
+ field_ids.add(field_id);
151
+ }
152
+ if (!field.name) {
153
+ errors.push({
154
+ file: file_path,
155
+ field: display_name,
156
+ message: 'Missing required "name" field',
157
+ severity: 'error'
158
+ });
159
+ }
160
+ if (!field.label && field.type !== 'info') {
161
+ errors.push({
162
+ file: file_path,
163
+ field: display_name,
164
+ message: 'Missing "label" field',
165
+ severity: 'warning'
166
+ });
167
+ }
168
+ if (!field.type) {
169
+ errors.push({
170
+ file: file_path,
171
+ field: display_name,
172
+ message: 'Missing required "type" field',
173
+ severity: 'error'
174
+ });
175
+ return errors;
176
+ }
177
+ if (!VALID_FIELD_TYPES.includes(field.type)) {
178
+ errors.push({
179
+ file: file_path,
180
+ field: display_name,
181
+ message: `Invalid field type: "${field.type}". Valid types: ${VALID_FIELD_TYPES.join(', ')}`,
182
+ severity: 'error'
183
+ });
184
+ }
185
+ errors.push(...validate_field_config(field, display_name, file_path));
186
+ if (field.type === 'select') {
187
+ errors.push(...validate_select_field(field, display_name, file_path));
188
+ }
189
+ if (field.parent && !field_names.has(field.parent)) {
190
+ errors.push({
191
+ file: file_path,
192
+ field: display_name,
193
+ message: `Parent field "${field.parent}" not found`,
194
+ severity: 'error'
195
+ });
196
+ }
197
+ if (Array.isArray(field.subfields)) {
198
+ for (const subfield of field.subfields) {
199
+ const subfield_name = subfield?.name || get_field_id(subfield) || '(unnamed subfield)';
200
+ errors.push(...validate_field_recursive(subfield, `${display_name}.${subfield_name}`, file_path, field_ids, field_names));
201
+ }
202
+ }
203
+ return errors;
204
+ }
41
205
  export async function validate_site(options) {
42
206
  const errors = [];
43
207
  const warnings = [];
@@ -51,6 +215,8 @@ export async function validate_site(options) {
51
215
  console.log(chalk.red(`✖ Directory not found: ${site_dir}`));
52
216
  process.exit(1);
53
217
  }
218
+ // Check for issues (warn-only, no modifications)
219
+ await normalize_site(site_dir);
54
220
  // Validate blocks
55
221
  const blocks_errors = await validate_blocks(site_dir);
56
222
  errors.push(...blocks_errors.filter(e => e.severity === 'error'));
@@ -63,6 +229,10 @@ export async function validate_site(options) {
63
229
  const site_errors = await validate_site_fields(site_dir);
64
230
  errors.push(...site_errors.filter(e => e.severity === 'error'));
65
231
  warnings.push(...site_errors.filter(e => e.severity === 'warning'));
232
+ // Validate site head
233
+ const site_head_errors = await validate_site_head(site_dir);
234
+ errors.push(...site_head_errors.filter(e => e.severity === 'error'));
235
+ warnings.push(...site_head_errors.filter(e => e.severity === 'warning'));
66
236
  // Validate pages
67
237
  const pages_errors = await validate_pages(site_dir);
68
238
  errors.push(...pages_errors.filter(e => e.severity === 'error'));
@@ -110,8 +280,10 @@ async function validate_blocks(site_dir) {
110
280
  if (!stat.isDirectory())
111
281
  continue;
112
282
  // Check for required files - read actual directory listing to handle case-insensitive filesystems
113
- const fields_path = path.join(block_dir, 'fields.json');
114
283
  const block_files = await fs.readdir(block_dir);
284
+ const fields_path = path.join(block_dir, 'fields.yaml');
285
+ const config_path = path.join(block_dir, 'config.yaml');
286
+ const content_path = path.join(block_dir, 'content.yaml');
115
287
  // Check for component.svelte with correct casing
116
288
  const component_file = block_files.find(f => f.toLowerCase() === 'component.svelte');
117
289
  if (!component_file) {
@@ -128,28 +300,62 @@ async function validate_blocks(site_dir) {
128
300
  severity: 'error'
129
301
  });
130
302
  }
303
+ // config.yaml is required (holds _id and display name).
131
304
  try {
132
- const fields_data = await fs.readFile(fields_path, 'utf-8');
133
- let fields_json;
134
- try {
135
- fields_json = JSON.parse(fields_data);
305
+ const raw = await fs.readFile(config_path, 'utf-8');
306
+ const config = load_yaml(raw);
307
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
308
+ errors.push({
309
+ file: `blocks/${block_name}/config.yaml`,
310
+ message: 'Must be a YAML object with at least a `name` key.',
311
+ severity: 'error'
312
+ });
136
313
  }
137
- catch {
314
+ else if (!config.name) {
138
315
  errors.push({
139
- file: `blocks/${block_name}/fields.json`,
140
- message: 'Invalid JSON syntax',
316
+ file: `blocks/${block_name}/config.yaml`,
317
+ message: 'Missing required "name" field.',
141
318
  severity: 'error'
142
319
  });
143
- continue;
144
320
  }
145
- // Validate fields structure
146
- const field_errors = validate_fields(fields_json, `blocks/${block_name}/fields.json`);
147
- errors.push(...field_errors);
148
321
  }
149
322
  catch {
150
323
  errors.push({
151
- file: `blocks/${block_name}/`,
152
- message: 'Missing fields.json file',
324
+ file: `blocks/${block_name}/config.yaml`,
325
+ message: 'Missing config.yaml or invalid YAML syntax. Each block needs a config.yaml with `_id` (system-stamped) and `name`.',
326
+ severity: 'error'
327
+ });
328
+ }
329
+ // fields.yaml is a bare top-level list of field definitions.
330
+ try {
331
+ const relative_fields_path = `blocks/${block_name}/fields.yaml`;
332
+ const fields_json = await parse_fields_file(fields_path);
333
+ if (!Array.isArray(fields_json)) {
334
+ errors.push({
335
+ file: relative_fields_path,
336
+ message: 'Must be a bare list of field definitions (no `fields:` wrapper). Start the file with `- name: ...`.',
337
+ severity: 'error'
338
+ });
339
+ }
340
+ else {
341
+ errors.push(...validate_fields(fields_json, relative_fields_path));
342
+ }
343
+ }
344
+ catch {
345
+ errors.push({
346
+ file: `blocks/${block_name}/fields.yaml`,
347
+ message: 'Missing fields.yaml file or invalid YAML syntax',
348
+ severity: 'error'
349
+ });
350
+ }
351
+ // content.yaml is required for blocks — it seeds the editor sidebar.
352
+ try {
353
+ await fs.access(content_path);
354
+ }
355
+ catch {
356
+ errors.push({
357
+ file: `blocks/${block_name}/content.yaml`,
358
+ message: 'Missing content.yaml. Every block needs default values (use `{}` if there are no defaults yet).',
153
359
  severity: 'error'
154
360
  });
155
361
  }
@@ -177,207 +383,209 @@ async function validate_page_types(site_dir) {
177
383
  const stat = await fs.stat(page_type_dir);
178
384
  if (!stat.isDirectory())
179
385
  continue;
180
- const config_path = path.join(page_type_dir, 'config.json');
386
+ const config_path = path.join(page_type_dir, 'config.yaml');
387
+ const fields_path = path.join(page_type_dir, 'fields.yaml');
388
+ const layout_path = path.join(page_type_dir, 'layout.yaml');
181
389
  try {
182
390
  const config_data = await fs.readFile(config_path, 'utf-8');
183
391
  let config;
184
392
  try {
185
- config = JSON.parse(config_data);
393
+ config = load_yaml(config_data);
186
394
  }
187
395
  catch {
188
396
  errors.push({
189
- file: `page-types/${page_type_name}/config.json`,
190
- message: 'Invalid JSON syntax',
397
+ file: `page-types/${page_type_name}/config.yaml`,
398
+ message: 'Invalid YAML syntax',
191
399
  severity: 'error'
192
400
  });
193
401
  continue;
194
402
  }
195
403
  // Validate config has required fields
404
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
405
+ errors.push({
406
+ file: `page-types/${page_type_name}/config.yaml`,
407
+ message: 'Must be a YAML object',
408
+ severity: 'error'
409
+ });
410
+ continue;
411
+ }
196
412
  if (!config.name) {
197
413
  errors.push({
198
- file: `page-types/${page_type_name}/config.json`,
414
+ file: `page-types/${page_type_name}/config.yaml`,
199
415
  message: 'Missing "name" field',
200
416
  severity: 'error'
201
417
  });
202
418
  }
203
- // Validate page type fields if they exist
204
- if (config.fields) {
205
- const field_errors = validate_fields({ fields: config.fields }, `page-types/${page_type_name}/config.json`);
206
- errors.push(...field_errors);
419
+ if ('fields' in config) {
420
+ errors.push({
421
+ file: `page-types/${page_type_name}/config.yaml`,
422
+ message: 'Page-type fields no longer live in config.yaml — move them to a sibling fields.yaml as a bare list.',
423
+ severity: 'error'
424
+ });
207
425
  }
208
426
  }
209
427
  catch {
210
428
  errors.push({
211
429
  file: `page-types/${page_type_name}/`,
212
- message: 'Missing config.json file',
430
+ message: 'Missing config.yaml file',
213
431
  severity: 'error'
214
432
  });
215
433
  }
216
- }
217
- return errors;
218
- }
219
- async function validate_site_fields(site_dir) {
220
- const errors = [];
221
- const site_fields_path = path.join(site_dir, 'site/fields.json');
222
- try {
223
- const fields_data = await fs.readFile(site_fields_path, 'utf-8');
224
- let fields_json;
434
+ // fields.yaml is a sibling bare list (may be `[]`); absent is allowed.
435
+ try {
436
+ const relative_fields_path = `page-types/${page_type_name}/fields.yaml`;
437
+ const fields_json = await parse_fields_file(fields_path);
438
+ if (!Array.isArray(fields_json)) {
439
+ errors.push({
440
+ file: relative_fields_path,
441
+ message: 'Must be a bare list of field definitions (no `fields:` wrapper). Use `[]` if there are no fields.',
442
+ severity: 'error'
443
+ });
444
+ }
445
+ else if (fields_json.length > 0) {
446
+ errors.push(...validate_fields(fields_json, relative_fields_path));
447
+ }
448
+ }
449
+ catch (err) {
450
+ if (err?.code !== 'ENOENT') {
451
+ errors.push({
452
+ file: `page-types/${page_type_name}/fields.yaml`,
453
+ message: 'Invalid YAML syntax in fields.yaml',
454
+ severity: 'error'
455
+ });
456
+ }
457
+ }
458
+ // layout.yaml is required — comment-only stub is fine, but the file
459
+ // must exist so the page type's shared header/footer slots are
460
+ // discoverable.
225
461
  try {
226
- fields_json = JSON.parse(fields_data);
462
+ await fs.access(layout_path);
227
463
  }
228
464
  catch {
229
465
  errors.push({
230
- file: 'site/fields.json',
231
- message: 'Invalid JSON syntax',
466
+ file: `page-types/${page_type_name}/layout.yaml`,
467
+ message: 'Missing layout.yaml. Each page type needs one (use the comment-only stub if there are no shared header/footer sections yet).',
232
468
  severity: 'error'
233
469
  });
234
- return errors;
235
470
  }
236
- // site/fields.json must be a plain array, not wrapped in an object
471
+ }
472
+ return errors;
473
+ }
474
+ async function validate_site_fields(site_dir) {
475
+ const errors = [];
476
+ const site_fields_path = path.join(site_dir, 'site', 'fields.yaml');
477
+ try {
478
+ const relative_path = 'site/fields.yaml';
479
+ const fields_json = await parse_fields_file(site_fields_path);
480
+ // site fields must be a plain array, not wrapped in an object
237
481
  if (!Array.isArray(fields_json)) {
238
482
  errors.push({
239
- file: 'site/fields.json',
483
+ file: relative_path,
240
484
  message: 'Must be a plain array (e.g., []) not wrapped in an object. The import will fail.',
241
485
  severity: 'error'
242
486
  });
243
487
  return errors;
244
488
  }
245
- // Validate as array of fields
489
+ // Validate as bare array of fields
246
490
  if (fields_json.length > 0) {
247
- const field_errors = validate_fields({ fields: fields_json }, 'site/fields.json');
491
+ const field_errors = validate_fields(fields_json, relative_path);
248
492
  errors.push(...field_errors);
249
493
  }
250
494
  }
251
495
  catch {
252
496
  errors.push({
253
- file: 'site/',
254
- message: 'Missing fields.json file',
497
+ file: 'site/fields.yaml',
498
+ message: 'Missing fields.yaml file or invalid YAML syntax',
255
499
  severity: 'warning'
256
500
  });
257
501
  }
258
502
  return errors;
259
503
  }
504
+ async function validate_head_svelte_file(site_dir, relative_path) {
505
+ const head_path = path.join(site_dir, relative_path);
506
+ try {
507
+ const head_content = await fs.readFile(head_path, 'utf-8');
508
+ const message = get_head_svelte_validation_error(head_content, relative_path);
509
+ if (message) {
510
+ return [{
511
+ file: relative_path,
512
+ message,
513
+ severity: 'error'
514
+ }];
515
+ }
516
+ }
517
+ catch (error) {
518
+ if (error?.code === 'ENOENT') {
519
+ return [];
520
+ }
521
+ return [{
522
+ file: relative_path,
523
+ message: `Failed to read file: ${error}`,
524
+ severity: 'error'
525
+ }];
526
+ }
527
+ return [];
528
+ }
529
+ async function validate_site_head(site_dir) {
530
+ const errors = [];
531
+ errors.push(...await validate_head_svelte_file(site_dir, 'site/head.svelte'));
532
+ // Per-page-type head fragments use the same rules as the site head.
533
+ const page_types_dir = path.join(site_dir, 'page-types');
534
+ let entries = [];
535
+ try {
536
+ entries = await fs.readdir(page_types_dir);
537
+ }
538
+ catch (error) {
539
+ if (error?.code !== 'ENOENT')
540
+ throw error;
541
+ }
542
+ for (const name of entries) {
543
+ errors.push(...await validate_head_svelte_file(site_dir, `page-types/${name}/head.svelte`));
544
+ }
545
+ return errors;
546
+ }
260
547
  function validate_fields(fields_json, file_path) {
261
548
  const errors = [];
262
- if (!fields_json.fields || !Array.isArray(fields_json.fields)) {
549
+ // Accept a bare list (canonical fields.yaml shape) or an object with a
550
+ // `fields:` array (page/site doc passing its already-parsed fields list).
551
+ const fields_array = get_fields_array(fields_json);
552
+ if (fields_array.length === 0 && !Array.isArray(fields_json) && !is_plain_object(fields_json)) {
263
553
  errors.push({
264
554
  file: file_path,
265
- message: 'Missing or invalid "fields" array',
555
+ message: 'Expected a list of field definitions',
266
556
  severity: 'error'
267
557
  });
268
558
  return errors;
269
559
  }
270
560
  const field_ids = new Set();
271
- const field_names = new Set();
272
- const parent_names = new Set();
273
- // Collect all field names for parent validation
274
- for (const field of fields_json.fields) {
275
- if (field.name) {
276
- field_names.add(field.name);
277
- }
278
- }
279
- for (const field of fields_json.fields) {
280
- const field_name = field.name || field.id || '(unnamed)';
281
- // Check required properties
282
- if (!field.id) {
283
- errors.push({
284
- file: file_path,
285
- field: field_name,
286
- message: 'Missing required "id" field',
287
- severity: 'error'
288
- });
289
- }
290
- else {
291
- // Check for duplicate IDs
292
- if (field_ids.has(field.id)) {
293
- errors.push({
294
- file: file_path,
295
- field: field_name,
296
- message: `Duplicate field ID: "${field.id}"`,
297
- severity: 'error'
298
- });
299
- }
300
- field_ids.add(field.id);
301
- }
302
- if (!field.name) {
303
- errors.push({
304
- file: file_path,
305
- field: field_name,
306
- message: 'Missing required "name" field',
307
- severity: 'error'
308
- });
309
- }
310
- if (!field.label && field.type !== 'info') {
311
- errors.push({
312
- file: file_path,
313
- field: field_name,
314
- message: 'Missing "label" field',
315
- severity: 'warning'
316
- });
317
- }
318
- if (!field.type) {
319
- errors.push({
320
- file: file_path,
321
- field: field_name,
322
- message: 'Missing required "type" field',
323
- severity: 'error'
324
- });
325
- }
326
- else {
327
- // Check valid field type
328
- if (!VALID_FIELD_TYPES.includes(field.type)) {
329
- errors.push({
330
- file: file_path,
331
- field: field_name,
332
- message: `Invalid field type: "${field.type}". Valid types: ${VALID_FIELD_TYPES.join(', ')}`,
333
- severity: 'error'
334
- });
335
- }
336
- // Type-specific validation
337
- if (field.type === 'select') {
338
- const select_errors = validate_select_field(field, field_name, file_path);
339
- errors.push(...select_errors);
340
- }
341
- if (field.type === 'repeater' || field.type === 'group') {
342
- parent_names.add(field.name);
343
- }
344
- }
345
- // Validate parent reference
346
- if (field.parent) {
347
- if (!field_names.has(field.parent)) {
348
- errors.push({
349
- file: file_path,
350
- field: field_name,
351
- message: `Parent field "${field.parent}" not found`,
352
- severity: 'error'
353
- });
354
- }
355
- }
561
+ const field_names = collect_field_names(fields_array);
562
+ for (const field of fields_array) {
563
+ errors.push(...validate_field_recursive(field, field.name || get_field_id(field) || '(unnamed)', file_path, field_ids, field_names));
356
564
  }
357
565
  return errors;
358
566
  }
359
567
  function validate_select_field(field, field_name, file_path) {
360
568
  const errors = [];
361
- if (!field.options || !field.options.options) {
569
+ if (!is_plain_object(field.config) || !field.config.options) {
362
570
  errors.push({
363
571
  file: file_path,
364
572
  field: field_name,
365
- message: 'Select field missing "options.options" array',
573
+ message: 'Select field missing "config.options" array',
366
574
  severity: 'error'
367
575
  });
368
576
  return errors;
369
577
  }
370
- if (!Array.isArray(field.options.options)) {
578
+ if (!Array.isArray(field.config.options)) {
371
579
  errors.push({
372
580
  file: file_path,
373
581
  field: field_name,
374
- message: 'Select field "options.options" must be an array',
582
+ message: 'Select field "config.options" must be an array',
375
583
  severity: 'error'
376
584
  });
377
585
  return errors;
378
586
  }
379
- for (let i = 0; i < field.options.options.length; i++) {
380
- const option = field.options.options[i];
587
+ for (let i = 0; i < field.config.options.length; i++) {
588
+ const option = field.config.options[i];
381
589
  if (!option.label) {
382
590
  errors.push({
383
591
  file: file_path,
@@ -408,6 +616,7 @@ function validate_select_field(field, field_name, file_path) {
408
616
  async function validate_pages(site_dir) {
409
617
  const errors = [];
410
618
  const pages_dir = path.join(site_dir, 'pages');
619
+ const homepage_path = path.join(pages_dir, 'index.yaml');
411
620
  try {
412
621
  await fs.access(pages_dir);
413
622
  }
@@ -418,19 +627,28 @@ async function validate_pages(site_dir) {
418
627
  severity: 'warning'
419
628
  }];
420
629
  }
630
+ try {
631
+ await fs.access(homepage_path);
632
+ }
633
+ catch {
634
+ errors.push({
635
+ file: 'pages/index.yaml',
636
+ message: 'Missing required homepage file. Primo requires pages/index.yaml for the site root.',
637
+ severity: 'error'
638
+ });
639
+ }
421
640
  // Recursively find all YAML files in pages directory
422
641
  const yaml_files = await find_yaml_files(pages_dir, 'pages');
423
642
  for (const yaml_file of yaml_files) {
424
643
  const file_path = path.join(site_dir, yaml_file);
425
644
  try {
426
- let content = await fs.readFile(file_path, 'utf-8');
427
- // Auto-fix: normalize homepage slug from "index" to ""
428
- if (yaml_file === 'pages/index.yaml' || yaml_file === 'pages\\index.yaml') {
429
- const slug_index_pattern = /^slug:\s*index\s*$/m;
430
- if (slug_index_pattern.test(content)) {
431
- content = content.replace(slug_index_pattern, "slug: ''");
432
- await fs.writeFile(file_path, content, 'utf-8');
433
- }
645
+ const content = await fs.readFile(file_path, 'utf-8');
646
+ if (/^slug:/m.test(content)) {
647
+ errors.push({
648
+ file: yaml_file,
649
+ message: 'Page slug is now derived from the file path. Remove the "slug:" field; it is ignored.',
650
+ severity: 'warning'
651
+ });
434
652
  }
435
653
  // Check for common mistakes
436
654
  if (content.includes('\nblocks:') || content.match(/^blocks:/m)) {
@@ -494,7 +712,7 @@ async function find_yaml_files(dir, relative_path) {
494
712
  const nested = await find_yaml_files(full_path, rel_path);
495
713
  files.push(...nested);
496
714
  }
497
- else if (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) {
715
+ else if (entry.name.endsWith('.yaml')) {
498
716
  files.push(rel_path);
499
717
  }
500
718
  }