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.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # Primo CLI
2
+
3
+ Local development CLI for [Primo](https://primocms.org) - build and edit sites with a visual CMS.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g primo-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Create a new site
15
+ primo new my-site
16
+
17
+ # This starts the local CMS automatically
18
+ # Edit at: http://my-site.localhost:3000/admin/site
19
+ # Preview at: http://my-site.localhost:3000/
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ ### `primo new [name]`
25
+
26
+ Create a new site with starter files.
27
+
28
+ ```bash
29
+ primo new # Interactive prompt for name
30
+ primo new my-site # Create "my-site" directory
31
+ primo new --skip-dev # Create files without starting CMS
32
+ ```
33
+
34
+ ### `primo dev`
35
+
36
+ Start the local CMS server. Watches for file changes and syncs edits from the CMS back to local files.
37
+
38
+ ```bash
39
+ primo dev # Start in current directory
40
+ primo dev -p 8080 # Use custom port
41
+ ```
42
+
43
+ ### `primo push`
44
+
45
+ Push local files to a hosted Primo instance.
46
+
47
+ ```bash
48
+ primo push -s https://cms.example.com --site abc123
49
+ primo push --preview # Preview changes without applying
50
+ ```
51
+
52
+ Options:
53
+ - `-s, --server <url>` - Server URL
54
+ - `--site <id>` - Site ID
55
+ - `-d, --dir <dir>` - Directory (default: `.`)
56
+ - `-t, --token <token>` - Auth token
57
+ - `--preview` - Preview only
58
+
59
+ ### `primo pull`
60
+
61
+ Pull from a hosted Primo instance to local files.
62
+
63
+ ```bash
64
+ primo pull -s https://cms.example.com
65
+ primo pull --site abc123 -o ./my-site
66
+ ```
67
+
68
+ Options:
69
+ - `-s, --server <url>` - Server URL (auto-detects local)
70
+ - `--site <id>` - Site ID (interactive if not provided)
71
+ - `-o, --output <dir>` - Output directory (default: `.`)
72
+ - `-t, --token <token>` - Auth token
73
+
74
+ ### `primo login`
75
+
76
+ Authenticate with a hosted Primo instance.
77
+
78
+ ```bash
79
+ primo login https://cms.example.com
80
+ primo login https://cms.example.com -e user@example.com
81
+ ```
82
+
83
+ ### `primo publish`
84
+
85
+ Deploy your site with CMS to Railway or Fly.io.
86
+
87
+ ```bash
88
+ primo publish # Interactive provider selection
89
+ primo publish -p railway # Deploy to Railway
90
+ primo publish -p fly # Deploy to Fly.io
91
+ ```
92
+
93
+ ### `primo validate`
94
+
95
+ Check site structure for errors.
96
+
97
+ ```bash
98
+ primo validate
99
+ primo validate --strict # Strict mode
100
+ ```
101
+
102
+ ### `primo build`
103
+
104
+ Build static HTML site for deployment to any static host.
105
+
106
+ ```bash
107
+ primo build # Output to ./dist
108
+ primo build -o ./public # Custom output directory
109
+ ```
110
+
111
+ Deploy the output anywhere:
112
+ ```bash
113
+ # Netlify
114
+ npx netlify deploy --prod --dir=dist
115
+
116
+ # Vercel
117
+ npx vercel dist
118
+
119
+ # Cloudflare Pages
120
+ npx wrangler pages deploy dist
121
+
122
+ # Or just push to a repo connected to any static host
123
+ ```
124
+
125
+ ## Site Structure
126
+
127
+ ```
128
+ my-site/
129
+ ├── primo.json # Site config (name, site_id, host)
130
+ ├── blocks/ # Svelte components
131
+ │ └── hero/
132
+ │ ├── component.svelte
133
+ │ ├── fields.json
134
+ │ └── content.yaml
135
+ ├── pages/ # Page content (YAML)
136
+ │ └── index.yaml
137
+ ├── page-types/ # Page templates
138
+ │ └── default/
139
+ │ └── config.json
140
+ ├── site/ # Site-wide settings
141
+ │ ├── fields.json
142
+ │ ├── content.yaml
143
+ │ └── head.svelte
144
+ └── uploads/ # Media files
145
+ ```
146
+
147
+ ## Multi-Site Mode
148
+
149
+ Manage multiple sites from one directory:
150
+
151
+ ```
152
+ workspace/
153
+ ├── server.json
154
+ ├── site-one/
155
+ │ ├── primo.json
156
+ │ ├── blocks/
157
+ │ └── pages/
158
+ └── site-two/
159
+ ├── primo.json
160
+ ├── blocks/
161
+ └── pages/
162
+ ```
163
+
164
+ ```json
165
+ // server.json
166
+ { "port": 3000 }
167
+ ```
168
+
169
+ ```bash
170
+ cd workspace
171
+ primo dev
172
+ ```
173
+
174
+ Each site gets its own subdomain: `site-one.localhost:3000`, `site-two.localhost:3000`
175
+
176
+ ## Documentation
177
+
178
+ Full documentation: [docs.primocms.org](https://docs.primocms.org)
179
+
180
+ ## Requirements
181
+
182
+ - Node.js 18+
183
+ - For `primo publish`: Railway CLI or Fly.io CLI
@@ -0,0 +1,6 @@
1
+ interface BuildOptions {
2
+ dir: string;
3
+ output: string;
4
+ }
5
+ export declare function build_site(options: BuildOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,379 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { load as load_yaml } from 'js-yaml';
6
+ export async function build_site(options) {
7
+ const spinner = ora('Building site...').start();
8
+ try {
9
+ const site_dir = path.resolve(options.dir);
10
+ const output_dir = path.resolve(options.output);
11
+ // Read site config
12
+ const config_path = path.join(site_dir, 'primo.json');
13
+ let config;
14
+ try {
15
+ const config_data = await fs.readFile(config_path, 'utf-8');
16
+ config = JSON.parse(config_data);
17
+ }
18
+ catch {
19
+ spinner.fail('No primo.json found. Run `primo new` first.');
20
+ process.exit(1);
21
+ }
22
+ // Clean and create output directory
23
+ await fs.rm(output_dir, { recursive: true, force: true });
24
+ await fs.mkdir(output_dir, { recursive: true });
25
+ // Read head.svelte for global styles
26
+ let head_content = '';
27
+ try {
28
+ const head_path = path.join(site_dir, 'site', 'head.svelte');
29
+ head_content = await fs.readFile(head_path, 'utf-8');
30
+ }
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];
40
+ }
41
+ }
42
+ // Find all pages
43
+ 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];
49
+ // Build each page
50
+ for (const page_file of pages) {
51
+ const page_content = await fs.readFile(page_file, 'utf-8');
52
+ 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;
71
+ }
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'
77
+ ? path.join(output_dir, 'index.html')
78
+ : path.join(output_dir, slug, 'index.html');
79
+ await fs.mkdir(path.dirname(out_path), { recursive: true });
80
+ await fs.writeFile(out_path, html);
81
+ }
82
+ // Copy uploads directory
83
+ const uploads_src = path.join(site_dir, 'uploads');
84
+ const uploads_dest = path.join(output_dir, 'uploads');
85
+ try {
86
+ await copy_dir(uploads_src, uploads_dest);
87
+ }
88
+ catch {
89
+ // No uploads directory, that's fine
90
+ }
91
+ spinner.succeed(`Built ${pages.length} page${pages.length !== 1 ? 's' : ''} to ${chalk.cyan(output_dir)}`);
92
+ console.log('');
93
+ console.log(chalk.dim(' Preview locally:'));
94
+ console.log(chalk.dim(` npx serve ${options.output}`));
95
+ console.log('');
96
+ 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));
99
+ console.log('');
100
+ }
101
+ catch (error) {
102
+ spinner.fail(`Build failed: ${error instanceof Error ? error.message : error}`);
103
+ if (error instanceof Error && error.stack) {
104
+ console.log(chalk.dim(error.stack));
105
+ }
106
+ process.exit(1);
107
+ }
108
+ }
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');
134
+ 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 };
140
+ }
141
+ catch {
142
+ return { css: '' };
143
+ }
144
+ }
145
+ async function render_block(site_dir, block_name, content) {
146
+ const component_path = path.join(site_dir, 'blocks', block_name, 'component.svelte');
147
+ 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
+ }
188
+ }
189
+ return '';
190
+ });
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));
199
+ }
200
+ }
201
+ return escape_html(defaultVal);
202
+ });
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
210
+ });
211
+ // Clean up any remaining unresolved template expressions
212
+ html = html.replace(/\{[^}]+\}/g, '');
213
+ return html;
214
+ }
215
+ catch (error) {
216
+ console.log(chalk.yellow(` Warning: Could not render block "${block_name}": ${error}`));
217
+ return `<!-- Block "${block_name}" could not be rendered -->`;
218
+ }
219
+ }
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]];
255
+ }
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]];
260
+ }
261
+ return false;
262
+ }
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
+ });
301
+ }
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];
311
+ }
312
+ return current;
313
+ }
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];
321
+ }
322
+ else {
323
+ normalized[key] = value;
324
+ }
325
+ }
326
+ return normalized;
327
+ }
328
+ function escape_html(str) {
329
+ return str
330
+ .replace(/&/g, '&amp;')
331
+ .replace(/</g, '&lt;')
332
+ .replace(/>/g, '&gt;')
333
+ .replace(/"/g, '&quot;')
334
+ .replace(/'/g, '&#039;');
335
+ }
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
+ `;
365
+ }
366
+ async function copy_dir(src, dest) {
367
+ await fs.mkdir(dest, { recursive: true });
368
+ const entries = await fs.readdir(src, { withFileTypes: true });
369
+ for (const entry of entries) {
370
+ const src_path = path.join(src, entry.name);
371
+ const dest_path = path.join(dest, entry.name);
372
+ if (entry.isDirectory()) {
373
+ await copy_dir(src_path, dest_path);
374
+ }
375
+ else {
376
+ await fs.copyFile(src_path, dest_path);
377
+ }
378
+ }
379
+ }
@@ -0,0 +1,6 @@
1
+ interface DeployOptions {
2
+ dir: string;
3
+ provider?: string;
4
+ }
5
+ export declare function deploy(options: DeployOptions): Promise<void>;
6
+ export {};