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,507 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import inquirer from 'inquirer';
6
+ import { dev_server } from './dev.js';
7
+ export async function new_site(options) {
8
+ const base_dir = process.cwd();
9
+ const server_config_path = path.join(base_dir, 'server.json');
10
+ // Auto-create server.json if it doesn't exist
11
+ try {
12
+ await fs.access(server_config_path);
13
+ }
14
+ catch {
15
+ await fs.writeFile(server_config_path, JSON.stringify({ port: 3000 }, null, 2) + '\n');
16
+ }
17
+ let site_name = options.name;
18
+ // Prompt for name if not provided
19
+ if (!site_name) {
20
+ const { name } = await inquirer.prompt([{
21
+ type: 'input',
22
+ name: 'name',
23
+ message: 'Site name:',
24
+ default: 'my-site',
25
+ validate: (input) => {
26
+ if (!input.trim())
27
+ return 'Name is required';
28
+ if (!/^[a-z0-9.-]+$/i.test(input))
29
+ return 'Use only letters, numbers, dots, and hyphens';
30
+ return true;
31
+ }
32
+ }]);
33
+ site_name = name;
34
+ }
35
+ // Always create site in subdirectory
36
+ const site_dir = path.join(base_dir, site_name);
37
+ // Check if directory exists
38
+ try {
39
+ await fs.access(site_dir);
40
+ console.log(chalk.red(`Directory "${site_name}" already exists`));
41
+ process.exit(1);
42
+ }
43
+ catch {
44
+ // Directory doesn't exist, good to proceed
45
+ }
46
+ const spinner = ora('Creating site...').start();
47
+ try {
48
+ // Create directory structure
49
+ await fs.mkdir(site_dir, { recursive: true });
50
+ await fs.mkdir(path.join(site_dir, 'blocks'), { recursive: true });
51
+ await fs.mkdir(path.join(site_dir, 'pages'), { recursive: true });
52
+ await fs.mkdir(path.join(site_dir, 'page-types', 'default'), { recursive: true });
53
+ await fs.mkdir(path.join(site_dir, 'site'), { recursive: true });
54
+ await fs.mkdir(path.join(site_dir, 'uploads'), { recursive: true });
55
+ // Create primo.json
56
+ // If name has dots (hostname), use first part capitalized as display name
57
+ const display_name = site_name.includes('.')
58
+ ? site_name.split('.')[0].charAt(0).toUpperCase() + site_name.split('.')[0].slice(1)
59
+ : site_name.charAt(0).toUpperCase() + site_name.slice(1).replace(/-/g, ' ');
60
+ const config = {
61
+ name: display_name,
62
+ site_id: generate_id(),
63
+ // Leave host empty for local dev - dev.ts will generate coffee-shop.localhost:3000
64
+ // Only set host if it looks like a real domain (has a dot)
65
+ host: site_name.includes('.') ? site_name : ''
66
+ };
67
+ await fs.writeFile(path.join(site_dir, 'primo.json'), JSON.stringify(config, null, 2) + '\n');
68
+ // Create default page type config
69
+ const page_type_config = {
70
+ id: generate_id(),
71
+ name: 'Default',
72
+ icon: 'mdi:file-document-outline',
73
+ allowed_blocks: ['hero'],
74
+ fields: []
75
+ };
76
+ await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'config.json'), JSON.stringify(page_type_config, null, 2) + '\n');
77
+ // Create site fields (empty array)
78
+ await fs.writeFile(path.join(site_dir, 'site', 'fields.json'), '[]\n');
79
+ // Create site content (empty)
80
+ await fs.writeFile(path.join(site_dir, 'site', 'content.yaml'), '# Site-wide content\n');
81
+ // Create site head with CSS variables
82
+ await fs.writeFile(path.join(site_dir, 'site', 'head.svelte'), `<style>
83
+ :root {
84
+ --theme-primary: #6366f1;
85
+ --theme-primary-dark: #4f46e5;
86
+ --theme-background: #ffffff;
87
+ --theme-background-secondary: #f8fafc;
88
+ --theme-text: #0f172a;
89
+ --theme-text-muted: #64748b;
90
+ --theme-border-color: #e2e8f0;
91
+ --theme-heading-font: system-ui, -apple-system, sans-serif;
92
+ --theme-body-font: system-ui, -apple-system, sans-serif;
93
+ --theme-section-padding: 5rem;
94
+ }
95
+ </style>
96
+ `);
97
+ // Create starter hero block
98
+ await fs.mkdir(path.join(site_dir, 'blocks', 'hero'), { recursive: true });
99
+ const hero_fields = {
100
+ id: generate_id(),
101
+ name: 'Hero',
102
+ fields: [
103
+ { id: generate_id(), name: 'headline', label: 'Headline', type: 'text', options: null },
104
+ { id: generate_id(), name: 'subheadline', label: 'Subheadline', type: 'text', options: null },
105
+ { id: generate_id(), name: 'cta', label: 'Call to Action', type: 'link', options: null }
106
+ ]
107
+ };
108
+ await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'fields.json'), JSON.stringify(hero_fields, null, 2) + '\n');
109
+ await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'component.svelte'), `<section class="hero">
110
+ <div class="container">
111
+ <h1>{headline}</h1>
112
+ {#if subheadline}
113
+ <p class="subheadline">{subheadline}</p>
114
+ {/if}
115
+ {#if cta?.url}
116
+ <a href={cta.url} class="btn">{cta.label}</a>
117
+ {/if}
118
+ </div>
119
+ </section>
120
+
121
+ <script>
122
+ let { headline = '', subheadline = '', cta = {} } = $props()
123
+ </script>
124
+
125
+ <style>
126
+ .hero {
127
+ padding: var(--theme-section-padding, 5rem) 0;
128
+ background: var(--theme-background-secondary, #f8fafc);
129
+ text-align: center;
130
+ }
131
+
132
+ .container {
133
+ max-width: 800px;
134
+ margin: 0 auto;
135
+ padding: 0 1.5rem;
136
+ }
137
+
138
+ h1 {
139
+ font-family: var(--theme-heading-font, system-ui);
140
+ font-size: clamp(2rem, 5vw, 3.5rem);
141
+ font-weight: 700;
142
+ color: var(--theme-text, #0f172a);
143
+ margin: 0 0 1rem;
144
+ line-height: 1.1;
145
+ }
146
+
147
+ .subheadline {
148
+ font-size: 1.25rem;
149
+ color: var(--theme-text-muted, #64748b);
150
+ margin: 0 0 2rem;
151
+ }
152
+
153
+ .btn {
154
+ display: inline-block;
155
+ padding: 0.875rem 2rem;
156
+ background: var(--theme-primary, #6366f1);
157
+ color: white;
158
+ text-decoration: none;
159
+ border-radius: 0.5rem;
160
+ font-weight: 500;
161
+ transition: background 0.2s;
162
+ }
163
+
164
+ .btn:hover {
165
+ background: var(--theme-primary-dark, #4f46e5);
166
+ }
167
+ </style>
168
+ `);
169
+ await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'content.yaml'), `headline: Welcome to ${display_name}
170
+ subheadline: Edit this content in your local files or CMS
171
+ cta:
172
+ label: Get Started
173
+ url: "#"
174
+ `);
175
+ // Create index page
176
+ const page_id = generate_id();
177
+ await fs.writeFile(path.join(site_dir, 'pages', 'index.yaml'), `id: ${page_id}
178
+ name: Home
179
+ slug: index
180
+ page_type: default
181
+ fields: {}
182
+ sections:
183
+ - block: hero
184
+ content:
185
+ headline: Welcome to ${display_name}
186
+ subheadline: Edit this content in your local files or CMS
187
+ cta:
188
+ label: Get Started
189
+ url: "#"
190
+ `);
191
+ // Create AGENT.md
192
+ await fs.writeFile(path.join(site_dir, 'AGENT.md'), generate_agent_md(display_name));
193
+ spinner.succeed(`Site created: ${chalk.cyan(site_dir)}`);
194
+ // Check if server is already running
195
+ const port = 3000;
196
+ const server_running = await is_server_running(port);
197
+ if (server_running) {
198
+ // Tell the server to reload and pick up the new site
199
+ try {
200
+ await fetch(`http://127.0.0.1:${port + 1}/reload`, { method: 'POST' });
201
+ }
202
+ catch {
203
+ // Reload server might not be running (older version)
204
+ }
205
+ console.log('');
206
+ console.log(chalk.dim(` http://${site_name}.localhost:${port}/`));
207
+ console.log('');
208
+ }
209
+ else if (!options.skipDev) {
210
+ // No server running, start one
211
+ console.log('');
212
+ await dev_server({ dir: base_dir, port: String(port) });
213
+ }
214
+ else {
215
+ console.log('');
216
+ console.log(chalk.dim(' Next steps:'));
217
+ console.log(chalk.dim(' primo dev'));
218
+ console.log('');
219
+ }
220
+ }
221
+ catch (error) {
222
+ spinner.fail(`Failed to create site: ${error instanceof Error ? error.message : error}`);
223
+ process.exit(1);
224
+ }
225
+ }
226
+ function generate_id() {
227
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
228
+ let id = '';
229
+ for (let i = 0; i < 15; i++) {
230
+ id += chars[Math.floor(Math.random() * chars.length)];
231
+ }
232
+ return id;
233
+ }
234
+ async function is_server_running(port) {
235
+ try {
236
+ const controller = new AbortController();
237
+ const timeout = setTimeout(() => controller.abort(), 1000);
238
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
239
+ signal: controller.signal
240
+ });
241
+ clearTimeout(timeout);
242
+ return response.ok;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
248
+ function generate_agent_md(site_name) {
249
+ return `# ${site_name}
250
+
251
+ Pala site for local development.
252
+
253
+ ## Structure
254
+
255
+ \`\`\`
256
+ blocks/ # Svelte components with content fields
257
+ {name}/
258
+ component.svelte
259
+ fields.json
260
+ content.yaml # Default field values (optional)
261
+ page-types/ # Page templates
262
+ {name}/
263
+ config.json
264
+ pages/ # Page content (YAML)
265
+ index.yaml # Homepage
266
+ contact.yaml # Leaf page (/contact)
267
+ about/ # Section with children
268
+ index.yaml # /about
269
+ team.yaml # /about/team
270
+ site/ # Site-wide settings
271
+ fields.json
272
+ content.yaml
273
+ head.svelte # Injected into <head>
274
+ .primo/ # Internal metadata
275
+ \`\`\`
276
+
277
+ ## Creating Blocks
278
+
279
+ Each block needs two files:
280
+
281
+ **component.svelte** - Svelte 5 component:
282
+ \`\`\`svelte
283
+ <h1>{headline}</h1>
284
+ {#if image?.url}
285
+ <img src={image.url} alt={image.alt} />
286
+ {/if}
287
+
288
+ <style>
289
+ h1 { font-size: 2rem; }
290
+ </style>
291
+ \`\`\`
292
+
293
+ **Note:** Props are auto-injected from fields.json. No need to declare \`$props()\` - just use the field names directly in your template.
294
+
295
+ **fields.json** - Field definitions:
296
+ \`\`\`json
297
+ {
298
+ "name": "Hero",
299
+ "fields": [
300
+ { "name": "headline", "label": "Headline", "type": "text" },
301
+ { "name": "image", "label": "Image", "type": "image" }
302
+ ]
303
+ }
304
+ \`\`\`
305
+
306
+ ## Field Types
307
+
308
+ ### text
309
+ Single-line text input.
310
+ \`\`\`svelte
311
+ <h1>{headline}</h1>
312
+ \`\`\`
313
+
314
+ ### rich-text
315
+ WYSIWYG editor. Outputs HTML.
316
+ \`\`\`svelte
317
+ {@html content}
318
+ \`\`\`
319
+
320
+ ### markdown
321
+ Markdown editor. Outputs HTML.
322
+ \`\`\`svelte
323
+ {@html body}
324
+ \`\`\`
325
+
326
+ ### image
327
+ Image upload. Returns \`{ url, alt, width, height }\`.
328
+ \`\`\`svelte
329
+ {#if image?.url}
330
+ <img src={image.url} alt={image.alt} />
331
+ {/if}
332
+ \`\`\`
333
+
334
+ ### link
335
+ URL with label. Returns \`{ url, label }\`.
336
+ \`\`\`svelte
337
+ {#if cta?.url}
338
+ <a href={cta.url}>{cta.label}</a>
339
+ {/if}
340
+ \`\`\`
341
+
342
+ ### url
343
+ Plain URL string.
344
+ \`\`\`svelte
345
+ <a href={website_url}>Visit</a>
346
+ \`\`\`
347
+
348
+ ### icon
349
+ Icon picker. Returns SVG string.
350
+ \`\`\`svelte
351
+ {@html icon}
352
+ \`\`\`
353
+
354
+ ### number
355
+ Numeric input.
356
+ \`\`\`json
357
+ { "name": "columns", "type": "number", "options": { "min": 1, "max": 6 } }
358
+ \`\`\`
359
+
360
+ ### switch
361
+ Boolean toggle.
362
+ \`\`\`svelte
363
+ {#if show_title}<h1>{title}</h1>{/if}
364
+ \`\`\`
365
+
366
+ ### select
367
+ Dropdown selection.
368
+ \`\`\`json
369
+ { "name": "align", "type": "select", "options": { "choices": ["left", "center", "right"] } }
370
+ \`\`\`
371
+ \`\`\`svelte
372
+ <div class="text-{align}">{content}</div>
373
+ \`\`\`
374
+
375
+ ### repeater
376
+ List of items with nested fields.
377
+ \`\`\`json
378
+ {
379
+ "name": "features",
380
+ "type": "repeater",
381
+ "options": {
382
+ "fields": [
383
+ { "name": "title", "type": "text" },
384
+ { "name": "description", "type": "text" }
385
+ ]
386
+ }
387
+ }
388
+ \`\`\`
389
+ \`\`\`svelte
390
+ {#each features as feature}
391
+ <div>
392
+ <h3>{feature.title}</h3>
393
+ <p>{feature.description}</p>
394
+ </div>
395
+ {/each}
396
+ \`\`\`
397
+
398
+ ### group
399
+ Nested object of fields.
400
+ \`\`\`json
401
+ {
402
+ "name": "author",
403
+ "type": "group",
404
+ "options": {
405
+ "fields": [
406
+ { "name": "name", "type": "text" },
407
+ { "name": "avatar", "type": "image" }
408
+ ]
409
+ }
410
+ }
411
+ \`\`\`
412
+ \`\`\`svelte
413
+ <div>{author.name}</div>
414
+ {#if author.avatar?.url}<img src={author.avatar.url} />{/if}
415
+ \`\`\`
416
+
417
+ ### page
418
+ Reference to another page. Returns page data with \`_meta.url\`.
419
+ \`\`\`json
420
+ { "name": "featured_post", "type": "page", "options": { "page_type": "blog-post" } }
421
+ \`\`\`
422
+
423
+ ### page-list
424
+ All pages of a type.
425
+ \`\`\`json
426
+ { "name": "posts", "type": "page-list", "options": { "page_type": "blog-post" } }
427
+ \`\`\`
428
+
429
+ ### page-field
430
+ Reference a field from the current page type.
431
+
432
+ ### site-field
433
+ Reference a site-wide field.
434
+
435
+ ### slider
436
+ Range slider for numeric values.
437
+ \`\`\`json
438
+ { "name": "opacity", "type": "slider", "options": { "min": 0, "max": 100, "step": 10 } }
439
+ \`\`\`
440
+
441
+ ### date
442
+ Date picker.
443
+
444
+ ### info
445
+ Display-only text for editors (not rendered in component).
446
+
447
+ ## Svelte 5 Syntax
448
+
449
+ Components use Svelte 5:
450
+ - \`$state()\` for reactive variables
451
+ - \`$derived()\` for computed values
452
+ - \`$effect()\` for side effects
453
+ - \`onclick={handler}\` not \`on:click={handler}\`
454
+
455
+ ## Editor Context
456
+
457
+ Check if component is running in the CMS editor:
458
+ \`\`\`svelte
459
+ let is_editor = $state(false)
460
+
461
+ if (typeof window !== 'undefined') {
462
+ is_editor = window.__PALA_CONTEXT__?.environment === 'editor'
463
+ }
464
+ \`\`\`
465
+
466
+ Use this for:
467
+ - Disabling fixed/sticky positioning
468
+ - Skipping scroll/resize listeners
469
+ - Showing placeholder content
470
+
471
+ ## This Site
472
+
473
+ ### Blocks
474
+
475
+ - \`hero\` - Hero
476
+
477
+ ### Page Types
478
+
479
+ - \`default\` - Default
480
+
481
+ ## Best Practices
482
+
483
+ ### Safe Field Access
484
+
485
+ Always handle potentially undefined fields:
486
+ \`\`\`svelte
487
+ <!-- Images -->
488
+ {#if hero_image?.url}
489
+ <img src={hero_image.url} alt={hero_image.alt} />
490
+ {/if}
491
+
492
+ <!-- Links -->
493
+ <a href={cta?.url || '#'}>{cta?.label || 'Learn More'}</a>
494
+
495
+ <!-- Repeaters -->
496
+ {#each features || [] as feature}
497
+ <div>{feature.title}</div>
498
+ {/each}
499
+ \`\`\`
500
+
501
+ ## Workflow
502
+
503
+ 1. Run \`primo dev\` to start the local preview server
504
+ 2. Edit blocks, pages, or site settings - changes auto-sync to dev server
505
+ 3. Run \`primo push\` to deploy changes to a live server (if connected)
506
+ `;
507
+ }
@@ -0,0 +1,6 @@
1
+ interface PublishOptions {
2
+ dir: string;
3
+ provider?: string;
4
+ }
5
+ export declare function publish(options: PublishOptions): Promise<void>;
6
+ export {};