primo-cli 0.1.0

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.
@@ -0,0 +1,516 @@
1
+ import fs from 'fs/promises';
2
+ import { watch } from 'fs';
3
+ import path from 'path';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { spawn } from 'child_process';
7
+ import archiver from 'archiver';
8
+ import extract from 'extract-zip';
9
+ import { ensure_binary, ensure_data_dir } from '../utils/binary.js';
10
+ import { normalize_site } from './validate.js';
11
+ let cms_process = null;
12
+ let watchers = [];
13
+ let reimport_timeout = null;
14
+ let sync_interval = null;
15
+ let is_syncing = false;
16
+ let is_importing = false;
17
+ let is_cleaning_up = false;
18
+ // Track files written by sync to prevent watcher from re-pushing them
19
+ // Map of filepath -> mtime (ms) when we wrote it
20
+ const synced_files = new Map();
21
+ // Check if a port is in use
22
+ async function is_port_in_use(port) {
23
+ try {
24
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
25
+ method: 'GET',
26
+ signal: AbortSignal.timeout(500)
27
+ });
28
+ return response.ok;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ // Fetch with timeout helper
35
+ async function fetch_with_timeout(url, options = {}, timeout_ms = 10000) {
36
+ const controller = new AbortController();
37
+ const timeout_id = setTimeout(() => controller.abort(), timeout_ms);
38
+ try {
39
+ const response = await fetch(url, { ...options, signal: controller.signal });
40
+ return response;
41
+ }
42
+ finally {
43
+ clearTimeout(timeout_id);
44
+ }
45
+ }
46
+ // Kill process with escalation to SIGKILL
47
+ async function kill_process(proc) {
48
+ if (!proc || proc.killed)
49
+ return;
50
+ proc.kill('SIGTERM');
51
+ // Wait up to 3 seconds for graceful shutdown
52
+ const start = Date.now();
53
+ while (Date.now() - start < 3000) {
54
+ if (proc.killed || proc.exitCode !== null)
55
+ return;
56
+ await new Promise(resolve => setTimeout(resolve, 100));
57
+ }
58
+ // Force kill if still running
59
+ if (!proc.killed && proc.exitCode === null) {
60
+ proc.kill('SIGKILL');
61
+ }
62
+ }
63
+ export async function dev_server(options) {
64
+ const spinner = ora('Starting Pala...').start();
65
+ try {
66
+ const base_dir = path.resolve(options.dir);
67
+ // Check for server.json (multi-site mode) or primo.json (single-site mode)
68
+ const server_config_path = path.join(base_dir, 'server.json');
69
+ const site_config_path = path.join(base_dir, 'primo.json');
70
+ let server_config = {};
71
+ let sites = [];
72
+ let is_server_mode = false;
73
+ try {
74
+ const server_data = await fs.readFile(server_config_path, 'utf-8');
75
+ server_config = JSON.parse(server_data);
76
+ is_server_mode = true;
77
+ }
78
+ catch {
79
+ // No server.json, check for primo.json
80
+ }
81
+ let port = server_config.port || parseInt(options.port, 10);
82
+ // Find an available port
83
+ const max_port_attempts = 10;
84
+ for (let i = 0; i < max_port_attempts; i++) {
85
+ if (!await is_port_in_use(port))
86
+ break;
87
+ port++;
88
+ if (i === max_port_attempts - 1) {
89
+ spinner.fail(`Ports ${port - max_port_attempts + 1}-${port} are all in use`);
90
+ process.exit(1);
91
+ }
92
+ }
93
+ if (is_server_mode) {
94
+ // Auto-discover sites in subdirectories
95
+ spinner.text = 'Discovering sites...';
96
+ sites = await discover_sites(base_dir);
97
+ // Sites can be empty - the dashboard will show the site creation screen
98
+ }
99
+ else {
100
+ // Single site mode
101
+ try {
102
+ const config_data = await fs.readFile(site_config_path, 'utf-8');
103
+ const config = JSON.parse(config_data);
104
+ sites = [{ dir: base_dir, config }];
105
+ }
106
+ catch {
107
+ spinner.fail('No server.json or primo.json found. Run `primo new` first.');
108
+ process.exit(1);
109
+ }
110
+ }
111
+ // Ensure binary is installed
112
+ spinner.text = 'Checking palacms...';
113
+ const binary_path = await ensure_binary();
114
+ // Create data directory in project folder
115
+ const data_dir = await ensure_data_dir(base_dir);
116
+ spinner.text = 'Starting CMS...';
117
+ // Start the CMS binary with dev mode enabled
118
+ cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ env: { ...process.env, PALA_DEV_MODE: '1' }
121
+ });
122
+ // Capture stderr for errors
123
+ let stderr_output = '';
124
+ cms_process.stderr?.on('data', (data) => {
125
+ stderr_output += data.toString();
126
+ });
127
+ // Wait for CMS to be ready
128
+ const ready = await wait_for_ready(`http://127.0.0.1:${port}`, 30000);
129
+ if (!ready) {
130
+ spinner.fail('CMS failed to start');
131
+ if (stderr_output) {
132
+ console.log(chalk.red(stderr_output));
133
+ }
134
+ process.exit(1);
135
+ }
136
+ // Normalize and load all sites
137
+ spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
138
+ const api_url = `http://127.0.0.1:${port}`;
139
+ for (const site of sites) {
140
+ await normalize_site(site.dir);
141
+ await import_site_files(site.dir, api_url, site.config, port);
142
+ }
143
+ // Verify all sites are accessible before proceeding
144
+ spinner.text = 'Verifying sites...';
145
+ for (const site of sites) {
146
+ await verify_site_ready(api_url, site.config.site_id);
147
+ }
148
+ spinner.succeed('Pala running');
149
+ console.log('');
150
+ if (is_server_mode) {
151
+ console.log(` ${chalk.cyan('Dashboard:')} http://127.0.0.1:${port}/admin/dashboard`);
152
+ console.log('');
153
+ }
154
+ for (const site of sites) {
155
+ const host = site.config.host || `${site.config.name.toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`;
156
+ console.log(` ${chalk.cyan(site.config.name)}`);
157
+ console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
158
+ console.log(` ${chalk.dim('Preview:')} http://${host}/`);
159
+ }
160
+ if (sites.length > 0 || !is_server_mode) {
161
+ console.log('');
162
+ }
163
+ // Start watching for file changes
164
+ const dirs_to_watch = ['blocks', 'page-types', 'pages', 'site'];
165
+ const known_sites = new Set(sites.map(s => s.dir));
166
+ const setup_site_watchers = (site) => {
167
+ const schedule_reimport = () => {
168
+ if (reimport_timeout) {
169
+ clearTimeout(reimport_timeout);
170
+ }
171
+ reimport_timeout = setTimeout(async () => {
172
+ try {
173
+ is_importing = true;
174
+ await normalize_site(site.dir);
175
+ await import_site_files(site.dir, api_url, site.config, port);
176
+ console.log(chalk.green(` ✓ ${site.config.name} pushed`));
177
+ }
178
+ catch (err) {
179
+ console.log(chalk.red(` ✗ ${site.config.name} push failed: ${err}`));
180
+ }
181
+ finally {
182
+ is_importing = false;
183
+ }
184
+ }, 300);
185
+ };
186
+ for (const dir of dirs_to_watch) {
187
+ const watch_path = path.join(site.dir, dir);
188
+ try {
189
+ const watcher = watch(watch_path, { recursive: true }, async (event, filename) => {
190
+ if (!filename || filename.startsWith('.'))
191
+ return;
192
+ // Check if this file was just written by sync
193
+ const full_path = path.join(watch_path, filename);
194
+ const synced_mtime = synced_files.get(full_path);
195
+ if (synced_mtime) {
196
+ try {
197
+ const stat = await fs.stat(full_path);
198
+ // If mtime matches what we wrote, skip this event
199
+ if (Math.abs(stat.mtimeMs - synced_mtime) < 1000) {
200
+ synced_files.delete(full_path);
201
+ return;
202
+ }
203
+ }
204
+ catch {
205
+ // File might have been deleted
206
+ }
207
+ synced_files.delete(full_path);
208
+ }
209
+ console.log(chalk.dim(` ${site.config.name}: ${dir}/${filename}`));
210
+ schedule_reimport();
211
+ });
212
+ watchers.push(watcher);
213
+ }
214
+ catch {
215
+ // Directory might not exist
216
+ }
217
+ }
218
+ };
219
+ // Set up watchers for existing sites
220
+ for (const site of sites) {
221
+ setup_site_watchers(site);
222
+ }
223
+ // Simple HTTP server for reload requests (only in server mode)
224
+ if (is_server_mode) {
225
+ const http = await import('http');
226
+ const reload_server = http.createServer(async (req, res) => {
227
+ if (req.method !== 'POST' || req.url !== '/reload') {
228
+ res.writeHead(404);
229
+ res.end();
230
+ return;
231
+ }
232
+ const new_sites = await discover_sites(base_dir);
233
+ for (const site of new_sites) {
234
+ if (known_sites.has(site.dir))
235
+ continue;
236
+ known_sites.add(site.dir);
237
+ sites.push(site);
238
+ await normalize_site(site.dir);
239
+ await import_site_files(site.dir, api_url, site.config, port);
240
+ setup_site_watchers(site);
241
+ const host = site.config.host || `${path.basename(site.dir).toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`;
242
+ console.log(chalk.green(` ✓ New site loaded: ${site.config.name}`));
243
+ console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
244
+ console.log(` ${chalk.dim('Preview:')} http://${host}/`);
245
+ }
246
+ res.writeHead(200);
247
+ res.end('ok');
248
+ });
249
+ reload_server.listen(port + 1, '127.0.0.1');
250
+ }
251
+ // Start polling for CMS changes (sync back to local files)
252
+ sync_interval = setInterval(async () => {
253
+ if (is_syncing || is_importing)
254
+ return;
255
+ for (const site of sites) {
256
+ try {
257
+ await sync_from_cms(site.dir, api_url, site.config);
258
+ }
259
+ catch {
260
+ // Silently ignore sync errors
261
+ }
262
+ }
263
+ }, 5000);
264
+ console.log(chalk.dim(' Watching for changes...'));
265
+ console.log(chalk.dim(' Press Ctrl+C to stop'));
266
+ // Handle cleanup
267
+ const cleanup = async () => {
268
+ if (is_cleaning_up)
269
+ return;
270
+ is_cleaning_up = true;
271
+ console.log(chalk.dim('\n Shutting down...'));
272
+ for (const watcher of watchers) {
273
+ try {
274
+ watcher.close();
275
+ }
276
+ catch {
277
+ // Ignore watcher close errors
278
+ }
279
+ }
280
+ watchers = [];
281
+ if (reimport_timeout) {
282
+ clearTimeout(reimport_timeout);
283
+ reimport_timeout = null;
284
+ }
285
+ if (sync_interval) {
286
+ clearInterval(sync_interval);
287
+ sync_interval = null;
288
+ }
289
+ if (cms_process) {
290
+ await kill_process(cms_process);
291
+ cms_process = null;
292
+ }
293
+ process.exit(0);
294
+ };
295
+ process.on('SIGINT', cleanup);
296
+ process.on('SIGTERM', cleanup);
297
+ process.on('uncaughtException', (err) => {
298
+ console.error(chalk.red(`\n Uncaught exception: ${err.message}`));
299
+ cleanup();
300
+ });
301
+ process.on('unhandledRejection', (reason) => {
302
+ console.error(chalk.red(`\n Unhandled rejection: ${reason}`));
303
+ cleanup();
304
+ });
305
+ // Keep process alive
306
+ await new Promise(() => { });
307
+ }
308
+ catch (error) {
309
+ spinner.fail(`Failed to start: ${error instanceof Error ? error.message : error}`);
310
+ process.exit(1);
311
+ }
312
+ }
313
+ async function discover_sites(base_dir) {
314
+ const sites = [];
315
+ const entries = await fs.readdir(base_dir, { withFileTypes: true });
316
+ for (const entry of entries) {
317
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
318
+ const site_dir = path.join(base_dir, entry.name);
319
+ const config_path = path.join(site_dir, 'primo.json');
320
+ try {
321
+ const config_data = await fs.readFile(config_path, 'utf-8');
322
+ const config = JSON.parse(config_data);
323
+ sites.push({ dir: site_dir, config });
324
+ }
325
+ catch {
326
+ // Not a site directory
327
+ }
328
+ }
329
+ }
330
+ return sites;
331
+ }
332
+ async function wait_for_ready(url, timeout_ms) {
333
+ const start = Date.now();
334
+ const health_url = `${url}/api/health`;
335
+ while (Date.now() - start < timeout_ms) {
336
+ try {
337
+ const response = await fetch_with_timeout(health_url, {}, 2000);
338
+ if (response.ok) {
339
+ // Health check passed, but collections may not be ready yet
340
+ // Give PocketBase a moment to finish initializing
341
+ await new Promise(resolve => setTimeout(resolve, 500));
342
+ return true;
343
+ }
344
+ }
345
+ catch {
346
+ // Server not ready yet
347
+ }
348
+ await new Promise(resolve => setTimeout(resolve, 100));
349
+ }
350
+ return false;
351
+ }
352
+ async function verify_site_ready(api_url, site_id) {
353
+ const max_attempts = 20;
354
+ const delay_ms = 100;
355
+ for (let i = 0; i < max_attempts; i++) {
356
+ try {
357
+ const response = await fetch_with_timeout(`${api_url}/api/collections/sites/records/${site_id}`, {}, 5000);
358
+ if (response.ok) {
359
+ return true;
360
+ }
361
+ }
362
+ catch {
363
+ // Site not ready yet
364
+ }
365
+ await new Promise(resolve => setTimeout(resolve, delay_ms));
366
+ }
367
+ return false;
368
+ }
369
+ async function import_site_files(site_dir, api_url, config, port) {
370
+ const site_name = config.name || 'My Site';
371
+ const site_id = config.site_id;
372
+ // Create ZIP of site files
373
+ const zip_buffer = await create_site_zip(site_dir);
374
+ // Use hostname from config, or generate from folder name
375
+ const folder_name = path.basename(site_dir);
376
+ const host = config.host || (folder_name.includes('.')
377
+ ? `${folder_name}:${port}` // Looks like a domain
378
+ : `${folder_name.toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`);
379
+ // Retry bootstrap up to 3 times (collections may not be ready immediately)
380
+ const max_retries = 3;
381
+ for (let attempt = 1; attempt <= max_retries; attempt++) {
382
+ const form_data = new FormData();
383
+ form_data.append('site_id', site_id);
384
+ form_data.append('name', site_name);
385
+ form_data.append('host', host);
386
+ form_data.append('file', new Blob([zip_buffer]), 'site.zip');
387
+ try {
388
+ const bootstrap_response = await fetch_with_timeout(`${api_url}/api/palacms/bootstrap`, {
389
+ method: 'POST',
390
+ body: form_data
391
+ }, 30000); // 30s timeout for imports
392
+ if (bootstrap_response.ok) {
393
+ return;
394
+ }
395
+ const error_text = await bootstrap_response.text();
396
+ // Check if it's a collection not found error (timing issue)
397
+ if (error_text.includes('collection') && attempt < max_retries) {
398
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
399
+ continue;
400
+ }
401
+ console.log(chalk.yellow(` Bootstrap failed (${bootstrap_response.status}): ${error_text}`));
402
+ // Bootstrap failed, try regular import
403
+ const import_form = new FormData();
404
+ import_form.append('file', new Blob([zip_buffer]), 'site.zip');
405
+ const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
406
+ method: 'POST',
407
+ body: import_form
408
+ }, 30000); // 30s timeout for imports
409
+ if (!import_response.ok) {
410
+ const import_error = await import_response.text();
411
+ console.log(chalk.yellow(` Import failed (${import_response.status}): ${import_error}`));
412
+ }
413
+ return;
414
+ }
415
+ catch (err) {
416
+ if (attempt < max_retries) {
417
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
418
+ continue;
419
+ }
420
+ console.log(chalk.yellow(` Import error: ${err}`));
421
+ }
422
+ }
423
+ }
424
+ async function create_site_zip(dir) {
425
+ return new Promise((resolve, reject) => {
426
+ const archive = archiver('zip', { zlib: { level: 9 } });
427
+ const chunks = [];
428
+ archive.on('data', chunk => chunks.push(chunk));
429
+ archive.on('end', () => resolve(Buffer.concat(chunks)));
430
+ archive.on('error', reject);
431
+ const dirs_to_include = ['blocks', 'page-types', 'pages', 'site', 'uploads'];
432
+ for (const subdir of dirs_to_include) {
433
+ const full_path = path.join(dir, subdir);
434
+ archive.directory(full_path, subdir);
435
+ }
436
+ const primo_json = path.join(dir, 'primo.json');
437
+ archive.file(primo_json, { name: 'primo.json' });
438
+ archive.finalize();
439
+ });
440
+ }
441
+ async function sync_from_cms(site_dir, api_url, config) {
442
+ is_syncing = true;
443
+ try {
444
+ const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
445
+ if (!response.ok)
446
+ return;
447
+ const zip_data = await response.arrayBuffer();
448
+ // Extract to temp directory
449
+ const temp_dir = path.join(site_dir, '.primo', 'sync-temp');
450
+ const temp_zip = path.join(temp_dir, 'export.zip');
451
+ await fs.mkdir(temp_dir, { recursive: true });
452
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
453
+ await extract(temp_zip, { dir: temp_dir });
454
+ await fs.unlink(temp_zip);
455
+ // Compare and sync files
456
+ const dirs_to_sync = ['blocks', 'page-types', 'pages', 'site'];
457
+ const changed_files = [];
458
+ for (const dir of dirs_to_sync) {
459
+ const temp_path = path.join(temp_dir, dir);
460
+ const local_path = path.join(site_dir, dir);
461
+ try {
462
+ const files = await sync_directory(temp_path, local_path, dir);
463
+ changed_files.push(...files);
464
+ }
465
+ catch {
466
+ // Directory might not exist in export
467
+ }
468
+ }
469
+ // Clean up temp directory
470
+ await fs.rm(temp_dir, { recursive: true, force: true });
471
+ if (changed_files.length > 0) {
472
+ for (const file of changed_files) {
473
+ console.log(chalk.blue(` ↓ ${config.name}: ${file}`));
474
+ }
475
+ }
476
+ }
477
+ finally {
478
+ is_syncing = false;
479
+ }
480
+ }
481
+ async function sync_directory(src, dest, relative_path = '') {
482
+ const changed_files = [];
483
+ const entries = await fs.readdir(src, { withFileTypes: true });
484
+ await fs.mkdir(dest, { recursive: true });
485
+ for (const entry of entries) {
486
+ const src_path = path.join(src, entry.name);
487
+ const dest_path = path.join(dest, entry.name);
488
+ const file_relative = relative_path ? `${relative_path}/${entry.name}` : entry.name;
489
+ if (entry.isDirectory()) {
490
+ const nested = await sync_directory(src_path, dest_path, file_relative);
491
+ changed_files.push(...nested);
492
+ }
493
+ else {
494
+ const src_content = await fs.readFile(src_path, 'utf-8');
495
+ let dest_content = '';
496
+ try {
497
+ dest_content = await fs.readFile(dest_path, 'utf-8');
498
+ }
499
+ catch {
500
+ // File doesn't exist locally
501
+ }
502
+ // Normalize to handle trailing newline/whitespace differences
503
+ if (src_content.trim() !== dest_content.trim()) {
504
+ // Track this file BEFORE writing to avoid race with watcher
505
+ // Use current time as estimate, watcher allows 1 second tolerance
506
+ synced_files.set(dest_path, Date.now());
507
+ await fs.writeFile(dest_path, src_content);
508
+ // Update with actual mtime after write
509
+ const stat = await fs.stat(dest_path);
510
+ synced_files.set(dest_path, stat.mtimeMs);
511
+ changed_files.push(file_relative);
512
+ }
513
+ }
514
+ }
515
+ return changed_files;
516
+ }
@@ -0,0 +1,8 @@
1
+ interface ExportOptions {
2
+ server: string;
3
+ site: string;
4
+ output: string;
5
+ token?: string;
6
+ }
7
+ export declare function export_site(options: ExportOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,163 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import extract from 'extract-zip';
6
+ import { get_auth_token } from '../utils/auth.js';
7
+ export async function export_site(options) {
8
+ const spinner = ora('Connecting to server...').start();
9
+ try {
10
+ // Get auth token
11
+ const token = options.token || await get_auth_token(options.server);
12
+ if (!token) {
13
+ spinner.fail('Authentication required. Use --token or run `pala login` first.');
14
+ process.exit(1);
15
+ }
16
+ // Create output directory
17
+ const output_dir = path.resolve(options.output);
18
+ await fs.mkdir(output_dir, { recursive: true });
19
+ // Fetch the export
20
+ spinner.text = 'Exporting site...';
21
+ const response = await fetch(`${options.server}/api/palacms/export/${options.site}`, {
22
+ headers: {
23
+ 'Authorization': `Bearer ${token}`
24
+ }
25
+ });
26
+ if (!response.ok) {
27
+ const error = await response.text();
28
+ spinner.fail(`Export failed: ${error}`);
29
+ process.exit(1);
30
+ }
31
+ // Save ZIP temporarily
32
+ const zip_data = await response.arrayBuffer();
33
+ const temp_zip = path.join(output_dir, '.pala-export.zip');
34
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
35
+ // Extract ZIP
36
+ spinner.text = 'Extracting files...';
37
+ await extract(temp_zip, { dir: output_dir });
38
+ // Clean up temp ZIP
39
+ await fs.unlink(temp_zip);
40
+ // Copy JSON schemas
41
+ spinner.text = 'Adding JSON schemas...';
42
+ await copy_schemas(output_dir);
43
+ // Add $schema references
44
+ await add_schema_references(output_dir);
45
+ spinner.succeed(`Site exported to ${chalk.cyan(output_dir)}`);
46
+ // Show summary
47
+ const files = await count_files(output_dir);
48
+ console.log('');
49
+ console.log(chalk.dim(' Files exported:'));
50
+ console.log(chalk.dim(` blocks/ ${files.blocks} blocks`));
51
+ console.log(chalk.dim(` page-types/ ${files.page_types} page types`));
52
+ console.log(chalk.dim(` pages/ ${files.pages} pages`));
53
+ console.log('');
54
+ console.log(chalk.green(' Ready for local development!'));
55
+ console.log(chalk.dim(' Run `pala dev` to start the local server'));
56
+ }
57
+ catch (error) {
58
+ spinner.fail(`Export failed: ${error instanceof Error ? error.message : error}`);
59
+ process.exit(1);
60
+ }
61
+ }
62
+ async function count_files(dir) {
63
+ const counts = { blocks: 0, page_types: 0, pages: 0 };
64
+ try {
65
+ const blocks_dir = path.join(dir, 'blocks');
66
+ const entries = await fs.readdir(blocks_dir, { withFileTypes: true });
67
+ counts.blocks = entries.filter(e => e.isDirectory()).length;
68
+ }
69
+ catch { }
70
+ try {
71
+ const pt_dir = path.join(dir, 'page-types');
72
+ const entries = await fs.readdir(pt_dir, { withFileTypes: true });
73
+ counts.page_types = entries.filter(e => e.isDirectory()).length;
74
+ }
75
+ catch { }
76
+ try {
77
+ const pages_dir = path.join(dir, 'pages');
78
+ counts.pages = await count_json_files(pages_dir);
79
+ }
80
+ catch { }
81
+ return counts;
82
+ }
83
+ async function count_json_files(dir) {
84
+ let count = 0;
85
+ const entries = await fs.readdir(dir, { withFileTypes: true });
86
+ for (const entry of entries) {
87
+ if (entry.isDirectory()) {
88
+ count += await count_json_files(path.join(dir, entry.name));
89
+ }
90
+ else if (entry.name.endsWith('.json')) {
91
+ count++;
92
+ }
93
+ }
94
+ return count;
95
+ }
96
+ async function copy_schemas(output_dir) {
97
+ // Get path to schemas directory relative to compiled dist file
98
+ const current_file = new URL(import.meta.url).pathname;
99
+ const dist_dir = path.dirname(path.dirname(current_file)); // dist/
100
+ const project_root = path.dirname(dist_dir); // project root
101
+ const schemas_src = path.join(project_root, 'schemas');
102
+ const schemas_dest = path.join(output_dir, '.schemas');
103
+ await fs.mkdir(schemas_dest, { recursive: true });
104
+ const schema_files = await fs.readdir(schemas_src);
105
+ for (const file of schema_files) {
106
+ if (file.endsWith('.json')) {
107
+ await fs.copyFile(path.join(schemas_src, file), path.join(schemas_dest, file));
108
+ }
109
+ }
110
+ }
111
+ async function add_schema_references(output_dir) {
112
+ // Add $schema to block fields.json
113
+ const blocks_dir = path.join(output_dir, 'blocks');
114
+ try {
115
+ const blocks = await fs.readdir(blocks_dir, { withFileTypes: true });
116
+ for (const block of blocks) {
117
+ if (block.isDirectory()) {
118
+ const fields_path = path.join(blocks_dir, block.name, 'fields.json');
119
+ try {
120
+ const fields = JSON.parse(await fs.readFile(fields_path, 'utf-8'));
121
+ // Create new object with $schema first
122
+ const with_schema = {
123
+ $schema: '../../.schemas/fields.schema.json',
124
+ ...fields
125
+ };
126
+ await fs.writeFile(fields_path, JSON.stringify(with_schema, null, 2) + '\n');
127
+ }
128
+ catch { }
129
+ }
130
+ }
131
+ }
132
+ catch { }
133
+ // Add $schema to page-type config.json
134
+ const page_types_dir = path.join(output_dir, 'page-types');
135
+ try {
136
+ const page_types = await fs.readdir(page_types_dir, { withFileTypes: true });
137
+ for (const page_type of page_types) {
138
+ if (page_type.isDirectory()) {
139
+ const config_path = path.join(page_types_dir, page_type.name, 'config.json');
140
+ try {
141
+ const config = JSON.parse(await fs.readFile(config_path, 'utf-8'));
142
+ // Create new object with $schema first
143
+ const with_schema = {
144
+ $schema: '../../.schemas/page-type-config.schema.json',
145
+ ...config
146
+ };
147
+ await fs.writeFile(config_path, JSON.stringify(with_schema, null, 2) + '\n');
148
+ }
149
+ catch { }
150
+ }
151
+ }
152
+ }
153
+ catch { }
154
+ // Add $schema to site fields.json
155
+ const site_fields_path = path.join(output_dir, 'site/fields.json');
156
+ try {
157
+ const site_fields = JSON.parse(await fs.readFile(site_fields_path, 'utf-8'));
158
+ // Site fields is an array, so we need to add $schema differently
159
+ // Since JSON Schema doesn't support $schema in arrays, we'll skip this for now
160
+ // IDEs can still use the schema if users manually add it via settings
161
+ }
162
+ catch { }
163
+ }