@apisurf/canonui 0.1.1

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/dist/bin.js ADDED
@@ -0,0 +1,1925 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // src/build.ts
7
+ import { mkdirSync as mkdirSync2, rmSync, writeFileSync } from "node:fs";
8
+ import { randomUUID } from "node:crypto";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join, resolve as resolve3 } from "node:path";
11
+
12
+ // ../core/src/time.ts
13
+ function formatTimestamp(value) {
14
+ return `${new Date(value).toISOString().slice(0, 19)}Z`;
15
+ }
16
+
17
+ // ../core/src/bundle.ts
18
+ var MAX_HEADING = 6;
19
+ function bundleDocument(snapshot, options = {}) {
20
+ const meta = options.meta ?? true;
21
+ const { document, blocks } = snapshot;
22
+ const parts = [];
23
+ if (meta) parts.push(frontMatter(snapshot));
24
+ parts.push(`# ${document.title}`);
25
+ if (document.description) parts.push(document.description);
26
+ if (options.toc) {
27
+ const toc = contents(blocks);
28
+ if (toc) parts.push(toc);
29
+ }
30
+ for (const block of blocks) parts.push(renderBlock(block, 2));
31
+ if (blocks.length === 0) parts.push("_This document has no content yet._");
32
+ return `${parts.join("\n\n")}
33
+ `;
34
+ }
35
+ function frontMatter(snapshot) {
36
+ const d = snapshot.document;
37
+ const lines = [
38
+ "---",
39
+ `document: ${yaml(d.slug)}`,
40
+ `title: ${yaml(d.title)}`,
41
+ ...d.description ? [`description: ${yaml(d.description)}`] : [],
42
+ `blocks: ${snapshot.blocks.length}`,
43
+ `updated: ${formatTimestamp(d.updatedAt)}`,
44
+ `generated: ${formatTimestamp(snapshot.generatedAt)}`,
45
+ "---"
46
+ ];
47
+ return lines.join("\n");
48
+ }
49
+ function yaml(value) {
50
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
51
+ }
52
+ function contents(blocks) {
53
+ const lines = blocks.filter((b) => b.title).map((b) => `- ${b.title}`);
54
+ return lines.length > 0 ? ["## Contents", "", ...lines].join("\n") : null;
55
+ }
56
+ function renderBlock(block, level) {
57
+ const parts = [];
58
+ if (block.title) parts.push(`${"#".repeat(Math.min(level, MAX_HEADING))} ${block.title}`);
59
+ const body = block.content.trim();
60
+ switch (block.type) {
61
+ case "markdown":
62
+ parts.push(shiftHeadings(body, block.title ? level : level - 1));
63
+ break;
64
+ case "text":
65
+ parts.push(body);
66
+ break;
67
+ case "html":
68
+ parts.push(fence(body, "html"));
69
+ break;
70
+ case "mermaid":
71
+ parts.push(fence(body, "mermaid"));
72
+ break;
73
+ case "img": {
74
+ const alt = typeof block.attrs.alt === "string" ? block.attrs.alt : "";
75
+ const caption = typeof block.attrs.caption === "string" && block.attrs.caption ? `
76
+ _${block.attrs.caption}_` : "";
77
+ parts.push(`![${alt}](${body})${caption}`);
78
+ break;
79
+ }
80
+ default:
81
+ parts.push(fence(body, String(block.type)));
82
+ }
83
+ return parts.filter(Boolean).join("\n\n");
84
+ }
85
+ function fence(body, language) {
86
+ const longest = [...body.matchAll(/`+/g)].reduce((max, m) => Math.max(max, m[0].length), 0);
87
+ const ticks = "`".repeat(Math.max(3, longest + 1));
88
+ return `${ticks}${language}
89
+ ${body}
90
+ ${ticks}`;
91
+ }
92
+ function shiftHeadings(markdown, by) {
93
+ if (by <= 0) return markdown;
94
+ let open2 = null;
95
+ return markdown.split("\n").map((line) => {
96
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
97
+ if (open2 !== null) {
98
+ if (marker && marker[0] === open2[0] && marker.length >= open2.length) open2 = null;
99
+ return line;
100
+ }
101
+ if (marker) {
102
+ open2 = marker;
103
+ return line;
104
+ }
105
+ const heading = /^(#{1,6})(\s|$)/.exec(line);
106
+ if (!heading) return line;
107
+ const hashes = heading[1];
108
+ return `${"#".repeat(Math.min(hashes.length + by, MAX_HEADING))}${line.slice(hashes.length)}`;
109
+ }).join("\n");
110
+ }
111
+
112
+ // ../db/src/open.ts
113
+ import { dirname, isAbsolute, resolve } from "node:path";
114
+ import { homedir } from "node:os";
115
+ import { existsSync, mkdirSync } from "node:fs";
116
+ import Database from "better-sqlite3";
117
+
118
+ // ../db/src/schema.ts
119
+ var MIGRATIONS = [
120
+ // ---------------------------------------------------------------------------
121
+ // 001 — the schema
122
+ // ---------------------------------------------------------------------------
123
+ `
124
+ -- The unit of publishing: one document builds into one page.
125
+ CREATE TABLE documents (
126
+ id INTEGER PRIMARY KEY,
127
+ uid TEXT NOT NULL UNIQUE, -- stable external id
128
+ slug TEXT NOT NULL UNIQUE, -- URL-safe name, and how a human refers to it
129
+ title TEXT NOT NULL,
130
+ description TEXT,
131
+ created_at INTEGER NOT NULL, -- epoch ms
132
+ updated_at INTEGER NOT NULL
133
+ );
134
+ CREATE INDEX idx_documents_updated ON documents (updated_at DESC);
135
+ CREATE INDEX idx_documents_created ON documents (created_at DESC);
136
+ -- NOCASE so "list documents titled like Api" does not depend on how it was typed.
137
+ CREATE INDEX idx_documents_title ON documents (title COLLATE NOCASE);
138
+
139
+ -- One renderable chunk. \`content\` is the payload for every type -- the
140
+ -- markdown, the prose, the HTML, the mermaid source, or the image src -- so
141
+ -- that one column means one thing no matter what is being rendered.
142
+ -- Everything type-specific (alt text, a caption, a width) goes in \`attrs\`.
143
+ CREATE TABLE blocks (
144
+ id INTEGER PRIMARY KEY,
145
+ uid TEXT NOT NULL UNIQUE,
146
+ document_id INTEGER NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
147
+ position INTEGER NOT NULL, -- order within the document, 1..N contiguous
148
+ type TEXT NOT NULL, -- 'markdown' | 'text' | 'html' | 'img' | 'mermaid'
149
+ title TEXT, -- optional heading or caption
150
+ content TEXT NOT NULL DEFAULT '',
151
+ attrs TEXT, -- JSON object, or NULL
152
+ created_at INTEGER NOT NULL,
153
+ updated_at INTEGER NOT NULL
154
+ );
155
+ CREATE INDEX idx_blocks_document ON blocks (document_id, position, id);
156
+ CREATE INDEX idx_blocks_type ON blocks (type);
157
+
158
+ -- Full-text over block content and titles. Query it directly:
159
+ -- SELECT block_id FROM block_fts WHERE block_fts MATCH 'retry'
160
+ CREATE VIRTUAL TABLE block_fts USING fts5(
161
+ content,
162
+ title,
163
+ block_id UNINDEXED,
164
+ tokenize = 'unicode61'
165
+ );
166
+
167
+ -- Kept in sync by trigger rather than by the writer: an index that can be
168
+ -- forgotten at one call site is an index nobody can trust.
169
+ CREATE TRIGGER blocks_fts_insert AFTER INSERT ON blocks BEGIN
170
+ INSERT INTO block_fts (block_id, content, title)
171
+ VALUES (new.id, new.content, COALESCE(new.title, ''));
172
+ END;
173
+ CREATE TRIGGER blocks_fts_update AFTER UPDATE ON blocks BEGIN
174
+ DELETE FROM block_fts WHERE block_id = old.id;
175
+ INSERT INTO block_fts (block_id, content, title)
176
+ VALUES (new.id, new.content, COALESCE(new.title, ''));
177
+ END;
178
+ CREATE TRIGGER blocks_fts_delete AFTER DELETE ON blocks BEGIN
179
+ DELETE FROM block_fts WHERE block_id = old.id;
180
+ END;
181
+
182
+ -- ---------------------------------------------------------------------------
183
+ -- Views: the denormalized surface. Normalization is for the query planner;
184
+ -- these are what get, ls and the build snapshot read.
185
+ -- ---------------------------------------------------------------------------
186
+
187
+ CREATE VIEW v_documents AS
188
+ SELECT
189
+ d.id,
190
+ d.uid,
191
+ d.slug,
192
+ d.title,
193
+ d.description,
194
+ (SELECT COUNT(*) FROM blocks b WHERE b.document_id = d.id) AS block_count,
195
+ d.created_at,
196
+ d.updated_at
197
+ FROM documents d;
198
+
199
+ -- \`seq\` is the ordinal computed on read; \`position\` is what is stored and what
200
+ -- sorts. The store keeps positions contiguous, so the two agree -- but only
201
+ -- \`seq\` promises to.
202
+ CREATE VIEW v_blocks AS
203
+ SELECT
204
+ b.id,
205
+ b.uid,
206
+ b.document_id,
207
+ d.slug AS document_slug,
208
+ ROW_NUMBER() OVER (PARTITION BY b.document_id ORDER BY b.position, b.id) AS seq,
209
+ b.position,
210
+ b.type,
211
+ b.title,
212
+ b.content,
213
+ b.attrs,
214
+ LENGTH(b.content) AS size,
215
+ b.created_at,
216
+ b.updated_at
217
+ FROM blocks b
218
+ JOIN documents d ON d.id = b.document_id;
219
+ `
220
+ ];
221
+ var SCHEMA_VERSION = MIGRATIONS.length;
222
+
223
+ // ../db/src/open.ts
224
+ var DEFAULT_DB_SEGMENTS = [".apisurf", "canon"];
225
+ var DEFAULT_DB_FILENAME = "canon.sqlite";
226
+ var DB_ENV_VAR = "CANON_DB";
227
+ function resolveDbPath(options = {}) {
228
+ const cwd = options.cwd ?? process.cwd();
229
+ const raw = options.path ?? process.env[DB_ENV_VAR];
230
+ if (!raw) return resolve(homedir(), ...DEFAULT_DB_SEGMENTS, DEFAULT_DB_FILENAME);
231
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
232
+ }
233
+ function openDb(options = {}) {
234
+ const file = resolveDbPath(options);
235
+ if (options.mustExist && !existsSync(file)) {
236
+ throw new Error(
237
+ `No database at ${file}. Run \`canon new document <title>\` first, or pass --db <path>.`
238
+ );
239
+ }
240
+ if (!options.mustExist) mkdirSync(dirname(file), { recursive: true });
241
+ const db = new Database(file);
242
+ db.pragma("journal_mode = WAL");
243
+ db.pragma("synchronous = NORMAL");
244
+ db.pragma("temp_store = MEMORY");
245
+ db.pragma("cache_size = -65536");
246
+ db.pragma("foreign_keys = ON");
247
+ if (options.skipMigrations) assertMigrated(db, file);
248
+ else applyMigrations(db);
249
+ return db;
250
+ }
251
+ function assertMigrated(db, file) {
252
+ const current = db.pragma("user_version", { simple: true });
253
+ if (current === MIGRATIONS.length) return;
254
+ db.close();
255
+ if (current > MIGRATIONS.length) {
256
+ throw new Error(
257
+ `Database schema version ${current} is newer than this build of canon understands (${MIGRATIONS.length}). Upgrade the CLI.`
258
+ );
259
+ }
260
+ throw new Error(
261
+ `${file} is at schema version ${current}; this build of canon reads ${MIGRATIONS.length}. Reads do not migrate. Run any write against it to bring it forward \u2014 \`canon set document <ref> --db <path>\` will do.`
262
+ );
263
+ }
264
+ function applyMigrations(db) {
265
+ const current = db.pragma("user_version", { simple: true });
266
+ if (current > MIGRATIONS.length) {
267
+ throw new Error(
268
+ `Database schema version ${current} is newer than this build of canon understands (${MIGRATIONS.length}). Upgrade the CLI.`
269
+ );
270
+ }
271
+ for (let version = current; version < MIGRATIONS.length; version++) {
272
+ const sql = MIGRATIONS[version];
273
+ if (!sql) continue;
274
+ db.exec(`BEGIN; ${sql} PRAGMA user_version = ${version + 1}; COMMIT;`);
275
+ }
276
+ }
277
+
278
+ // ../db/src/entities.ts
279
+ var COMPUTED_TYPES = {
280
+ "v_documents.block_count": "INTEGER",
281
+ "v_blocks.seq": "INTEGER",
282
+ "v_blocks.size": "INTEGER"
283
+ };
284
+ function describeRelation(db, name) {
285
+ const rows = db.prepare(`SELECT name, type FROM pragma_table_info(?) ORDER BY cid`).all(name);
286
+ return rows.map((r) => {
287
+ const declared = r.type.trim();
288
+ if (declared) return { name: r.name, type: declared, computed: false };
289
+ return {
290
+ name: r.name,
291
+ type: COMPUTED_TYPES[`${name}.${r.name}`] ?? "",
292
+ computed: true
293
+ };
294
+ });
295
+ }
296
+ var ENTITIES = [
297
+ {
298
+ name: "document",
299
+ plural: "documents",
300
+ view: "v_documents",
301
+ key: "id",
302
+ nameKey: "slug",
303
+ defaultFields: ["id", "slug", "title", "block_count", "created_at", "updated_at"],
304
+ description: "One document: an ordered list of blocks. Builds into one site.",
305
+ filters: {
306
+ title: {
307
+ column: "title",
308
+ mode: "like",
309
+ value: "string",
310
+ help: "Match the title. Substring by default; % and _ work"
311
+ },
312
+ // Creation, to match the order. A window that selected on one date and
313
+ // sorted on another would put rows in an order the filter cannot explain.
314
+ since: {
315
+ column: "created_at",
316
+ mode: "gte",
317
+ value: "number",
318
+ help: "Created at or after this date. YYYY-MM-DD, or epoch ms"
319
+ },
320
+ until: {
321
+ column: "created_at",
322
+ mode: "lte",
323
+ value: "number",
324
+ help: "Created at or before this date"
325
+ }
326
+ },
327
+ // Newest document first. A document's identity is when it started, not when
328
+ // it was last touched -- editing an old one should not move it to the top.
329
+ // The id breaks ties, because two documents created in the same millisecond
330
+ // would otherwise come back in whatever order the planner chose.
331
+ order: "created_at DESC, id DESC",
332
+ derived: {}
333
+ },
334
+ {
335
+ name: "block",
336
+ plural: "blocks",
337
+ view: "v_blocks",
338
+ key: "id",
339
+ // A block has no name. It is the seq-th thing in a document, and that is
340
+ // an address rather than an identity — it changes the moment one moves.
341
+ nameKey: null,
342
+ defaultFields: ["id", "document_id", "seq", "type", "title", "size", "preview"],
343
+ description: "One renderable chunk of a document.",
344
+ filters: {
345
+ document: {
346
+ column: "document_id",
347
+ mode: "eq",
348
+ value: "number",
349
+ help: "Only blocks in this document. Takes an id or a slug"
350
+ },
351
+ type: {
352
+ column: "type",
353
+ mode: "eq",
354
+ value: "string",
355
+ help: "markdown | text | html | img | mermaid"
356
+ }
357
+ },
358
+ order: "document_id DESC, position, id",
359
+ derived: { preview: "content" }
360
+ }
361
+ ];
362
+ function findEntity(name) {
363
+ const wanted = name.toLowerCase();
364
+ return ENTITIES.find((e) => e.name === wanted || e.plural === wanted || e.view === wanted) ?? null;
365
+ }
366
+ function entityFields(db, entity) {
367
+ const columns2 = describeRelation(db, entity.view).map((c) => c.name);
368
+ return [...columns2, ...Object.keys(entity.derived)];
369
+ }
370
+ var UnknownFieldError = class extends Error {
371
+ constructor(entity, field, available2) {
372
+ super(`unknown field "${field}" on ${entity.name}`);
373
+ this.entity = entity;
374
+ this.field = field;
375
+ this.available = available2;
376
+ this.name = "UnknownFieldError";
377
+ }
378
+ };
379
+ var PREVIEW_CHARS = 120;
380
+ function assertFieldsExist(entity, fields, available2) {
381
+ for (const field of fields) {
382
+ if (!available2.includes(field)) {
383
+ throw new UnknownFieldError(entity, field, available2);
384
+ }
385
+ }
386
+ }
387
+ function columnsFor(entity, fields) {
388
+ const columns2 = /* @__PURE__ */ new Set();
389
+ for (const field of fields) {
390
+ columns2.add(entity.derived[field] ?? field);
391
+ }
392
+ if (columns2.size === 0) columns2.add(entity.key);
393
+ return [...columns2];
394
+ }
395
+ function idClause(entity, ids) {
396
+ const numeric2 = (ids ?? []).filter((id) => typeof id === "number");
397
+ const named2 = (ids ?? []).filter((id) => typeof id === "string");
398
+ const clauses = [];
399
+ const params = [];
400
+ if (numeric2.length > 0) {
401
+ clauses.push(`"${entity.key}" IN (${numeric2.map(() => "?").join(", ")})`);
402
+ params.push(...numeric2);
403
+ }
404
+ if (named2.length > 0 && entity.nameKey) {
405
+ clauses.push(`"${entity.nameKey}" IN (${named2.map(() => "?").join(", ")})`);
406
+ params.push(...named2);
407
+ }
408
+ return { sql: clauses.length > 0 ? `(${clauses.join(" OR ")})` : "0", params };
409
+ }
410
+ function whereClause(entity, options) {
411
+ const where = [];
412
+ const params = [];
413
+ if (options.ids && options.ids.length > 0) {
414
+ const clause = idClause(entity, options.ids);
415
+ where.push(clause.sql);
416
+ params.push(...clause.params);
417
+ }
418
+ for (const applied of options.filters ?? []) {
419
+ const def = entity.filters[applied.name];
420
+ if (!def) continue;
421
+ where.push(filterClause(def));
422
+ params.push(applied.value);
423
+ }
424
+ return { where, params };
425
+ }
426
+ function selectEntity(db, entity, options) {
427
+ const available2 = entityFields(db, entity);
428
+ assertFieldsExist(entity, options.fields, available2);
429
+ const columns2 = columnsFor(entity, options.fields);
430
+ const { where, params } = whereClause(entity, options);
431
+ const order = orderClause(entity, options, available2);
432
+ const sql = `SELECT ${columns2.map((c) => `"${c}"`).join(", ")} FROM "${entity.view}"` + (where.length ? ` WHERE ${where.join(" AND ")}` : "") + ` ORDER BY ${order}` + (options.limit !== void 0 && options.limit > 0 ? ` LIMIT ${Number(options.limit)}` : "");
433
+ const rows = db.prepare(sql).all(...params);
434
+ return rows.map((row) => {
435
+ const out = {};
436
+ for (const field of options.fields) {
437
+ const source = entity.derived[field];
438
+ out[field] = source === void 0 ? row[field] : preview(row[source]);
439
+ }
440
+ return out;
441
+ });
442
+ }
443
+ function filterClause(def) {
444
+ switch (def.mode) {
445
+ case "like":
446
+ return `"${def.column}" LIKE ? ESCAPE '\\'`;
447
+ case "gte":
448
+ return `"${def.column}" >= ?`;
449
+ case "lte":
450
+ return `"${def.column}" <= ?`;
451
+ default:
452
+ return `"${def.column}" = ?`;
453
+ }
454
+ }
455
+ function orderClause(entity, options, available2) {
456
+ if (options.sort) {
457
+ if (!available2.includes(options.sort)) {
458
+ throw new UnknownFieldError(entity, options.sort, available2);
459
+ }
460
+ return `"${options.sort}" ${options.desc ? "DESC" : "ASC"}`;
461
+ }
462
+ const scoped = (options.filters ?? []).some((f) => f.name === "document");
463
+ if (entity.name === "block" && scoped) return "position, id";
464
+ return entity.order;
465
+ }
466
+ function preview(value) {
467
+ if (value === null || value === void 0) return null;
468
+ const text = typeof value === "string" ? value : String(value);
469
+ const flat = text.replace(/\s+/g, " ").trim();
470
+ return flat.length <= PREVIEW_CHARS ? flat : `${flat.slice(0, PREVIEW_CHARS)}\u2026`;
471
+ }
472
+
473
+ // ../db/src/store.ts
474
+ var StoreError = class extends Error {
475
+ constructor(message, hint) {
476
+ super(message);
477
+ this.hint = hint;
478
+ this.name = "StoreError";
479
+ }
480
+ };
481
+ var now = () => Date.now();
482
+ function isId(ref) {
483
+ return typeof ref === "number" || /^\d+$/.test(ref);
484
+ }
485
+ function findDocument(db, ref) {
486
+ const sql = isId(ref) ? `SELECT * FROM v_documents WHERE id = ?` : `SELECT * FROM v_documents WHERE slug = ?`;
487
+ return db.prepare(sql).get(isId(ref) ? Number(ref) : ref) ?? null;
488
+ }
489
+ function requireDocument(db, ref) {
490
+ const row = findDocument(db, ref);
491
+ if (!row) throw new StoreError(`no document "${ref}"`, "canon ls documents");
492
+ return row;
493
+ }
494
+ function parseAttrs(raw) {
495
+ if (!raw) return {};
496
+ try {
497
+ const parsed = JSON.parse(raw);
498
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
499
+ } catch {
500
+ return {};
501
+ }
502
+ }
503
+ function snapshotDocument(db, documentId) {
504
+ const document = db.prepare(`SELECT * FROM v_documents WHERE id = ?`).get(documentId);
505
+ if (!document) throw new StoreError(`no document ${documentId}`);
506
+ const blocks = db.prepare(`SELECT * FROM blocks WHERE document_id = ? ORDER BY position, id`).all(documentId);
507
+ return {
508
+ version: 7,
509
+ generatedAt: now(),
510
+ document: {
511
+ uid: document.uid,
512
+ slug: document.slug,
513
+ title: document.title,
514
+ description: document.description,
515
+ createdAt: document.created_at,
516
+ updatedAt: document.updated_at
517
+ },
518
+ blocks: blocks.map((block, index) => ({
519
+ uid: block.uid,
520
+ seq: index + 1,
521
+ type: block.type,
522
+ title: block.title,
523
+ content: block.content,
524
+ attrs: parseAttrs(block.attrs)
525
+ }))
526
+ };
527
+ }
528
+
529
+ // src/db.ts
530
+ function dbPathOf(options) {
531
+ return resolveDbPath({ ...options.db ? { path: options.db } : {}, cwd: options.cwd });
532
+ }
533
+ function openRead(options) {
534
+ const path = dbPathOf(options);
535
+ try {
536
+ return openDb({ path, mustExist: true, skipMigrations: true });
537
+ } catch (err) {
538
+ process.stderr.write(
539
+ `canonui: ${err instanceof Error ? err.message : String(err)}
540
+
541
+ Content is created with canon:
542
+ canon new document "Retry Guide"
543
+ `
544
+ );
545
+ return null;
546
+ }
547
+ }
548
+ function describeStoreError(err) {
549
+ return err.hint ? `${err.message}
550
+ Try: ${err.hint}
551
+ ` : `${err.message}
552
+ `;
553
+ }
554
+
555
+ // src/format.ts
556
+ function renderJson(value) {
557
+ return JSON.stringify(value, null, 2);
558
+ }
559
+ function plural(count, noun) {
560
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
561
+ }
562
+ function isoDateTime(epochMs) {
563
+ return new Date(epochMs).toISOString().slice(0, 16).replace("T", " ");
564
+ }
565
+ function columns(rows, right = []) {
566
+ const width = [];
567
+ for (const row of rows) {
568
+ row.forEach((cell, i) => {
569
+ width[i] = Math.max(width[i] ?? 0, cell.length);
570
+ });
571
+ }
572
+ const flush = new Set(right);
573
+ return rows.map(
574
+ (row) => row.map((cell, i) => flush.has(i) ? cell.padStart(width[i] ?? 0) : cell.padEnd(width[i] ?? 0)).join(" ").trimEnd()
575
+ );
576
+ }
577
+
578
+ // src/render.ts
579
+ import { spawn } from "node:child_process";
580
+ import { readFileSync } from "node:fs";
581
+ import { createRequire } from "node:module";
582
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
583
+ import { fileURLToPath } from "node:url";
584
+ var PACKAGE_ROOT = resolve2(dirname2(fileURLToPath(import.meta.url)), "..");
585
+ function render(options) {
586
+ const astro = astroBin();
587
+ if (!astro) {
588
+ process.stderr.write(
589
+ "canonui: astro is not installed next to this package, so there is nothing to build with.\nReinstall @apisurf/canonui.\n"
590
+ );
591
+ return Promise.resolve(1);
592
+ }
593
+ return run(
594
+ astro,
595
+ {
596
+ CANON_SITE_DATA: options.data,
597
+ CANON_OUT_DIR: options.out,
598
+ ...options.base ? { CANON_BASE: options.base } : {},
599
+ ...options.site ? { CANON_SITE_URL: options.site } : {}
600
+ },
601
+ options.verbose === true
602
+ );
603
+ }
604
+ function astroBin() {
605
+ try {
606
+ const require2 = createRequire(import.meta.url);
607
+ const manifestPath = require2.resolve("astro/package.json");
608
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
609
+ const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.astro;
610
+ if (!entry) return null;
611
+ return resolve2(dirname2(manifestPath), entry);
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+ function run(bin, env, verbose) {
617
+ const child = spawn(process.execPath, [bin, "build"], {
618
+ cwd: PACKAGE_ROOT,
619
+ stdio: verbose ? "inherit" : ["ignore", "ignore", "inherit"],
620
+ env: { ...process.env, ...env, FORCE_COLOR: process.env.FORCE_COLOR ?? "0" }
621
+ });
622
+ return new Promise((done) => {
623
+ child.on("error", (err) => {
624
+ process.stderr.write(`canonui: could not start astro \u2014 ${err.message}
625
+ `);
626
+ done(1);
627
+ });
628
+ child.on("exit", (code) => done(code ?? 0));
629
+ });
630
+ }
631
+
632
+ // src/build.ts
633
+ var BUILDS_DIR = "builds";
634
+ async function build(options) {
635
+ return (await buildSite(options)).code;
636
+ }
637
+ async function buildSite(options) {
638
+ const prepared = prepare(options);
639
+ if (typeof prepared === "number") return { code: prepared, out: "" };
640
+ const { snapshot, out, snapshotPath } = prepared;
641
+ const blocks = snapshot.blocks.length;
642
+ if (options.snapshotOnly) {
643
+ report(options, { snapshot, out: snapshotPath, snapshotPath, blocks, rendered: false });
644
+ return { code: 0, out };
645
+ }
646
+ if (options.global && !options.out) rmSync(out, { recursive: true, force: true });
647
+ const code = await render({
648
+ data: snapshotPath,
649
+ out,
650
+ ...options.base ? { base: options.base } : {},
651
+ ...options.site ? { site: options.site } : {},
652
+ ...options.verbose ? { verbose: true } : {}
653
+ });
654
+ if (code !== 0) {
655
+ process.stderr.write(
656
+ `canonui: the build failed (exit ${code}).` + (options.verbose ? "\n" : " Rerun with --verbose to see why.\n") + `canonui: the snapshot it failed on is at ${snapshotPath}
657
+ `
658
+ );
659
+ return { code, out };
660
+ }
661
+ writeMarkdown(snapshot, out);
662
+ if (!options.snapshot) rmSync(snapshotPath, { force: true });
663
+ report(options, { snapshot, out, snapshotPath, blocks, rendered: true });
664
+ return { code: 0, out };
665
+ }
666
+ function writeMarkdown(snapshot, out) {
667
+ writeFileSync(
668
+ join(out, "index.md"),
669
+ bundleDocument(snapshot, { toc: true, meta: false }),
670
+ "utf8"
671
+ );
672
+ }
673
+ function prepare(options) {
674
+ const db = openRead(options);
675
+ if (!db) return 1;
676
+ try {
677
+ const document = requireDocument(db, options.document);
678
+ const snapshot = snapshotDocument(db, document.id);
679
+ if (snapshot.blocks.length === 0) {
680
+ process.stderr.write(
681
+ `canonui: ${document.slug} has no blocks to build
682
+
683
+ canon new block --document ${document.slug} --file intro.md
684
+ `
685
+ );
686
+ return 1;
687
+ }
688
+ const out = options.out ? absolute(options.out, options.cwd) : options.global ? join(dirname3(dbPathOf(options)), BUILDS_DIR, document.slug) : absolute(join("dist", document.slug), options.cwd);
689
+ const snapshotPath = options.snapshot ? absolute(options.snapshot, options.cwd) : join(tmpdir(), "canonui-build", `${document.slug}-${randomUUID()}.json`);
690
+ mkdirSync2(dirname3(snapshotPath), { recursive: true });
691
+ writeFileSync(snapshotPath, `${renderJson(snapshot)}
692
+ `, "utf8");
693
+ return { snapshot, out, snapshotPath };
694
+ } catch (err) {
695
+ if (err instanceof StoreError) {
696
+ process.stderr.write(`canonui: ${describeStoreError(err)}`);
697
+ return 1;
698
+ }
699
+ throw err;
700
+ } finally {
701
+ db.close();
702
+ }
703
+ }
704
+ function report(options, result) {
705
+ if (options.json) {
706
+ const payload = {
707
+ document: result.snapshot.document.slug,
708
+ out: result.out,
709
+ blocks: result.blocks,
710
+ snapshot: result.snapshotPath
711
+ };
712
+ process.stdout.write(`${renderJson(payload)}
713
+ `);
714
+ return;
715
+ }
716
+ const what = result.rendered ? "built" : "wrote snapshot for";
717
+ const slug = result.snapshot.document.slug;
718
+ process.stdout.write(
719
+ `
720
+ ${what} ${slug}
721
+
722
+ blocks ${result.blocks}
723
+ output ${result.out}
724
+
725
+ ` + (result.rendered && !options.serving ? ` Serve it: canonui serve ${slug}
726
+
727
+ ` : "\n")
728
+ );
729
+ }
730
+ function absolute(path, cwd) {
731
+ return isAbsolute2(path) ? path : resolve3(cwd, path);
732
+ }
733
+
734
+ // src/howto.ts
735
+ var INDEX_BANNER = ` These are worked examples, not your data. The names in them are
736
+ invented \u2014 substitute your own. Nothing here reads or writes your
737
+ database.`;
738
+ var PAGE_BANNER = ` A worked example, not your data. The names in it are invented \u2014
739
+ substitute your own. Nothing on this page has been run, and reading
740
+ it changed nothing.`;
741
+ var HOWTOS = [
742
+ {
743
+ name: "preview",
744
+ summary: "Look at a document in a browser while writing it",
745
+ body: `The loop is two shells: one writing with canon, one looking with canonui.
746
+
747
+ 1. See what there is to build
748
+ canonui ls
749
+
750
+ Prints every document, the slug each answers to and how many blocks
751
+ it holds. canonui's own \u2014 nothing here needs the other command.
752
+
753
+ 2. Build it and open it
754
+ canonui serve retry-guide
755
+
756
+ With only one document in the database, the slug can be left off \u2014
757
+ canonui serve says which one it picked. With several, it prints the
758
+ list instead of an error.
759
+
760
+ Builds into ~/.apisurf/canon/builds/retry-guide, then serves it on
761
+ http://127.0.0.1:3000 \u2014 or the next free port above it \u2014 and holds
762
+ the terminal until Ctrl-C.
763
+
764
+ Loopback only. --host is there for when putting it on the network
765
+ is on purpose.
766
+
767
+ 3. Change something, in another shell
768
+ canon set block 12 --file ./retry-guide.md
769
+ canon mv block 12 --to 1
770
+
771
+ 4. See the change
772
+ Ctrl-C, then canonui serve retry-guide again.
773
+
774
+ There is no watcher. Nothing is cached either, so a build into the
775
+ same folder in a third shell shows up on the next refresh without
776
+ restarting this:
777
+ canonui build retry-guide --out ~/.apisurf/canon/builds/retry-guide
778
+
779
+ Serving what is already built
780
+ canonui open
781
+ canonui open ./public
782
+
783
+ A command of its own, because it is a different question: it takes
784
+ a folder rather than a document and never opens the database. This
785
+ is how to look at output somebody else produced, and how to check
786
+ that what you are about to upload is what you think it is.
787
+
788
+ With nothing after it, it takes the one site under ./dist.
789
+
790
+ A busy port
791
+ canonui serve retry-guide --port 4000
792
+
793
+ Without --port, 3000 through 3020 are tried in turn, so another dev
794
+ server holding 3000 moves this to 3001 rather than failing.
795
+
796
+ Then
797
+ canonui howto publish putting the folder somewhere
798
+ canonui help sites what the folder holds
799
+ canonui help ls listing, in full`
800
+ },
801
+ {
802
+ name: "publish",
803
+ summary: "Build a folder and put it on a host",
804
+ body: `The output is static files with no server requirement, so publishing
805
+ is a copy. What needs deciding first is where the page will live,
806
+ because one of the two answers has to be baked in at build time.
807
+
808
+ At the root of a domain
809
+ canonui build retry-guide --site https://docs.example.com
810
+
811
+ Writes ./dist/retry-guide. Upload the contents. --site only fills in
812
+ the URLs that cannot be relative \u2014 canonical tags and the like \u2014 so
813
+ leaving it off still produces a working site.
814
+
815
+ Under a sub-path
816
+ canonui build retry-guide --base /docs --site https://example.com
817
+
818
+ Bakes the prefix into every internal href. This is a build-time
819
+ decision, not a serving one: a site built without --base and then
820
+ served under /docs has a broken stylesheet.
821
+
822
+ Check it before uploading, mounted at the same prefix:
823
+
824
+ canonui serve retry-guide --base /docs
825
+ canonui open ./dist/retry-guide --base /docs
826
+
827
+ Without --base the server would put the folder at the root, where
828
+ the stylesheet starts with /docs and 404s \u2014 which is the failure
829
+ this is worth checking for.
830
+
831
+ Somewhere in particular
832
+ canonui build retry-guide --out ./public
833
+
834
+ --out defaults to ./dist/<document-slug>. Point it at whatever
835
+ directory the host expects and there is nothing to move afterwards.
836
+
837
+ In CI
838
+ canonui ls --json
839
+ canonui build retry-guide --json
840
+
841
+ The first is the list of documents as objects, for a job that builds
842
+ each in turn. The second prints the document, the output path and
843
+ the block count, and exits 0 or non-zero. --verbose adds Astro's own
844
+ output, which is what to turn on when a build fails.
845
+
846
+ The database is one file, so a pipeline that builds from a checkout
847
+ wants CANON_DB pointing into it:
848
+
849
+ CANON_DB=./docs.sqlite canonui build retry-guide --out ./public
850
+
851
+ What gets built
852
+ Every block in the document. There is no draft state and nothing to
853
+ mark first \u2014 a block that should not ship belongs in another
854
+ document. canon help documents
855
+
856
+ Then
857
+ canonui help sites base paths and the markdown twin
858
+ canonui howto snapshot rendering with something else`
859
+ },
860
+ {
861
+ name: "snapshot",
862
+ summary: "Render the content with something other than this theme",
863
+ body: `A build has two halves that meet at a JSON file. The first reads the
864
+ database and reduces one document to a snapshot; the second reads the
865
+ snapshot and writes the site. Only the first half needs SQLite, and
866
+ only the second half needs Astro.
867
+
868
+ Get the snapshot
869
+ canonui build retry-guide --snapshot-only --snapshot ./doc.json
870
+
871
+ Writes the file and stops. Nothing is rendered, and the theme is
872
+ never started \u2014 so this works the same on a machine that has no
873
+ intention of building a site.
874
+
875
+ What is in it
876
+ The document, and every block in reading order with its type, its
877
+ title, its body and its attributes. Plus a version field, so a
878
+ renderer can refuse a shape it does not know rather than guess.
879
+
880
+ Read it back with anything: jq, a script, another generator.
881
+
882
+ Keep it from a real build
883
+ canonui build retry-guide --snapshot ./doc.json
884
+
885
+ Renders as usual and leaves the snapshot behind instead of deleting
886
+ it. A build that fails does the same without being asked, at the
887
+ path the error names \u2014 the exact input, to look at.
888
+
889
+ The other way out
890
+ canon cat retry-guide > retry-guide.md
891
+
892
+ When what you want is the prose rather than the data. Front matter,
893
+ then the content, in one markdown document.
894
+
895
+ Then
896
+ canonui help snapshot the seam, in more detail
897
+ canon help cat the markdown side`
898
+ }
899
+ ];
900
+ var WIDTH = Math.max(...HOWTOS.map((h) => h.name.length));
901
+ var HOWTO_INDEX = `canonui howto <name> \u2014 worked examples of whole tasks, start to finish.
902
+
903
+ ${INDEX_BANNER}
904
+
905
+ Tasks
906
+ ${HOWTOS.map((h) => ` ${h.name.padEnd(WIDTH)} ${h.summary}`).join("\n")}
907
+
908
+ Usage
909
+ canonui howto <name> print one of them
910
+ canonui howto this list
911
+
912
+ For a command's flags rather than a task, use canonui help <command>.
913
+ Writing the content, rather than rendering it, is canon howto.
914
+ `;
915
+ var HOWTO_NAMES = HOWTOS.map((h) => h.name);
916
+ function howtoPage(name) {
917
+ const found = HOWTOS.find((h) => h.name === name);
918
+ if (!found) return null;
919
+ return `canonui howto ${found.name} \u2014 ${lowerFirst(found.summary)}.
920
+
921
+ ${PAGE_BANNER}
922
+
923
+ ${found.body}
924
+ `;
925
+ }
926
+ function lowerFirst(text) {
927
+ return text.charAt(0).toLowerCase() + text.slice(1);
928
+ }
929
+
930
+ // src/help.ts
931
+ var ROOT_HELP = `canonui \u2014 build and preview the documents that canon holds.
932
+
933
+ Usage
934
+ canonui <command> [options]
935
+
936
+ Commands
937
+ ls What is here, and what to build. Start with this.
938
+ build <document> Render a document into a folder of static HTML.
939
+ serve <document> Build it and serve it over HTTP.
940
+ open [dir] Serve a folder that is already built.
941
+ howto [name] Worked examples of whole tasks, start to finish.
942
+ help [command] One command's flags, or a topic.
943
+
944
+ Model
945
+ canonui reads; canon writes. Both open the same file, and this one opens it
946
+ read-only and unmigrated \u2014 nothing here can change the content it renders.
947
+
948
+ document \u2192 block. One document builds into one page: its blocks top to
949
+ bottom, in the order canon mv block sets, with a contents rail drawn from
950
+ their titles.
951
+
952
+ Next
953
+ canonui ls the documents, and what each one holds
954
+ canonui howto do a thing, start to finish
955
+ canonui help open looking at a folder without the database
956
+ canonui help sites what the built site is, and what it needs
957
+ canonui help snapshot the JSON seam, for another renderer
958
+ canonui <command> --help one command's flags
959
+
960
+ build and serve name the only document when there is only one, and print the
961
+ list to choose from when there is more than one. So canonui ls first is a
962
+ habit, not a requirement.
963
+
964
+ Writing the content
965
+ Everything that creates or edits content is canon, not this:
966
+
967
+ canon new document "Retry Guide"
968
+ canon new block --document retry-guide --file intro.md
969
+ canon help
970
+
971
+ Global options
972
+ --db <path> Database file. Default $CANON_DB, else ~/.apisurf/canon/canon.sqlite
973
+ -h, --help Show help for the command
974
+ -v, --version Print this build's version. --json for the parts
975
+ `;
976
+ var LS_HELP = `canonui ls \u2014 what is in the database, and what to build.
977
+
978
+ Usage
979
+ canonui ls Every document
980
+ canonui ls documents The same
981
+
982
+ The first command to run, and the answer to "what can I build". Prints the
983
+ slugs the other commands take, and ends with the line that acts on them.
984
+
985
+ Not a query. canon ls is the projected, filtered, bounded listing \u2014 --fields,
986
+ --limit, --since, and a row per record. This is a short list to pick from, so
987
+ it takes none of that and prints all of it: a listing you cannot trust to be
988
+ complete is not one you can choose from.
989
+
990
+ Options
991
+ --db <path> Database file. Default $CANON_DB, else ~/.apisurf/canon/canon.sqlite
992
+ --json Emit an array of objects. Prefer this when parsing
993
+ -h, --help Show this help
994
+
995
+ Examples
996
+ canonui ls
997
+ canonui ls --json
998
+
999
+ Then
1000
+ canonui build retry-guide
1001
+ canonui serve retry-guide
1002
+ canon ls blocks --document retry-guide what is inside one document
1003
+ `;
1004
+ var BUILD_HELP = `canonui build \u2014 render a document into a static site.
1005
+
1006
+ Usage
1007
+ canonui build <document> [options]
1008
+
1009
+ Reads the document out of the database, reduces it to a JSON snapshot, and
1010
+ hands that to the bundled Astro theme, which writes HTML, CSS and \u2014 only where a
1011
+ block needs it \u2014 JavaScript. The result has no server requirement: open
1012
+ index.html, or point any static file server at the folder.
1013
+
1014
+ Every block in the document is built. There is no draft state and nothing to
1015
+ mark first; what canon holds is what ships. A document with no blocks is
1016
+ refused, since there would be nothing on the page.
1017
+
1018
+ The document may be left off when there is only one \u2014 it is named on stderr so
1019
+ there is no doubt which was picked. With more than one, the list is printed to
1020
+ choose from rather than a usage error.
1021
+
1022
+ Options
1023
+ --db <path> Database file. Default $CANON_DB, else ~/.apisurf/canon/canon.sqlite
1024
+ --out <dir> Where to write the site. Default ./dist/<document-slug>
1025
+ --base <path> Sub-path the site will be served under, e.g. /docs
1026
+ --site <url> Canonical origin, for absolute URLs in the output
1027
+ --snapshot <path> Keep the snapshot at this path instead of a temporary one
1028
+ --snapshot-only Write the snapshot and stop. No rendering, no Astro
1029
+ --json Print the result as an object
1030
+ --verbose Show Astro's own build output
1031
+ -h, --help Show this help
1032
+
1033
+ Examples
1034
+ canonui build retry-guide
1035
+ canonui build retry-guide --out ./public
1036
+ canonui build retry-guide --base /docs --site https://example.com
1037
+ canonui build retry-guide --snapshot-only --snapshot ./doc.json
1038
+
1039
+ Then
1040
+ canonui serve retry-guide look at it
1041
+ canonui open ./public look at a folder without rebuilding
1042
+ canonui help sites what the folder holds
1043
+ `;
1044
+ var SERVE_HELP = `canonui serve \u2014 build a document and serve it.
1045
+
1046
+ Usage
1047
+ canonui serve <document> [options]
1048
+
1049
+ Builds the document, then serves the folder over HTTP from 127.0.0.1 and holds
1050
+ the terminal until Ctrl-C. Nothing is cached, so a rebuild in another shell
1051
+ shows up on the next refresh. The default browser opens on it once it is
1052
+ listening; --no-open skips that.
1053
+
1054
+ Always builds. To serve a folder that already exists \u2014 output somebody else
1055
+ produced, or the thing you are about to upload \u2014 use canonui open, which takes a
1056
+ directory and never opens the database.
1057
+
1058
+ Interactive: this command does not exit. To render a site programmatically use
1059
+ canonui build, which writes the folder and returns.
1060
+
1061
+ There is no watcher. Content changes through canon, in another shell, and the way
1062
+ to see it is to rerun this \u2014 which is a second of build time and no state to
1063
+ lose.
1064
+
1065
+ The document may be left off when there is only one. With more than one, the
1066
+ list is printed to choose from. canonui ls shows it at any time.
1067
+
1068
+ Options
1069
+ --db <path> Database file. Default $CANON_DB, else ~/.apisurf/canon/canon.sqlite
1070
+ --out <dir> Folder to build into and serve. Default builds/<document-slug>
1071
+ beside the database, replaced on every run
1072
+ --port <n> Port to serve on. Default 3000. Without --port, the next free
1073
+ port up to 3020 is used instead of failing
1074
+ --host <addr> Interface to bind. Default 127.0.0.1 \u2014 loopback only
1075
+ --base <path> Sub-path the site is built for and served under, e.g. /docs
1076
+ --verbose Show Astro's own build output
1077
+ --no-open Don't open the browser
1078
+ -h, --help Show this help
1079
+
1080
+ Examples
1081
+ canonui serve retry-guide
1082
+ canonui serve retry-guide --port 4000
1083
+ canonui serve retry-guide --base /docs
1084
+ canonui serve retry-guide --out ./public
1085
+
1086
+ Then
1087
+ canonui open ./public the same folder, without rebuilding
1088
+ canonui howto preview the write-and-look loop
1089
+ `;
1090
+ var OPEN_HELP = `canonui open \u2014 serve a folder that is already built.
1091
+
1092
+ Usage
1093
+ canonui open [dir] [options]
1094
+
1095
+ Serves a folder of static files over HTTP from 127.0.0.1 and holds the terminal
1096
+ until Ctrl-C. Nothing is built and the database is never opened, so this works
1097
+ on output somebody else produced, on a checkout with no content in it, and on
1098
+ the folder you are about to upload.
1099
+
1100
+ The counterpart to canonui serve, which takes a document and always builds. This
1101
+ takes a directory, and that is the whole difference: one needs the content, the
1102
+ other needs only the files.
1103
+
1104
+ With no directory it looks in ./dist, where builds land. One site there is not
1105
+ a choice worth asking about, so it is used and named; more than one prints the
1106
+ list.
1107
+
1108
+ Options
1109
+ --port <n> Port to serve on. Default 3000. Without --port, the next free
1110
+ port up to 3020 is used instead of failing
1111
+ --host <addr> Interface to bind. Default 127.0.0.1 \u2014 loopback only
1112
+ --base <path> Sub-path the folder was built for, e.g. /docs. Mount it there
1113
+ -h, --help Show this help
1114
+
1115
+ Examples
1116
+ canonui open
1117
+ canonui open ./public
1118
+ canonui open ./dist/retry-guide --port 4000
1119
+ canonui open ./public --base /docs
1120
+
1121
+ A folder built with --base
1122
+ Its links and its stylesheet all start with the prefix, so serving it at the
1123
+ root 404s on everything. Pass the same --base and it is mounted there instead
1124
+ \u2014 which is how to check a sub-path build before it goes anywhere.
1125
+
1126
+ Then
1127
+ canonui serve retry-guide build first, then serve
1128
+ canonui help sites what the folder holds
1129
+ `;
1130
+ var SITES_HELP = `canonui sites \u2014 what a built site is, and what it needs.
1131
+
1132
+ The folder
1133
+ index.html the document: its title, description and every block
1134
+ index.md the same document as markdown
1135
+ 404.html served for anything else
1136
+ _astro/ the stylesheet, and scripts only where a block needs one
1137
+
1138
+ No server behaviour, no build step of its own, no runtime configuration. Open
1139
+ index.html from the filesystem, or hand the folder to any static host.
1140
+
1141
+ The page
1142
+ Blocks render top to bottom in the order canon ls blocks shows. Beside them a
1143
+ contents rail lists the titled blocks and the headings inside markdown
1144
+ blocks, and follows the reader down the page. The rail steps aside on a
1145
+ narrow screen, or when the reader widens the page.
1146
+
1147
+ canon ls blocks --document retry-guide the order, as it will build
1148
+ canon mv block 12 --to 1
1149
+
1150
+ Links
1151
+ A markdown block's links pass through untouched. Each block and each heading
1152
+ has an anchor, so a link to a section of the page is #<id>.
1153
+
1154
+ Serving it under a sub-path
1155
+ canonui build retry-guide --base /docs
1156
+
1157
+ Bakes the prefix into every internal href. A site built without it and then
1158
+ served under /docs has a broken stylesheet, so this is a build-time decision
1159
+ rather than a serving one. Pass the same --base to canonui serve.
1160
+
1161
+ Absolute URLs
1162
+ canonui build retry-guide --site https://example.com
1163
+
1164
+ Only needed for the things that cannot be relative \u2014 canonical tags and the
1165
+ like.
1166
+
1167
+ The markdown twin
1168
+ /index.md is the document canon cat prints, with a contents list and
1169
+ without the front matter. It is what the copy button under the title puts
1170
+ on the clipboard, and what curl gets you without one.
1171
+
1172
+ The button fetches what it copies, so it wants the site served rather than
1173
+ opened off the disk \u2014 canonui open is enough, and so is any host. Everything
1174
+ else on the page works either way.
1175
+
1176
+ Offline
1177
+ A document holding a mermaid block loads mermaid from a copy bundled into the
1178
+ site, not from a CDN, so the output works with no network. Only documents
1179
+ that hold one pay for the library.
1180
+
1181
+ A note on trust
1182
+ html blocks pass through unsanitized and markdown is rendered with inline
1183
+ HTML allowed, because the database is yours and the build runs on your
1184
+ machine. Do not point canonui build at a database somebody else wrote.
1185
+
1186
+ Then
1187
+ canonui howto publish putting the folder somewhere
1188
+ `;
1189
+ var SNAPSHOT_HELP = `canonui snapshot \u2014 the JSON seam, for another renderer.
1190
+
1191
+ A build has two halves. The first reads the database and reduces one document
1192
+ to a snapshot: the document, and every block in order with its type, its title,
1193
+ its body and its attributes. The second reads that snapshot and writes the
1194
+ site, and never opens SQLite at all.
1195
+
1196
+ Normally the seam is invisible \u2014 the snapshot goes to a temporary file and is
1197
+ deleted when the build succeeds. Two flags expose it:
1198
+
1199
+ canonui build retry-guide --snapshot ./doc.json
1200
+ Render as usual, and keep the snapshot.
1201
+
1202
+ canonui build retry-guide --snapshot-only --snapshot ./doc.json
1203
+ Write the snapshot and stop. Nothing is rendered and Astro never runs.
1204
+
1205
+ The shape
1206
+ {
1207
+ "version": 7,
1208
+ "generatedAt": <epoch ms>,
1209
+ "document": { uid, slug, title, description, createdAt, updatedAt },
1210
+ "blocks": [ { uid, seq, type, title, content, attrs }, ... ]
1211
+ }
1212
+
1213
+ What it is for
1214
+ Rendering with something that is not this theme. The snapshot is a plain
1215
+ document with a version field on it, so a renderer can refuse a shape it does
1216
+ not know rather than guess at one.
1217
+
1218
+ It is also what a failed build leaves behind \u2014 the exact input, at the path
1219
+ the error names.
1220
+
1221
+ What it is not
1222
+ A backup, and not an export format. The database is the record, and it is one
1223
+ file you can copy. For the content as prose rather than as data, canon cat gives
1224
+ you one markdown document, and a built site serves the same one at /index.md.
1225
+
1226
+ Then
1227
+ canon help cat the content, as markdown
1228
+ canonui howto snapshot rendering it elsewhere
1229
+ `;
1230
+ var COMMAND_HELP = {
1231
+ build: BUILD_HELP,
1232
+ howto: HOWTO_INDEX,
1233
+ ls: LS_HELP,
1234
+ open: OPEN_HELP,
1235
+ serve: SERVE_HELP,
1236
+ sites: SITES_HELP,
1237
+ snapshot: SNAPSHOT_HELP
1238
+ };
1239
+ function usageLines(command) {
1240
+ const page = COMMAND_HELP[command];
1241
+ if (!page) return [];
1242
+ const lines = page.split("\n");
1243
+ const start = lines.indexOf("Usage");
1244
+ if (start === -1) return [];
1245
+ const out = [];
1246
+ for (const line of lines.slice(start + 1)) {
1247
+ if (line.trim() === "") break;
1248
+ out.push(line);
1249
+ }
1250
+ return out;
1251
+ }
1252
+ var COMMANDS = ["ls", "build", "serve", "open", "howto"];
1253
+ var HELP_TOPICS = ["sites", "snapshot"];
1254
+
1255
+ // src/list.ts
1256
+ function list(options) {
1257
+ const entity = options.entity ?? "documents";
1258
+ if (entity !== "documents") {
1259
+ process.stderr.write(
1260
+ `canonui: there is nothing called "${entity}" to list. Try documents.
1261
+
1262
+ canonui ls
1263
+
1264
+ Blocks are the level canonui never addresses on its own \u2014 canon ls blocks.
1265
+ `
1266
+ );
1267
+ return 1;
1268
+ }
1269
+ const db = openRead(options);
1270
+ if (!db) return 1;
1271
+ try {
1272
+ return listDocuments(db, options);
1273
+ } catch (err) {
1274
+ if (err instanceof StoreError) {
1275
+ process.stderr.write(`canonui: ${describeStoreError(err)}`);
1276
+ return 1;
1277
+ }
1278
+ throw err;
1279
+ } finally {
1280
+ db.close();
1281
+ }
1282
+ }
1283
+ function listDocuments(db, options) {
1284
+ const rows = documentRows(db);
1285
+ if (options.json) {
1286
+ process.stdout.write(`${renderJson(rows)}
1287
+ `);
1288
+ return 0;
1289
+ }
1290
+ if (rows.length === 0) {
1291
+ process.stdout.write(
1292
+ '\n no documents yet\n\n Content is created with canon:\n\n canon new document "Retry Guide"\n\n'
1293
+ );
1294
+ return 0;
1295
+ }
1296
+ const grid = rows.map((r, i) => [
1297
+ String(rows.length - i),
1298
+ isoDateTime(Number(r.created_at)),
1299
+ String(r.slug),
1300
+ plural(Number(r.block_count), "block")
1301
+ ]);
1302
+ process.stdout.write(
1303
+ `
1304
+ ${plural(rows.length, "document")}
1305
+
1306
+ ${columns(grid, [0]).map((line) => ` ${line}`).join("\n")}
1307
+
1308
+ Newest first, by the date each document was created.
1309
+
1310
+ Build one: canonui build <slug>
1311
+
1312
+ `
1313
+ );
1314
+ return 0;
1315
+ }
1316
+ function documentRows(db) {
1317
+ const entity = findEntity("documents");
1318
+ if (!entity) throw new Error("canonui: the documents entity is missing from @canon/db");
1319
+ return selectEntity(db, entity, {
1320
+ fields: ["slug", "title", "block_count", "created_at", "updated_at"]
1321
+ });
1322
+ }
1323
+ function resolveDocument(options, command) {
1324
+ const db = openRead(options);
1325
+ if (!db) return null;
1326
+ try {
1327
+ const rows = documentRows(db);
1328
+ if (rows.length === 1) {
1329
+ const only = String(rows[0]?.slug);
1330
+ process.stderr.write(`canonui: the only document is ${only}
1331
+ `);
1332
+ return only;
1333
+ }
1334
+ if (rows.length === 0) {
1335
+ process.stderr.write(
1336
+ `canonui: there are no documents to ${command}
1337
+
1338
+ canon new document "Retry Guide"
1339
+
1340
+ Content is written with canon; canonui renders it.
1341
+ `
1342
+ );
1343
+ return null;
1344
+ }
1345
+ const grid = rows.map((r) => [
1346
+ String(r.slug),
1347
+ String(r.title),
1348
+ plural(Number(r.block_count), "block")
1349
+ ]);
1350
+ const usage = usageLines(command);
1351
+ process.stderr.write(
1352
+ `canonui: there are ${rows.length} documents \u2014 name the one to ${command}
1353
+
1354
+ ${columns(grid).map((line) => ` ${line}`).join("\n")}
1355
+ ` + (usage.length > 0 ? `
1356
+ Usage
1357
+ ${usage.join("\n")}
1358
+ ` : "") + `
1359
+ Run canonui help ${command} for everything ${command} takes.
1360
+ `
1361
+ );
1362
+ return null;
1363
+ } finally {
1364
+ db.close();
1365
+ }
1366
+ }
1367
+
1368
+ // src/open.ts
1369
+ import { existsSync as existsSync3, readdirSync, statSync as statSync2 } from "node:fs";
1370
+ import { isAbsolute as isAbsolute3, join as join3, relative, resolve as resolve5 } from "node:path";
1371
+
1372
+ // src/http.ts
1373
+ import { spawn as spawn2 } from "node:child_process";
1374
+ import { createReadStream, existsSync as existsSync2, statSync } from "node:fs";
1375
+ import { createServer as createHttpServer } from "node:http";
1376
+ import { createServer as createSocketServer } from "node:net";
1377
+ import { extname, join as join2, normalize, resolve as resolve4, sep } from "node:path";
1378
+ var DEFAULT_HOST = "127.0.0.1";
1379
+ var DEFAULT_PORT = 3e3;
1380
+ var PORT_SCAN = 20;
1381
+ var CONTENT_TYPES = {
1382
+ ".html": "text/html; charset=utf-8",
1383
+ ".css": "text/css; charset=utf-8",
1384
+ ".js": "text/javascript; charset=utf-8",
1385
+ ".mjs": "text/javascript; charset=utf-8",
1386
+ ".json": "application/json; charset=utf-8",
1387
+ ".svg": "image/svg+xml",
1388
+ ".png": "image/png",
1389
+ ".jpg": "image/jpeg",
1390
+ ".jpeg": "image/jpeg",
1391
+ ".gif": "image/gif",
1392
+ ".webp": "image/webp",
1393
+ ".avif": "image/avif",
1394
+ ".ico": "image/x-icon",
1395
+ ".woff": "font/woff",
1396
+ ".woff2": "font/woff2",
1397
+ ".txt": "text/plain; charset=utf-8",
1398
+ ".md": "text/markdown; charset=utf-8",
1399
+ ".xml": "application/xml",
1400
+ ".pdf": "application/pdf"
1401
+ };
1402
+ function contentType(file) {
1403
+ return CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
1404
+ }
1405
+ async function serveDirectory(options) {
1406
+ const { root } = options;
1407
+ const base = normalizeBase(options.base);
1408
+ const host = options.host ?? DEFAULT_HOST;
1409
+ const wanted = options.port ?? DEFAULT_PORT;
1410
+ const listening = await freePort(host, wanted, options.port === void 0 ? PORT_SCAN : 0);
1411
+ if (listening === null) {
1412
+ process.stderr.write(
1413
+ options.port === void 0 ? `canonui: ports ${wanted}-${wanted + PORT_SCAN} are all in use. Pass --port <n> to pick one.
1414
+ ` : `canonui: port ${wanted} is already in use.
1415
+ `
1416
+ );
1417
+ return 1;
1418
+ }
1419
+ const server = createHttpServer((req, res) => {
1420
+ const requested = pathOf(req.url ?? "/");
1421
+ if (requested === null) {
1422
+ notFound(root, res);
1423
+ return;
1424
+ }
1425
+ if (base && (requested === "/" || requested === base)) {
1426
+ res.writeHead(302, { location: `${base}/` });
1427
+ res.end();
1428
+ return;
1429
+ }
1430
+ const unprefixed = unmount(requested, base);
1431
+ const target = unprefixed === null ? null : resolveRequest(root, unprefixed);
1432
+ if (!target) {
1433
+ notFound(root, res);
1434
+ return;
1435
+ }
1436
+ const stats = statSync(target);
1437
+ res.writeHead(200, {
1438
+ "content-type": contentType(target),
1439
+ "content-length": stats.size,
1440
+ // A preview that caches is a preview that lies after the next build.
1441
+ "cache-control": "no-store"
1442
+ });
1443
+ if (req.method === "HEAD") {
1444
+ res.end();
1445
+ return;
1446
+ }
1447
+ createReadStream(target).pipe(res);
1448
+ });
1449
+ await new Promise((done) => server.listen(listening, host, done));
1450
+ process.stdout.write(
1451
+ `
1452
+ canonui ${options.command}
1453
+
1454
+ open http://${host}:${listening}${base}/
1455
+ serving ${root}
1456
+
1457
+ Ctrl-C to stop.
1458
+
1459
+ `
1460
+ );
1461
+ if (options.launch) launchBrowser(`http://${browsable(host)}:${listening}${base}/`);
1462
+ return new Promise(() => {
1463
+ });
1464
+ }
1465
+ function browsable(host) {
1466
+ return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
1467
+ }
1468
+ function launchBrowser(url) {
1469
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1470
+ try {
1471
+ const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
1472
+ child.on("error", () => {
1473
+ });
1474
+ child.unref();
1475
+ } catch {
1476
+ }
1477
+ }
1478
+ function normalizeBase(base) {
1479
+ if (!base) return "";
1480
+ const trimmed = base.replace(/^\/+|\/+$/g, "");
1481
+ return trimmed ? `/${trimmed}` : "";
1482
+ }
1483
+ function pathOf(url) {
1484
+ try {
1485
+ return decodeURIComponent(new URL(url, "http://localhost").pathname);
1486
+ } catch {
1487
+ return null;
1488
+ }
1489
+ }
1490
+ function unmount(pathname, base) {
1491
+ if (!base) return pathname;
1492
+ if (!pathname.startsWith(`${base}/`)) return null;
1493
+ return pathname.slice(base.length);
1494
+ }
1495
+ function resolveRequest(root, pathname) {
1496
+ const target = resolve4(root, `.${normalize(pathname)}`);
1497
+ if (target !== root && !target.startsWith(root + sep)) return null;
1498
+ const candidates = [target, join2(target, "index.html"), `${target}.html`];
1499
+ for (const candidate of candidates) {
1500
+ if (existsSync2(candidate) && statSync(candidate).isFile()) return candidate;
1501
+ }
1502
+ return null;
1503
+ }
1504
+ function notFound(root, res) {
1505
+ const page = join2(root, "404.html");
1506
+ if (existsSync2(page)) {
1507
+ res.writeHead(404, { "content-type": CONTENT_TYPES[".html"] });
1508
+ createReadStream(page).pipe(res);
1509
+ return;
1510
+ }
1511
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1512
+ res.end("404 Not Found\n");
1513
+ }
1514
+ async function freePort(host, from, span) {
1515
+ for (let candidate = from; candidate <= from + span; candidate++) {
1516
+ if (await available(host, candidate)) return candidate;
1517
+ }
1518
+ return null;
1519
+ }
1520
+ function available(host, candidate) {
1521
+ return new Promise((done) => {
1522
+ const probe = createSocketServer();
1523
+ probe.once("error", () => done(false));
1524
+ probe.listen(candidate, host, () => probe.close(() => done(true)));
1525
+ });
1526
+ }
1527
+
1528
+ // src/open.ts
1529
+ var DEFAULT_ROOT = "dist";
1530
+ async function open(options) {
1531
+ const root = resolveRoot(options);
1532
+ if (root === null) return 1;
1533
+ return serveDirectory({
1534
+ root,
1535
+ command: "open",
1536
+ ...options.host !== void 0 ? { host: options.host } : {},
1537
+ ...options.port !== void 0 ? { port: options.port } : {},
1538
+ ...options.base !== void 0 ? { base: options.base } : {}
1539
+ });
1540
+ }
1541
+ function resolveRoot(options) {
1542
+ return options.dir === void 0 ? discover(options.cwd) : named(options.dir, options.cwd);
1543
+ }
1544
+ function named(dir, cwd) {
1545
+ const root = isAbsolute3(dir) ? dir : resolve5(cwd, dir);
1546
+ if (!existsSync3(root)) {
1547
+ process.stderr.write(
1548
+ `canonui: there is no folder at ${root}
1549
+
1550
+ canonui build <document> --out ${dir}
1551
+ `
1552
+ );
1553
+ return null;
1554
+ }
1555
+ if (!isSite(root)) {
1556
+ process.stderr.write(
1557
+ `canonui: ${root} has no index.html, so there is no site in it
1558
+
1559
+ canonui build <document> --out ${dir}
1560
+
1561
+ open serves a folder that is already built; canonui serve builds one first.
1562
+ `
1563
+ );
1564
+ return null;
1565
+ }
1566
+ return root;
1567
+ }
1568
+ function discover(cwd) {
1569
+ const dist = resolve5(cwd, DEFAULT_ROOT);
1570
+ if (!existsSync3(dist)) {
1571
+ process.stderr.write(
1572
+ `canonui: nothing to open \u2014 there is no ${DEFAULT_ROOT}/ here
1573
+
1574
+ canonui build <document>
1575
+
1576
+ Or name a folder: canonui open ./public
1577
+ `
1578
+ );
1579
+ return null;
1580
+ }
1581
+ if (isSite(dist)) return dist;
1582
+ const sites = readdirSync(dist).map((entry) => join3(dist, entry)).filter((path) => statSync2(path).isDirectory() && isSite(path));
1583
+ if (sites.length === 1) {
1584
+ const only = sites[0];
1585
+ process.stderr.write(`canonui: the only site under ${DEFAULT_ROOT}/ is ${here(cwd, only)}
1586
+ `);
1587
+ return only;
1588
+ }
1589
+ if (sites.length === 0) {
1590
+ process.stderr.write(`canonui: ${dist} holds no built site
1591
+
1592
+ canonui build <document>
1593
+ `);
1594
+ return null;
1595
+ }
1596
+ process.stderr.write(
1597
+ `canonui: there are ${sites.length} built sites under ${DEFAULT_ROOT}/ \u2014 name one
1598
+
1599
+ ${sites.map((path) => ` canonui open ${here(cwd, path)}`).join("\n")}
1600
+ `
1601
+ );
1602
+ return null;
1603
+ }
1604
+ function here(cwd, path) {
1605
+ const short = relative(cwd, path);
1606
+ return short && !short.startsWith("..") ? short : path;
1607
+ }
1608
+ function isSite(root) {
1609
+ return existsSync3(join3(root, "index.html"));
1610
+ }
1611
+
1612
+ // src/serve.ts
1613
+ async function serve(options) {
1614
+ const { code, out: root } = await buildSite({
1615
+ document: options.document,
1616
+ cwd: options.cwd,
1617
+ ...options.db !== void 0 ? { db: options.db } : {},
1618
+ ...options.out !== void 0 ? { out: options.out } : { global: true },
1619
+ ...options.base !== void 0 ? { base: options.base } : {},
1620
+ ...options.verbose ? { verbose: true } : {},
1621
+ serving: true
1622
+ });
1623
+ if (code !== 0) return code;
1624
+ return serveDirectory({
1625
+ root,
1626
+ command: "serve",
1627
+ launch: !options.noOpen,
1628
+ ...options.host !== void 0 ? { host: options.host } : {},
1629
+ ...options.port !== void 0 ? { port: options.port } : {},
1630
+ ...options.base !== void 0 ? { base: options.base } : {}
1631
+ });
1632
+ }
1633
+
1634
+ // src/version.ts
1635
+ import { readFileSync as readFileSync2 } from "node:fs";
1636
+ function readPackageVersion() {
1637
+ try {
1638
+ const manifest = readFileSync2(new URL("../package.json", import.meta.url), "utf8");
1639
+ const parsed = JSON.parse(manifest);
1640
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
1641
+ } catch {
1642
+ return "unknown";
1643
+ }
1644
+ }
1645
+ function versionInfo() {
1646
+ return {
1647
+ version: readPackageVersion(),
1648
+ schema: SCHEMA_VERSION,
1649
+ node: process.versions.node
1650
+ };
1651
+ }
1652
+ function formatVersion(info) {
1653
+ return `canonui ${info.version} (schema ${info.schema}, node v${info.node})`;
1654
+ }
1655
+
1656
+ // src/bin.ts
1657
+ var GLOBAL = {
1658
+ db: { type: "string" },
1659
+ help: { type: "boolean", short: "h" }
1660
+ };
1661
+ var CANON_COMMANDS = ["new", "get", "cat", "find", "set", "rm", "mv"];
1662
+ async function main(argv) {
1663
+ const [command, ...rest] = argv;
1664
+ if (!command) {
1665
+ process.stdout.write(ROOT_HELP);
1666
+ return 1;
1667
+ }
1668
+ if (command === "-v" || command === "--version" || command === "version") {
1669
+ const info = versionInfo();
1670
+ process.stdout.write(
1671
+ rest.includes("--json") ? `${JSON.stringify(info, null, 2)}
1672
+ ` : `${formatVersion(info)}
1673
+ `
1674
+ );
1675
+ return 0;
1676
+ }
1677
+ if (command === "-h" || command === "--help" || command === "help") {
1678
+ const topic = rest[0];
1679
+ if (topic && COMMAND_HELP[topic]) {
1680
+ process.stdout.write(COMMAND_HELP[topic]);
1681
+ return 0;
1682
+ }
1683
+ if (topic) {
1684
+ process.stderr.write(
1685
+ `canonui: no help for "${topic}". Commands: ${COMMANDS.join(", ")}
1686
+ Topics: ${HELP_TOPICS.join(", ")}
1687
+ Worked examples: canonui howto
1688
+ `
1689
+ );
1690
+ return 1;
1691
+ }
1692
+ process.stdout.write(ROOT_HELP);
1693
+ return 0;
1694
+ }
1695
+ switch (command) {
1696
+ case "ls":
1697
+ return runList(rest);
1698
+ case "build":
1699
+ return runBuild(rest);
1700
+ case "serve":
1701
+ return runServe(rest);
1702
+ case "open":
1703
+ return runOpen(rest);
1704
+ case "howto":
1705
+ return runHowto(rest);
1706
+ default:
1707
+ if (CANON_COMMANDS.includes(command)) {
1708
+ process.stderr.write(
1709
+ `canonui: "${command}" is a canon command \u2014 canonui renders content, canon writes it.
1710
+
1711
+ canon ${[command, ...rest].join(" ")}
1712
+
1713
+ Run canon help ${command} for what it takes.
1714
+ `
1715
+ );
1716
+ return 1;
1717
+ }
1718
+ process.stderr.write(
1719
+ `canonui: unknown command "${command}". Commands: ${[...COMMANDS, "help"].join(", ")}
1720
+ Run canonui --help
1721
+ `
1722
+ );
1723
+ return 1;
1724
+ }
1725
+ }
1726
+ function runHowto(argv) {
1727
+ const [name] = argv;
1728
+ if (!name || name === "-h" || name === "--help") {
1729
+ process.stdout.write(HOWTO_INDEX);
1730
+ return 0;
1731
+ }
1732
+ const page = howtoPage(name);
1733
+ if (!page) {
1734
+ process.stderr.write(
1735
+ `canonui: no worked example called "${name}". There is one for: ${HOWTO_NAMES.join(", ")}
1736
+ Run canonui howto for what each covers.
1737
+ `
1738
+ );
1739
+ return 1;
1740
+ }
1741
+ process.stdout.write(page);
1742
+ return 0;
1743
+ }
1744
+ function runList(argv) {
1745
+ let parsed;
1746
+ try {
1747
+ parsed = parseArgs({
1748
+ args: argv,
1749
+ allowPositionals: true,
1750
+ options: { ...GLOBAL, json: { type: "boolean" } }
1751
+ });
1752
+ } catch (err) {
1753
+ return usageError(err, "ls");
1754
+ }
1755
+ if (parsed.values.help) {
1756
+ process.stdout.write(COMMAND_HELP.ls ?? ROOT_HELP);
1757
+ return 0;
1758
+ }
1759
+ const [entity] = parsed.positionals;
1760
+ return list({
1761
+ cwd: process.cwd(),
1762
+ ...entity !== void 0 ? { entity } : {},
1763
+ ...pick(parsed.values, ["db", "json"])
1764
+ });
1765
+ }
1766
+ async function runBuild(argv) {
1767
+ let parsed;
1768
+ try {
1769
+ parsed = parseArgs({
1770
+ args: argv,
1771
+ allowPositionals: true,
1772
+ options: {
1773
+ ...GLOBAL,
1774
+ out: { type: "string" },
1775
+ base: { type: "string" },
1776
+ site: { type: "string" },
1777
+ snapshot: { type: "string" },
1778
+ "snapshot-only": { type: "boolean" },
1779
+ json: { type: "boolean" },
1780
+ verbose: { type: "boolean" }
1781
+ }
1782
+ });
1783
+ } catch (err) {
1784
+ return usageError(err, "build");
1785
+ }
1786
+ if (parsed.values.help) {
1787
+ process.stdout.write(COMMAND_HELP.build ?? ROOT_HELP);
1788
+ return 0;
1789
+ }
1790
+ const document = parsed.positionals[0] ?? resolveDocument(dbOf(parsed.values), "build");
1791
+ if (!document) return 1;
1792
+ return build({
1793
+ document,
1794
+ cwd: process.cwd(),
1795
+ ...pick(parsed.values, ["db", "out", "base", "site", "snapshot", "json", "verbose"]),
1796
+ ...parsed.values["snapshot-only"] ? { snapshotOnly: true } : {}
1797
+ });
1798
+ }
1799
+ async function runServe(argv) {
1800
+ let parsed;
1801
+ try {
1802
+ parsed = parseArgs({
1803
+ args: argv,
1804
+ allowPositionals: true,
1805
+ options: {
1806
+ ...GLOBAL,
1807
+ out: { type: "string" },
1808
+ port: { type: "string" },
1809
+ host: { type: "string" },
1810
+ base: { type: "string" },
1811
+ "no-build": { type: "boolean" },
1812
+ verbose: { type: "boolean" },
1813
+ "no-open": { type: "boolean" }
1814
+ }
1815
+ });
1816
+ } catch (err) {
1817
+ return usageError(err, "serve");
1818
+ }
1819
+ if (parsed.values.help) {
1820
+ process.stdout.write(COMMAND_HELP.serve ?? ROOT_HELP);
1821
+ return 0;
1822
+ }
1823
+ if (parsed.values["no-build"]) {
1824
+ const dir = parsed.values.out ?? "./dist/<document>";
1825
+ process.stderr.write(
1826
+ `canonui: --no-build is now canonui open, which takes a folder instead of a document.
1827
+
1828
+ canonui open ${dir}
1829
+
1830
+ Run canonui help open for what it takes.
1831
+ `
1832
+ );
1833
+ return 1;
1834
+ }
1835
+ const document = parsed.positionals[0] ?? resolveDocument(dbOf(parsed.values), "serve");
1836
+ if (!document) return 1;
1837
+ const port = numeric(parsed.values.port, "--port");
1838
+ if (port === null) return 1;
1839
+ if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) {
1840
+ process.stderr.write(`canonui: --port expects a number between 1 and 65535 (got "${port}")
1841
+ `);
1842
+ return 1;
1843
+ }
1844
+ return serve({
1845
+ document,
1846
+ cwd: process.cwd(),
1847
+ ...pick(parsed.values, ["db", "out", "host", "base", "verbose", "no-open"]),
1848
+ ...port !== void 0 ? { port } : {}
1849
+ });
1850
+ }
1851
+ async function runOpen(argv) {
1852
+ let parsed;
1853
+ try {
1854
+ parsed = parseArgs({
1855
+ args: argv,
1856
+ allowPositionals: true,
1857
+ options: {
1858
+ help: { type: "boolean", short: "h" },
1859
+ port: { type: "string" },
1860
+ host: { type: "string" },
1861
+ base: { type: "string" }
1862
+ }
1863
+ });
1864
+ } catch (err) {
1865
+ return usageError(err, "open");
1866
+ }
1867
+ if (parsed.values.help) {
1868
+ process.stdout.write(COMMAND_HELP.open ?? ROOT_HELP);
1869
+ return 0;
1870
+ }
1871
+ const port = numeric(parsed.values.port, "--port");
1872
+ if (port === null) return 1;
1873
+ if (port !== void 0 && (!Number.isInteger(port) || port < 1 || port > 65535)) {
1874
+ process.stderr.write(`canonui: --port expects a number between 1 and 65535 (got "${port}")
1875
+ `);
1876
+ return 1;
1877
+ }
1878
+ const dir = parsed.positionals[0];
1879
+ return open({
1880
+ cwd: process.cwd(),
1881
+ ...dir !== void 0 ? { dir } : {},
1882
+ ...pick(parsed.values, ["host", "base"]),
1883
+ ...port !== void 0 ? { port } : {}
1884
+ });
1885
+ }
1886
+ function dbOf(values) {
1887
+ return { cwd: process.cwd(), ...values.db !== void 0 ? { db: values.db } : {} };
1888
+ }
1889
+ function pick(values, keys) {
1890
+ const out = {};
1891
+ for (const key of keys) {
1892
+ const value = values[key];
1893
+ if (value !== void 0) out[camel(key)] = value;
1894
+ }
1895
+ return out;
1896
+ }
1897
+ var camel = (flag) => flag.replace(/-(\w)/g, (_, c) => c.toUpperCase());
1898
+ function numeric(raw, flag) {
1899
+ if (raw === void 0) return void 0;
1900
+ const value = Number(raw);
1901
+ if (Number.isNaN(value)) {
1902
+ process.stderr.write(`canonui: ${flag} expects a number (got "${raw}")
1903
+ `);
1904
+ return null;
1905
+ }
1906
+ return value;
1907
+ }
1908
+ function usageError(err, command) {
1909
+ process.stderr.write(
1910
+ `canonui: ${err instanceof Error ? err.message : String(err)}
1911
+ Run canonui help ${command} for the options ${command} takes.
1912
+ `
1913
+ );
1914
+ return 1;
1915
+ }
1916
+ main(process.argv.slice(2)).then((code) => {
1917
+ process.exitCode = code;
1918
+ }).catch((err) => {
1919
+ process.stderr.write(
1920
+ `canonui: ${err instanceof Error ? err.stack ?? err.message : String(err)}
1921
+ `
1922
+ );
1923
+ process.exitCode = 1;
1924
+ });
1925
+ //# sourceMappingURL=bin.js.map