@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/LICENSE +15 -0
- package/README.md +97 -0
- package/astro.config.mjs +30 -0
- package/dist/bin.js +1925 -0
- package/package.json +67 -0
- package/theme/components/Block.astro +76 -0
- package/theme/components/Blocks.astro +26 -0
- package/theme/components/Copy.astro +167 -0
- package/theme/components/Mermaid.astro +31 -0
- package/theme/components/Toc.astro +75 -0
- package/theme/components/Wide.astro +77 -0
- package/theme/layouts/Site.astro +74 -0
- package/theme/lib/markdown.ts +115 -0
- package/theme/lib/outline.ts +48 -0
- package/theme/lib/snapshot.ts +49 -0
- package/theme/pages/404.astro +12 -0
- package/theme/pages/index.astro +32 -0
- package/theme/styles/theme.css +543 -0
- package/tsconfig.json +5 -0
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(`${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
|