primo-cli 0.1.16 → 0.1.17

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.
@@ -51,6 +51,11 @@ export async function build_site(options) {
51
51
  // Find all pages
52
52
  const pages_dir = path.join(site_dir, 'pages');
53
53
  const page_files = await find_pages(pages_dir);
54
+ // Map each page's _id to its live URL so internal `page:` links resolve.
55
+ // The URL is derived from the page's file location (same convention the
56
+ // build uses to write output), not the slug, so a page-ref and a raw
57
+ // url pointing at the same page produce identical hrefs.
58
+ const page_url_map = await build_page_url_map(site_dir, page_files);
54
59
  spinner.text = `Building ${page_files.length} page${page_files.length !== 1 ? 's' : ''}...`;
55
60
  // Compile all blocks once and cache them
56
61
  const block_cache = new Map();
@@ -77,7 +82,8 @@ export async function build_site(options) {
77
82
  block_cache,
78
83
  layout_cache,
79
84
  page_type_head_cache,
80
- site_data
85
+ site_data,
86
+ page_url_map
81
87
  });
82
88
  if (result.error) {
83
89
  console.log(chalk.yellow(` Warning: ${page.name}: ${result.error}`));
@@ -140,7 +146,7 @@ async function load_page_type_head(site_dir, page_type, cache) {
140
146
  return content;
141
147
  }
142
148
  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;
149
+ const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
144
150
  try {
145
151
  const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
146
152
  // Load layout for this page type
@@ -155,9 +161,9 @@ async function build_page(options) {
155
161
  const page_type_head = await load_page_type_head(site_dir, page_type, page_type_head_cache);
156
162
  const combined_head_content = page_type_head ? `${head_content}\n${page_type_head}` : head_content;
157
163
  // 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);
164
+ const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data, page_url_map);
165
+ const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data, page_url_map);
166
+ const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data, page_url_map);
161
167
  const all_sections = [...header_sections, ...page_sections, ...footer_sections];
162
168
  if (all_sections.length === 0) {
163
169
  return { html: generate_empty_page(site_name, page.name, combined_head_content) };
@@ -448,7 +454,7 @@ async function load_layout(site_dir, page_type) {
448
454
  return {};
449
455
  }
450
456
  }
451
- async function resolve_layout_sections(sections, site_dir, site_data) {
457
+ async function resolve_layout_sections(sections, site_dir, site_data, page_url_map) {
452
458
  // For layout sections without content, load from block's content.yaml
453
459
  const resolved = [];
454
460
  for (const section of sections) {
@@ -462,20 +468,73 @@ async function resolve_layout_sections(sections, site_dir, site_data) {
462
468
  }
463
469
  // Resolve any site-field references in the content
464
470
  const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
465
- resolved.push({ ...section, content: resolved_content });
471
+ // Resolve internal page: links to URLs (walks nested repeaters/groups too)
472
+ resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
466
473
  }
467
474
  return resolved;
468
475
  }
469
- async function resolve_page_sections(sections, site_dir, site_data) {
476
+ async function resolve_page_sections(sections, site_dir, site_data, page_url_map) {
470
477
  // Resolve site-field references in page sections
471
478
  const resolved = [];
472
479
  for (const section of sections) {
473
480
  const content = section.content || {};
474
481
  const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
475
- resolved.push({ ...section, content: resolved_content });
482
+ // Resolve internal page: links to URLs (walks nested repeaters/groups too)
483
+ resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
476
484
  }
477
485
  return resolved;
478
486
  }
487
+ // Build a map of page _id -> live URL path. The URL is derived from the page
488
+ // file's location (the same convention used to write output HTML), so a
489
+ // `page:` reference resolves to exactly the path that page is deployed at.
490
+ async function build_page_url_map(site_dir, page_files) {
491
+ const map = new Map();
492
+ for (const page_file of page_files) {
493
+ try {
494
+ const page = load_yaml(await fs.readFile(page_file, 'utf-8'));
495
+ const id = page._id || page.id;
496
+ if (!id)
497
+ continue;
498
+ const page_path = get_page_path_from_file(site_dir, page_file);
499
+ map.set(id, page_path === '' ? '/' : `/${page_path}`);
500
+ }
501
+ catch {
502
+ // Skip unparseable page files; they'll surface elsewhere in the build.
503
+ }
504
+ }
505
+ return map;
506
+ }
507
+ // Recursively resolve internal `page:` links to their URL. Walks arbitrarily
508
+ // nested content (repeaters, groups, page-lists), so links inside a
509
+ // site-field-referenced repeater resolve the same as top-level link fields.
510
+ //
511
+ // A link value is any object carrying a `page` id. We look the id up in the
512
+ // page URL map and populate `url`:
513
+ // - known page id -> the page's live URL
514
+ // - missing/deleted id -> '' (degrades to href="#" downstream, never crashes)
515
+ // Objects with only `url` (raw/external links) are left untouched, and every
516
+ // other key on the link object (label, etc.) is preserved.
517
+ function resolve_links(value, page_url_map) {
518
+ if (Array.isArray(value)) {
519
+ return value.map((item) => resolve_links(item, page_url_map));
520
+ }
521
+ if (value && typeof value === 'object') {
522
+ const obj = value;
523
+ // A link with a page reference: resolve it to a URL.
524
+ if (typeof obj.page === 'string' && obj.page) {
525
+ const url = page_url_map.get(obj.page) ?? '';
526
+ return { ...obj, url };
527
+ }
528
+ // Otherwise recurse into every value (covers repeater arrays, groups,
529
+ // and the `{ link: {...} }` wrapper repeaters produce).
530
+ const result = {};
531
+ for (const [key, child] of Object.entries(obj)) {
532
+ result[key] = resolve_links(child, page_url_map);
533
+ }
534
+ return result;
535
+ }
536
+ return value;
537
+ }
479
538
  async function load_block_defaults(site_dir, block_name) {
480
539
  const content_path = path.join(site_dir, 'blocks', block_name, 'content.yaml');
481
540
  try {
@@ -3,4 +3,5 @@ interface LoginOptions {
3
3
  email?: string;
4
4
  }
5
5
  export declare function login(options: LoginOptions): Promise<void>;
6
+ export declare function authenticate_interactively(server: string, email?: string): Promise<string | null>;
6
7
  export {};
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import readline from 'readline';
4
4
  import fs from 'fs/promises';
5
5
  import { save_auth_token } from '../utils/auth.js';
6
- import { read_server_config, get_server_config_path } from '../utils/server-config.js';
6
+ import { read_server_config, get_server_config_path, normalize_server_url } from '../utils/server-config.js';
7
7
  export async function login(options) {
8
8
  let server_url = options.server;
9
9
  if (!server_url) {
@@ -29,7 +29,19 @@ export async function login(options) {
29
29
  console.log('');
30
30
  console.log(chalk.bold(`Logging in to ${server}`));
31
31
  console.log('');
32
- let email = options.email;
32
+ const token = await authenticate_interactively(server, options.email);
33
+ if (!token) {
34
+ process.exit(1);
35
+ }
36
+ console.log('');
37
+ console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
38
+ console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
39
+ }
40
+ // Prompt for credentials (email if not supplied, then password), authenticate
41
+ // against the server, and persist the token on success. Returns the token, or
42
+ // null if authentication failed. Shared by `login` and the auto-login prompt
43
+ // that `pull`/`push` fall back to when no token is cached.
44
+ export async function authenticate_interactively(server, email) {
33
45
  if (!email) {
34
46
  email = await prompt('Email: ');
35
47
  }
@@ -50,28 +62,18 @@ export async function login(options) {
50
62
  if (!response.ok) {
51
63
  const error = await response.json().catch(() => ({ message: 'Authentication failed' }));
52
64
  spinner.fail(`Login failed: ${error.message || 'Invalid credentials'}`);
53
- process.exit(1);
65
+ return null;
54
66
  }
55
67
  const data = await response.json();
56
68
  // Save the token
57
69
  await save_auth_token(server, data.token);
58
70
  spinner.succeed(`Logged in as ${chalk.cyan(data.record.email)}`);
59
- console.log('');
60
- console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
61
- console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
71
+ return data.token;
62
72
  }
63
73
  catch (error) {
64
74
  spinner.fail(`Login failed: ${error instanceof Error ? error.message : error}`);
65
- process.exit(1);
66
- }
67
- }
68
- function normalize_server_url(server) {
69
- // Add https:// if no protocol specified
70
- if (!server.startsWith('http://') && !server.startsWith('https://')) {
71
- server = `https://${server}`;
75
+ return null;
72
76
  }
73
- // Remove trailing slash
74
- return server.replace(/\/+$/, '');
75
77
  }
76
78
  function prompt(question) {
77
79
  const rl = readline.createInterface({
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { normalize_server_url } from '../utils/server-config.js';
7
8
  async function detect_server() {
8
9
  const ports = [3000, 8080, 5173];
9
10
  for (const port of ports) {
@@ -27,7 +28,7 @@ export async function pull_library(options) {
27
28
  try {
28
29
  let server;
29
30
  if (options.server) {
30
- server = options.server.replace(/\/+$/, '');
31
+ server = normalize_server_url(options.server);
31
32
  }
32
33
  else {
33
34
  spinner.text = 'Looking for local server...';
@@ -5,8 +5,9 @@ import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
6
  import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
7
7
  import { get_auth_token } from '../utils/auth.js';
8
+ import { authenticate_interactively } from './login.js';
8
9
  import { write_site_config } from '../utils/site-config.js';
9
- import { read_server_config, write_server_config } from '../utils/server-config.js';
10
+ import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
10
11
  async function detect_server() {
11
12
  const ports = [3000, 8080, 5173];
12
13
  for (const port of ports) {
@@ -64,12 +65,15 @@ export async function pull_site(options) {
64
65
  let server;
65
66
  let used_configured = false;
66
67
  if (options.server) {
67
- server = options.server.replace(/\/+$/, '');
68
+ server = normalize_server_url(options.server);
68
69
  }
69
70
  else {
70
71
  const configured = await read_configured_server(process.cwd());
71
72
  if (configured) {
72
- server = configured;
73
+ // A hand-edited server.yaml may hold a bare host; normalize it so
74
+ // is_remote_server() and the inline-login guard below behave the
75
+ // same as they do for a --server flag.
76
+ server = normalize_server_url(configured);
73
77
  used_configured = true;
74
78
  spinner.text = `Using ${server}`;
75
79
  }
@@ -80,8 +84,21 @@ export async function pull_site(options) {
80
84
  spinner.text = `Using ${server}`;
81
85
  }
82
86
  }
83
- // Auth (optional for local)
84
- const token = options.token || await get_auth_token(server);
87
+ // Auth (optional for local). For a remote server with no cached token,
88
+ // prompt for login inline instead of bailing out — the user almost
89
+ // always wants to authenticate and continue rather than re-run.
90
+ let token = options.token || await get_auth_token(server);
91
+ if (!token && is_remote_server(server)) {
92
+ spinner.stop();
93
+ console.log('');
94
+ console.log(chalk.dim(` Not logged in to ${server}. Log in to continue.`));
95
+ console.log('');
96
+ token = await authenticate_interactively(server);
97
+ if (!token) {
98
+ process.exit(1);
99
+ }
100
+ spinner.start('Fetching sites...');
101
+ }
85
102
  const headers = {};
86
103
  if (token) {
87
104
  headers['Authorization'] = `Bearer ${token}`;
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { normalize_server_url } from '../utils/server-config.js';
7
8
  function is_local_server(server) {
8
9
  try {
9
10
  const url = new URL(server);
@@ -28,7 +29,7 @@ export async function push_library(options) {
28
29
  spinner.fail(`Library directory not found at ${chalk.cyan(library_dir)}.`);
29
30
  process.exit(1);
30
31
  }
31
- const server = options.server?.replace(/\/+$/, '');
32
+ const server = options.server ? normalize_server_url(options.server) : undefined;
32
33
  if (!server) {
33
34
  spinner.fail('Server URL required. Pass it as the first argument or use --server.');
34
35
  process.exit(1);
@@ -5,7 +5,7 @@ import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
7
  import { read_site_config, get_site_config_path, SITE_CONFIG_FILE } from '../utils/site-config.js';
8
- import { get_server_config_path, read_server_config } from '../utils/server-config.js';
8
+ import { get_server_config_path, read_server_config, normalize_server_url } from '../utils/server-config.js';
9
9
  async function path_exists(p) {
10
10
  try {
11
11
  await fs.access(p);
@@ -133,7 +133,8 @@ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, opti
133
133
  }
134
134
  library_present = await path_exists(path.join(root_dir, 'library'));
135
135
  }
136
- const server = (options.server || inferred_server)?.replace(/\/+$/, '');
136
+ const server_raw = options.server || inferred_server;
137
+ const server = server_raw ? normalize_server_url(server_raw) : undefined;
137
138
  console.log(` Target server: ${chalk.cyan(server || '(not set — pass --server or set in site.yaml)')}`);
138
139
  console.log('');
139
140
  console.log(chalk.bold(' Will sync:'));
@@ -243,7 +244,8 @@ async function push_single_site(site_dir, options, spinner) {
243
244
  catch {
244
245
  // No config file, must provide options
245
246
  }
246
- const server = (options.server || config?.server)?.replace(/\/+$/, '');
247
+ const server_raw = options.server || config?.server;
248
+ const server = server_raw ? normalize_server_url(server_raw) : undefined;
247
249
  const site_id = options.site || config?.site_id;
248
250
  if (!server) {
249
251
  throw new Error(`Server URL required. Use --server or add server field to ${SITE_CONFIG_FILE}.`);
@@ -362,7 +364,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
362
364
  }
363
365
  async function push_library_dir(root_dir, options, spinner) {
364
366
  // Resolve server: --server > any site.yaml's server (they all point at the same server)
365
- let server = options.server?.replace(/\/+$/, '');
367
+ let server = options.server ? normalize_server_url(options.server) : undefined;
366
368
  if (!server) {
367
369
  const sites_root = path.join(root_dir, 'sites');
368
370
  if (await path_exists(sites_root)) {
@@ -12,6 +12,7 @@ export interface ServerConfig {
12
12
  }
13
13
  export declare const SERVER_CONFIG_FILE = "server.yaml";
14
14
  export declare function get_server_config_path(base_dir: string): string;
15
+ export declare function normalize_server_url(server: string): string;
15
16
  export declare function format_group_name(group_id: string): string;
16
17
  export declare function normalize_server_config(config: ServerConfig): ServerConfig;
17
18
  export declare function resolve_format_options(config: ServerConfig): FormatOptions;
@@ -6,6 +6,17 @@ export const SERVER_CONFIG_FILE = 'server.yaml';
6
6
  export function get_server_config_path(base_dir) {
7
7
  return path.join(base_dir, SERVER_CONFIG_FILE);
8
8
  }
9
+ // Normalize a user-supplied server value: prepend a protocol when missing and
10
+ // strip trailing slashes. Bare localhost/127.0.0.1 stay on http:// (local dev);
11
+ // everything else gets https://.
12
+ export function normalize_server_url(server) {
13
+ let normalized = server.trim();
14
+ if (!/^https?:\/\//.test(normalized)) {
15
+ const is_local = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?(\/|$)/.test(normalized);
16
+ normalized = `${is_local ? 'http' : 'https'}://${normalized}`;
17
+ }
18
+ return normalized.replace(/\/+$/, '');
19
+ }
9
20
  export function format_group_name(group_id) {
10
21
  if (!group_id.trim())
11
22
  return 'Default';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {