sdocs-dev 1.6.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/sdocs-dev.js CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * sdoc CLI
3
+ * sdoc CLI - thin entrypoint.
4
+ *
5
+ * All logic lives in ../lib. This file builds the command router,
6
+ * dispatches on parsed argv, and re-exports the public API for the
7
+ * test suite. Adding a new verb is a one-liner registration in
8
+ * `buildRouter()`; the existing handlers don't move.
9
+ *
4
10
  * Usage:
5
11
  * sdoc report.md # open file in browser
6
12
  * sdoc share report.md # copy shareable link to clipboard
@@ -9,2119 +15,163 @@
9
15
  * sdoc # open studio with empty editor
10
16
  */
11
17
 
12
- const fs = require('fs');
13
- const path = require('path');
14
- const zlib = require('zlib');
15
- const { execSync, execFileSync } = require('child_process');
16
- const SDocYaml = require('../public/sdocs-yaml.js');
17
- const SDocStyles = require('../public/sdocs-styles.js');
18
-
19
- const https = require('https');
20
- const http = require('http');
21
- const crypto = require('crypto');
22
- const os = require('os');
23
- const readline = require('readline');
24
-
25
- const DEFAULT_URL = 'https://sdocs.dev';
26
- const VERSION = require('../package.json').version;
27
-
28
- // ── Update check ─────────────────────────────────────────
29
- // Daily background refresh of the latest version from npm (silent).
30
- // On next invocation, if the cached `latest` is newer, prompt the user
31
- // to install. Y/y runs `npm i -g sdocs-dev` and re-execs their command.
32
-
33
- const UPDATE_CACHE = path.join(os.homedir(), '.sdocs', 'update-check.json');
34
- const SETUP_CACHE = path.join(os.homedir(), '.sdocs', 'setup.json');
35
- const ONE_DAY = 86400000;
36
-
37
- // ── Agent integration block ──────────────────────────────
38
- // IMPORTANT: keep this in sync with the per-agent setup snippets in
39
- // public/sdoc.md (the "Set up your agent" section). If you reword one,
40
- // reword the other.
41
- //
42
- // Release checklist when AGENT_BLOCK_BODY changes:
43
- // 1. Bump AGENT_BLOCK_VERSION below.
44
- // 2. Set AGENT_BLOCK_REASON to a one-line summary of what changed.
45
- // 3. Prepend a new section to public/agent-changes.md.
46
- // 4. Reword public/sdoc.md per-agent snippets to match.
47
-
48
- const AGENT_BLOCK_VERSION = 4;
49
- const AGENT_BLOCK_REASON = 'Add `sdoc diagrams` reference for the new Mermaid render path; tell agents to read it before writing ```mermaid blocks; mention `.mmd` / `.mermaid` standalone files.';
50
-
51
- const AGENT_BLOCK_BODY = `## SDocs
52
-
53
- SDocs (sdocs.dev) renders markdown with clean styling you can adjust.
54
- Content lives in the URL hash - nothing hits a server.
55
-
56
- Use it (or offer it) when the user wants to read, share, or export a \`.md\` file.
57
- Also handy for copying specific code - SDocs code blocks have copy buttons.
58
-
59
- The \`sdoc\` CLI is installed globally:
60
- - \`sdoc file.md\` - open styled in browser (great for easy reading).
61
- - \`sdoc share file.md\` - copy shareable URL to clipboard.
62
- - \`sdoc schema\` - how to adjust all stylable properties (fonts, colors, spacing).
63
- - \`sdoc charts\` - read this before writing a \`\`\`chart block. The JSON shape is specific and easy to get wrong from memory.
64
- - \`sdoc diagrams\` - read this before writing a \`\`\`mermaid block. Covers supported diagram types and the security model. Standalone \`.mmd\` / \`.mermaid\` files also work: \`sdoc graph.mmd\`.
65
- - \`sdoc --help\` - full usage.
66
-
67
- When helping the user themselves, prefer \`sdoc file.md\` over \`sdoc share file.md\`. Share is for sending a link to someone else.
68
-
69
- Source: https://github.com/espressoplease/SDocs
70
- `;
71
-
72
- const AGENT_BLOCK_START_PREFIX = '<!-- sdocs-agent-block:start v=';
73
- const AGENT_BLOCK_START_RE = /<!-- sdocs-agent-block:start v=(\d+) -->/;
74
- const AGENT_BLOCK_END_MARKER = '<!-- sdocs-agent-block:end -->';
75
- const AGENT_BLOCK_LEGACY_OPEN = '<!-- sdocs-agent-block -->';
76
-
77
- const AGENT_CHANGES_URL = 'https://sdocs.dev/agent-changes';
78
- const GITHUB_REPO_URL = 'https://github.com/espressoplease/SDocs';
79
-
80
- function formatAgentBlock(version, body) {
81
- return `${AGENT_BLOCK_START_PREFIX}${version} -->\n${body}${AGENT_BLOCK_END_MARKER}\n`;
82
- }
83
-
84
- const AGENT_TARGETS = [
85
- { name: 'Claude Code', dir: '.claude', file: 'CLAUDE.md' },
86
- { name: 'Codex', dir: '.codex', file: 'AGENTS.md' },
87
- { name: 'Gemini CLI', dir: '.gemini', file: 'GEMINI.md' },
88
- { name: 'opencode', dir: path.join('.config', 'opencode'), file: 'AGENTS.md' },
89
- ];
90
-
91
- // Find a current bookended block. Returns { start, end, version, body } | null.
92
- // Bails on ambiguity (multiple start markers).
93
- function findBookendedBlock(content) {
94
- const startMatch = AGENT_BLOCK_START_RE.exec(content);
95
- if (!startMatch) return null;
96
- const startIdx = startMatch.index;
97
- const startLineEnd = content.indexOf('\n', startIdx);
98
- if (startLineEnd < 0) return null;
99
- const endIdx = content.indexOf(AGENT_BLOCK_END_MARKER, startLineEnd);
100
- if (endIdx < 0) return null;
101
- const endMarkerEnd = endIdx + AGENT_BLOCK_END_MARKER.length;
102
- const trailingNewline = content[endMarkerEnd] === '\n' ? 1 : 0;
103
- const second = content.indexOf(AGENT_BLOCK_START_PREFIX, endMarkerEnd);
104
- if (second >= 0) return null;
105
- return {
106
- start: startIdx,
107
- end: endMarkerEnd + trailingNewline,
108
- version: parseInt(startMatch[1], 10),
109
- body: content.slice(startLineEnd + 1, endIdx),
110
- };
111
- }
112
-
113
- // Find a legacy open-only block (1.4.x format). Returns { start, end, version } | null.
114
- // Only matches bodies whose terminator is the JoshInLisbon URL line, which is the
115
- // known shape of v1 (1.4.0/1.4.1) and v2 (1.4.2). Hand-edited bodies return null.
116
- function findLegacyBlock(content) {
117
- const idx = content.indexOf(AGENT_BLOCK_LEGACY_OPEN);
118
- if (idx < 0) return null;
119
- const second = content.indexOf(AGENT_BLOCK_LEGACY_OPEN, idx + AGENT_BLOCK_LEGACY_OPEN.length);
120
- if (second >= 0) return null;
121
- const terminator = 'Source: https://github.com/JoshInLisbon/SDocs\n';
122
- const termIdx = content.indexOf(terminator, idx);
123
- if (termIdx < 0) return null;
124
- const blockEnd = termIdx + terminator.length;
125
- const region = content.slice(idx, blockEnd);
126
- // Heuristic to recover from-version: v2 added the copy-code line, v1 didn't.
127
- const version = region.includes('Also handy for copying specific code') ? 2 : 1;
128
- return { start: idx, end: blockEnd, version };
129
- }
130
-
131
- // Pure: takes content, returns refresh result.
132
- // { changed: false, reason: 'absent'|'current'|'newer'|'hand_edited' }
133
- // { changed: true, content, fromVersion, toVersion }
134
- function refreshContent(content) {
135
- const bookended = findBookendedBlock(content);
136
- if (bookended) {
137
- if (bookended.version === AGENT_BLOCK_VERSION) {
138
- return { changed: false, reason: 'current' };
139
- }
140
- if (bookended.version > AGENT_BLOCK_VERSION) {
141
- return { changed: false, reason: 'newer' };
142
- }
143
- return {
144
- changed: true,
145
- content: content.slice(0, bookended.start)
146
- + formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
147
- + content.slice(bookended.end),
148
- fromVersion: bookended.version,
149
- toVersion: AGENT_BLOCK_VERSION,
150
- };
151
- }
152
- const legacy = findLegacyBlock(content);
153
- if (!legacy) {
154
- // No block, or unrecognised legacy body. Either is "leave it alone."
155
- return { changed: false, reason: content.includes(AGENT_BLOCK_LEGACY_OPEN) ? 'hand_edited' : 'absent' };
156
- }
157
- return {
158
- changed: true,
159
- content: content.slice(0, legacy.start)
160
- + formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
161
- + content.slice(legacy.end),
162
- fromVersion: legacy.version,
163
- toVersion: AGENT_BLOCK_VERSION,
164
- };
165
- }
166
-
167
- function isNewer(latest, current) {
168
- const a = latest.split('.').map(Number);
169
- const b = current.split('.').map(Number);
170
- for (let i = 0; i < 3; i++) {
171
- if (a[i] > b[i]) return true;
172
- if (a[i] < b[i]) return false;
173
- }
174
- return false;
175
- }
176
-
177
- function readCachedLatest() {
178
- try { return JSON.parse(fs.readFileSync(UPDATE_CACHE, 'utf-8')).latest; }
179
- catch (_) { return null; }
180
- }
181
-
182
- // Self-upgrade: runs npm i -g, then re-execs the same command into the new binary.
183
- // On any failure, falls through (so the user's actual command still runs).
184
- function autoInstallAndReexec(latest) {
185
- console.log(`\nUpdating sdoc ${VERSION} \u2192 ${latest}...`);
186
- try {
187
- execSync('npm i -g sdocs-dev@latest', { stdio: 'pipe' });
188
- } catch (e) {
189
- console.error(`! sdoc auto-update to ${latest} failed: ${(e.stderr || e.message || '').toString().trim().split('\n')[0]}`);
190
- console.error(` Run \`npm i -g sdocs-dev@latest\` manually to upgrade.`);
191
- return false;
192
- }
193
- console.log(`\u2713 sdoc updated ${VERSION} \u2192 ${latest}`);
194
- console.log(` Diff: ${GITHUB_REPO_URL}/compare/v${VERSION}...v${latest}`);
195
- // Re-exec into the new binary so the user's command runs with the new code.
196
- const { spawnSync } = require('child_process');
197
- const r = spawnSync(process.argv0, process.argv.slice(1), { stdio: 'inherit' });
198
- process.exit(r.status == null ? 0 : r.status);
199
- }
200
-
201
- // Single entry point for "there's a newer version on npm" handling.
202
- // Behaviour depends on context:
203
- // - autoInstallUpdates=true: silent self-upgrade + re-exec.
204
- // - interactive TTY: Y/n prompt as today.
205
- // - non-TTY (agent shell): one-line hint to stdout.
206
- async function maybeUpdateBinary() {
207
- if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
208
- const latest = readCachedLatest();
209
- if (!latest || !isNewer(latest, VERSION)) return;
210
-
211
- const state = readSetupState();
212
- const autoInstall = state && state.autoInstallUpdates === true;
213
-
214
- if (autoInstall) {
215
- autoInstallAndReexec(latest);
216
- return;
217
- }
218
-
219
- const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
220
- if (!isInteractive) {
221
- console.log(`Update available: ${VERSION} \u2192 ${latest}. Run \`npm i -g sdocs-dev@latest\` to upgrade.`);
222
- return;
223
- }
224
-
225
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
226
- const answer = await new Promise(resolve => {
227
- rl.question(`\nUpdate available: ${VERSION} \u2192 ${latest}. Install now? [Y/n] `, a => {
228
- rl.close(); resolve(a.trim().toLowerCase());
229
- });
230
- });
231
- if (answer && answer !== 'y' && answer !== 'yes') return;
232
-
233
- console.log('Installing sdocs-dev@latest...');
234
- try {
235
- execSync('npm i -g sdocs-dev@latest', { stdio: 'inherit' });
236
- console.log(`\u2713 Updated to v${latest}`);
237
- } catch (_) {
238
- console.error('Update failed. You may need: sudo npm i -g sdocs-dev');
239
- }
240
- }
241
-
242
- // Daily refresh of the cached `latest` version from npm. Not gated on TTY:
243
- // agents populate the cache too, so the update hint reaches them on next run.
244
- function refreshUpdateCache() {
245
- if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
246
- try {
247
- if (Date.now() - fs.statSync(UPDATE_CACHE).mtimeMs < ONE_DAY) return;
248
- } catch (_) {}
249
-
250
- https.get('https://registry.npmjs.org/-/package/sdocs-dev/dist-tags', { timeout: 3000 }, res => {
251
- let data = '';
252
- res.on('data', chunk => { data += chunk; });
253
- res.on('end', () => {
254
- try {
255
- const latest = JSON.parse(data).latest;
256
- fs.mkdirSync(path.dirname(UPDATE_CACHE), { recursive: true });
257
- fs.writeFileSync(UPDATE_CACHE, JSON.stringify({ latest }));
258
- } catch (_) {}
259
- });
260
- }).on('error', () => {}).on('timeout', function () { this.destroy(); });
261
- }
262
-
263
- // ── Agent setup ──────────────────────────────────────────
264
- // On first interactive run, detect which coding-agent config dirs exist
265
- // and offer to write the SDocs section into each. Tracked in
266
- // ~/.sdocs/setup.json so we never prompt twice. Manually re-runnable
267
- // via `sdoc setup`. Auto-refresh on later upgrades is gated on the
268
- // user's consent during setup.
269
-
270
- const SETUP_SCHEMA_VERSION = 1;
271
-
272
- // Pre-1.5.0 setup.json had no `schemaVersion`. Existing users wrote the block
273
- // (so they want it kept current) but were never asked about auto-install.
274
- function migrateSetupState(raw) {
275
- if (!raw || typeof raw !== 'object') return null;
276
- if (raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
277
- if (raw.schemaVersion && raw.schemaVersion > SETUP_SCHEMA_VERSION) {
278
- // From a future sdoc; treat as unknown and let the user re-consent.
279
- return null;
280
- }
281
- if (!raw.setupCompleted) return null;
282
- return {
283
- schemaVersion: SETUP_SCHEMA_VERSION,
284
- setupCompleted: raw.setupCompleted,
285
- writtenTo: raw.writtenTo || [],
286
- declined: !!raw.declined,
287
- autoRefreshAgentFiles: !raw.declined,
288
- autoInstallUpdates: false,
289
- lastRunVersion: null,
290
- };
291
- }
292
-
293
- function readSetupState() {
294
- let raw;
295
- try { raw = JSON.parse(fs.readFileSync(SETUP_CACHE, 'utf-8')); }
296
- catch (_) { return null; }
297
- if (raw && raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
298
- const migrated = migrateSetupState(raw);
299
- if (migrated) {
300
- writeSetupState(migrated);
301
- return migrated;
302
- }
303
- return null;
304
- }
305
-
306
- function writeSetupState(state) {
307
- try {
308
- fs.mkdirSync(path.dirname(SETUP_CACHE), { recursive: true });
309
- const payload = { schemaVersion: SETUP_SCHEMA_VERSION, ...state };
310
- payload.schemaVersion = SETUP_SCHEMA_VERSION;
311
- fs.writeFileSync(SETUP_CACHE, JSON.stringify(payload, null, 2));
312
- } catch (_) {}
313
- }
314
-
315
- function compareVersions(a, b) {
316
- const A = String(a || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
317
- const B = String(b || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
318
- for (let i = 0; i < 3; i++) {
319
- if ((A[i] || 0) > (B[i] || 0)) return 1;
320
- if ((A[i] || 0) < (B[i] || 0)) return -1;
321
- }
322
- return 0;
323
- }
324
-
325
- function detectAgents() {
326
- const home = os.homedir();
327
- return AGENT_TARGETS
328
- .map(t => ({ ...t, dirPath: path.join(home, t.dir), filePath: path.join(home, t.dir, t.file) }))
329
- .filter(t => fs.existsSync(t.dirPath));
330
- }
331
-
332
- function fileHasBlock(filePath) {
333
- try {
334
- const content = fs.readFileSync(filePath, 'utf-8');
335
- return findBookendedBlock(content) !== null
336
- || content.includes(AGENT_BLOCK_LEGACY_OPEN);
337
- } catch (_) { return false; }
338
- }
339
-
340
- function isSymlink(filePath) {
341
- try { return fs.lstatSync(filePath).isSymbolicLink(); }
342
- catch (_) { return false; }
343
- }
344
-
345
- // Atomic write: tmp file in the SAME directory (so rename can't hit EXDEV),
346
- // then rename. Cleans up the tmp on any error.
347
- function atomicWrite(filePath, content) {
348
- const dir = path.dirname(filePath);
349
- const base = path.basename(filePath);
350
- const tmp = path.join(dir, `.${base}.sdocs.tmp.${process.pid}.${Date.now()}`);
351
- fs.writeFileSync(tmp, content);
352
- try { fs.renameSync(tmp, filePath); }
353
- catch (e) {
354
- try { fs.unlinkSync(tmp); } catch (_) {}
355
- throw e;
356
- }
357
- }
358
-
359
- function backupFile(filePath) {
360
- try {
361
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
362
- fs.copyFileSync(filePath, `${filePath}.sdocs.bak.${stamp}`);
363
- } catch (_) {}
364
- }
365
-
366
- // Best-effort exclusive lock. Returns a release function or null on contention.
367
- // Stale locks (>60s) are reaped.
368
- function acquireLock(filePath) {
369
- const lockPath = `${filePath}.sdocs.lock`;
370
- try {
371
- const fd = fs.openSync(lockPath, 'wx');
372
- try { fs.writeSync(fd, String(process.pid)); } catch (_) {}
373
- fs.closeSync(fd);
374
- return () => { try { fs.unlinkSync(lockPath); } catch (_) {} };
375
- } catch (e) {
376
- if (e.code !== 'EEXIST') return null;
377
- try {
378
- const age = Date.now() - fs.statSync(lockPath).mtimeMs;
379
- if (age > 60000) {
380
- fs.unlinkSync(lockPath);
381
- return acquireLock(filePath);
382
- }
383
- } catch (_) {}
384
- return null;
385
- }
386
- }
387
-
388
- function writeBookendedBlock(filePath) {
389
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
390
- const block = formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY);
391
- if (!fs.existsSync(filePath)) {
392
- atomicWrite(filePath, block);
393
- return;
394
- }
395
- const existing = fs.readFileSync(filePath, 'utf-8');
396
- const prefix = existing.endsWith('\n') ? '\n' : '\n\n';
397
- atomicWrite(filePath, existing + prefix + block);
398
- }
399
-
400
- // Refresh a single agent file. Returns { path, name?, changed, fromVersion?, toVersion?, reason?, error? }.
401
- function refreshAgentFile(filePath, opts = {}) {
402
- if (!fs.existsSync(filePath)) return { path: filePath, changed: false, reason: 'absent' };
403
- if (isSymlink(filePath) && !opts.followSymlinks) return { path: filePath, changed: false, reason: 'symlink' };
404
-
405
- const release = acquireLock(filePath);
406
- if (!release) return { path: filePath, changed: false, reason: 'locked' };
407
-
408
- try {
409
- const content = fs.readFileSync(filePath, 'utf-8');
410
- const result = refreshContent(content);
411
- if (!result.changed) return { path: filePath, changed: false, reason: result.reason };
412
- backupFile(filePath);
413
- atomicWrite(filePath, result.content);
414
- return {
415
- path: filePath, changed: true,
416
- fromVersion: result.fromVersion, toVersion: result.toVersion,
417
- };
418
- } catch (e) {
419
- return { path: filePath, changed: false, error: e.message };
420
- } finally {
421
- release();
422
- }
423
- }
424
-
425
- function refreshAllAgentFiles(opts = {}) {
426
- const home = os.homedir();
427
- return AGENT_TARGETS.map(t => {
428
- const filePath = path.join(home, t.dir, t.file);
429
- return { name: t.name, ...refreshAgentFile(filePath, opts) };
430
- });
431
- }
432
-
433
- function printRefreshSummary(results) {
434
- const changed = results.filter(r => r.changed);
435
- if (changed.length > 0) {
436
- const n = changed.length;
437
- console.log(`✓ SDocs agent block updated to v${AGENT_BLOCK_VERSION} in ${n} ${n === 1 ? 'file' : 'files'}`);
438
- console.log(` Changes: ${AGENT_CHANGES_URL}#v${AGENT_BLOCK_VERSION}`);
439
- }
440
- for (const r of results.filter(r => r.error)) {
441
- console.log(`! ${r.path}: ${r.error}`);
442
- }
443
- for (const r of results.filter(r => r.reason === 'symlink')) {
444
- console.log(`! ${r.path}: symlink, skipped (run \`sdoc setup --follow-symlinks\` to follow)`);
445
- }
446
- for (const r of results.filter(r => r.reason === 'hand_edited')) {
447
- console.log(`! ${r.path}: local edits detected, run \`sdoc setup\` to refresh manually`);
448
- }
449
- }
450
-
451
- function ask(question) {
452
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
453
- return new Promise(resolve => {
454
- rl.question(question, a => { rl.close(); resolve(a.trim().toLowerCase()); });
455
- });
456
- }
457
-
458
- async function askAutoInstallConsent() {
459
- console.log('\nAuto-install sdoc updates when available?');
460
- console.log('');
461
- console.log('This runs `npm i -g sdocs-dev@latest` on your behalf when a new');
462
- console.log('version ships. The output includes a source-diff link so you');
463
- console.log('(or your agent) can verify what was installed.');
464
- console.log('');
465
- console.log('Recommended if you mostly use sdoc through coding agents.');
466
- console.log('');
467
- console.log('Change any time with `sdoc auto-update on` / `sdoc auto-update off`.\n');
468
- const a = await ask('Enable? [Y/n] ');
469
- return !a || a === 'y' || a === 'yes';
470
- }
471
-
472
- async function askAutoRefreshConsent() {
473
- console.log('\nKeep this block updated on future sdoc upgrades?');
474
- console.log('');
475
- console.log('When sdoc adds a feature we sometimes update this section so');
476
- console.log('your agent learns about it. Each time the block changes we');
477
- console.log(`print a notice with a link to ${AGENT_CHANGES_URL}`);
478
- console.log('showing the exact delta - the new wording, and why it changed.');
479
- console.log('');
480
- console.log('Re-run `sdoc setup` any time to change this.\n');
481
- const a = await ask('Enable? [Y/n] ');
482
- return !a || a === 'y' || a === 'yes';
483
- }
484
-
485
- async function runSetup({ force = false } = {}) {
486
- if (!force) {
487
- if (!process.stdout.isTTY || !process.stdin.isTTY) return;
488
- if (process.env.CI || process.env.SDOCS_NO_SETUP) return;
489
- if (readSetupState()) return;
490
- }
491
-
492
- const detected = detectAgents().filter(t => !fileHasBlock(t.filePath));
493
-
494
- if (detected.length === 0) {
495
- // Fallback: ask about opencode if nothing detected and not already set up
496
- const opencodeAlreadyDone = fileHasBlock(path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md'));
497
- if (opencodeAlreadyDone) {
498
- writeSetupState({
499
- setupCompleted: new Date().toISOString(),
500
- writtenTo: [], declined: false,
501
- autoRefreshAgentFiles: true, autoInstallUpdates: false,
502
- lastRunVersion: VERSION,
503
- });
504
- console.log('\nSDocs is already set up in all detected agent configs. Nothing to do.');
505
- return;
506
- }
507
- console.log('\n\u2728\u2500\u2500\u2500\u2500\u2500\u2500\u2500 SDocs setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2728');
508
- console.log('First run only - wire SDocs into your CLI coding agents.\n');
509
- console.log('No coding-agent configs detected.');
510
- const a = await ask('Do you use opencode? [y/N] ');
511
- const writtenTo = [];
512
- let autoRefresh = false;
513
- let autoInstall = false;
514
- if (a === 'y' || a === 'yes') {
515
- const target = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
516
- try { writeBookendedBlock(target); writtenTo.push(target); console.log(`\u2713 Wrote SDocs section to ${target}`); }
517
- catch (e) { console.error(`Failed to write ${target}: ${e.message}`); }
518
- autoRefresh = await askAutoRefreshConsent();
519
- autoInstall = await askAutoInstallConsent();
520
- console.log('Done. Run `sdoc setup` any time to revisit.');
521
- } else {
522
- console.log('Skipped. Run `sdoc setup` any time to revisit.');
523
- }
524
- writeSetupState({
525
- setupCompleted: new Date().toISOString(),
526
- writtenTo, declined: writtenTo.length === 0,
527
- autoRefreshAgentFiles: autoRefresh,
528
- autoInstallUpdates: autoInstall,
529
- lastRunVersion: VERSION,
530
- });
531
- return;
532
- }
533
-
534
- console.log('\n\u2728\u2500\u2500\u2500\u2500\u2500\u2500\u2500 SDocs setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2728');
535
- console.log('First run only - wire SDocs into your CLI coding agents.\n');
536
- console.log('Detected: ' + detected.map(t => t.name).join(', '));
537
- console.log('\nWill append a short SDocs section to:');
538
- for (const t of detected) console.log(' ' + t.filePath);
539
- console.log('\nThese files are loaded into every conversation across all your');
540
- console.log('projects, so SDocs becomes available no matter where you\'re working.');
541
- console.log('');
542
- console.log('You can ask your agent things like:');
543
- console.log(' "write up the plan and sdoc it to me"');
544
- console.log(' "explain async/await to me in a sdoc"');
545
- console.log(' "draft the release notes as a sdoc I can share"');
546
- console.log('');
547
- console.log('This is the best way to work with SDocs');
548
- const RULE = '\u2550'.repeat(36);
549
- console.log(`\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Block to add \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550`);
550
- console.log(AGENT_BLOCK_BODY.trim());
551
- console.log(RULE);
552
-
553
- const a = await ask('\nAdd to all? [Y/n/skip] ');
554
- const skipped = a === 'skip' || (a && a !== 'y' && a !== 'yes');
555
- if (skipped) {
556
- writeSetupState({
557
- setupCompleted: new Date().toISOString(),
558
- writtenTo: [], declined: true,
559
- autoRefreshAgentFiles: false, autoInstallUpdates: false,
560
- lastRunVersion: VERSION,
561
- });
562
- console.log('Skipped. Run `sdoc setup` any time to revisit.');
563
- return;
564
- }
565
-
566
- const writtenTo = [];
567
- for (const t of detected) {
568
- try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`\u2713 ${t.name}: ${t.filePath}`); }
569
- catch (e) { console.error(`\u2717 ${t.name}: ${e.message}`); }
570
- }
571
-
572
- const autoRefresh = writtenTo.length > 0 ? await askAutoRefreshConsent() : false;
573
- const autoInstall = writtenTo.length > 0 ? await askAutoInstallConsent() : false;
574
-
575
- writeSetupState({
576
- setupCompleted: new Date().toISOString(),
577
- writtenTo, declined: false,
578
- autoRefreshAgentFiles: autoRefresh,
579
- autoInstallUpdates: autoInstall,
580
- lastRunVersion: VERSION,
581
- });
582
- console.log('\nDone. Run `sdoc setup` any time to revisit.');
583
- }
584
-
585
- // Auto-refresh existing agent files when the binary version is newer than the
586
- // version that last ran. No prompt: the user already consented during setup.
587
- // Bails on downgrades (block version > shipped version), errors, or partial
588
- // failures (lastRunVersion only advances when every changed file succeeded).
589
- async function maybeAutoRefresh() {
590
- if (process.env.SDOCS_NO_REFRESH) return;
591
- const state = readSetupState();
592
- if (!state) return;
593
- if (!state.autoRefreshAgentFiles) return;
594
- if (compareVersions(VERSION, state.lastRunVersion) <= 0) return;
595
-
596
- const results = refreshAllAgentFiles();
597
- const anyChanged = results.some(r => r.changed);
598
- if (anyChanged) printRefreshSummary(results);
599
-
600
- const anyError = results.some(r => r.error);
601
- if (!anyError) {
602
- writeSetupState({ ...state, lastRunVersion: VERSION });
603
- }
604
- }
605
-
606
- // `sdoc auto-update on|off|status` — flips state.autoInstallUpdates.
607
- function runAutoUpdateSubcommand(arg) {
608
- let state = readSetupState();
609
- if (!state) {
610
- console.log('Run `sdoc setup` first to configure auto-update.');
611
- return;
612
- }
613
- if (arg === 'on') {
614
- writeSetupState({ ...state, autoInstallUpdates: true });
615
- console.log('✓ Auto-install of sdoc updates: on');
616
- return;
617
- }
618
- if (arg === 'off') {
619
- writeSetupState({ ...state, autoInstallUpdates: false });
620
- console.log('✓ Auto-install of sdoc updates: off');
621
- return;
622
- }
623
- console.log(`Auto-install of sdoc updates: ${state.autoInstallUpdates ? 'on' : 'off'}`);
624
- console.log('Use `sdoc auto-update on` or `sdoc auto-update off` to change.');
625
- }
626
-
627
- // ── Help ───────────────────────────────────────────────────
628
- const HELP = `
629
- SDocs CLI
630
- =========
631
- Open, share, and style markdown files from the terminal.
632
-
633
- USAGE
634
- sdoc <file> Open file in browser (read mode)
635
- sdoc <file> --write Open in write mode
636
- sdoc <file> --style Open with style panel
637
- sdoc <file> --raw Open raw markdown source
638
- sdoc <file> --comment Open in comment mode (review/annotate)
639
- sdoc new New blank document (write mode)
640
- sdoc share <file> Copy shareable link to clipboard
641
- sdoc share <file> --section "X" Link with section anchor
642
- sdoc share <file> --short Encrypted /s/<id> short link (see SHORT LINKS)
643
- sdoc schema Print the full styles schema
644
- sdoc charts Chart types, options, and styling guide
645
- sdoc diagrams Mermaid diagrams reference (\`\`\`mermaid blocks)
646
- sdoc comments Comment-format reference (for agents)
647
- sdoc defaults Show ~/.sdocs/styles.yaml
648
- sdoc defaults --reset Remove default styles
649
- sdoc setup Wire SDocs into your coding agents
650
- sdoc auto-update [on|off] Toggle auto-install of sdoc updates
651
- sdoc safe Verify the SDocs server is running the published code
652
- sdoc safe --json Same, machine-readable (for agents)
653
- sdoc safe --audit Same, plus GitHub links to server-side source files
654
- sdoc help Show this help
655
- cat file.md | sdoc Pipe markdown from stdin
656
- cat file.md | sdoc share Pipe to clipboard link
657
-
658
- MODE FLAGS
659
- --read Clean reading view (default when file given)
660
- --write Opens the contentEditable writer
661
- --style Styled preview with style panel visible
662
- --raw Shows raw markdown source
663
- --comment Comment mode: gutter buttons appear on each block; cards
664
- render under blocks that already have comments. Useful both
665
- for human review and for opening files an agent has annotated.
666
-
667
- OPTIONS
668
- --section <heading> Scroll to heading section on load
669
- --light Open in light theme
670
- --dark Open in dark theme
671
- --url <base> Custom base URL (default: https://sdocs.dev)
672
- --mode <m> Alias for --read / --write / --style / --raw / --comment
673
- --short Use the encrypted /s/<id> short-URL form (share
674
- subcommand only). See SHORT LINKS below.
675
- --json Machine-readable output (safe subcommand only).
676
- --audit Also print GitHub links to server-side source
677
- files (safe subcommand only).
678
-
679
- ENVIRONMENT
680
- SDOCS_URL Fallback base URL if --url is not passed.
681
-
682
- FILE INFO CARD
683
- When you \`sdoc <file>\`, the browser shows a small info card
684
- above the document with:
685
- file The filename — included in the share URL.
686
- path Relative path from the cwd — local only.
687
- fullPath Absolute path on your machine — local only.
688
-
689
- Local fields (path, fullPath) are passed to the browser via a
690
- separate URL parameter that JS reads into memory and then strips
691
- from the address bar on load. They never appear in any URL the
692
- user can copy, and \`sdoc share <file>\` never includes them in
693
- the generated link. If someone opens your shared URL, only
694
- \`file\` is visible.
695
-
696
- SHORT LINKS (sdoc share --short)
697
- By default, \`sdoc share <file>\` encodes the document into the URL hash:
698
- \`https://sdocs.dev/#md=<base64url>\`. The whole document lives in the
699
- hash, which the browser does not send to any server.
700
-
701
- \`--short\` produces a shorter, encrypted form: \`https://sdocs.dev/s/<id>#k=<key>\`.
702
-
703
- How it works:
704
- 1. The CLI brotli-compresses the content, generates a 256-bit AES-GCM
705
- key + 96-bit nonce locally, and encrypts the compressed bytes.
706
- 2. The CLI POSTs the ciphertext (nonce + ct + auth tag, base64url) to
707
- /api/short. The server stores it under a random short id and
708
- returns the id. The key NEVER leaves the CLI.
709
- 3. The CLI assembles \`https://sdocs.dev/s/<id>#k=<key>\` and copies
710
- it to the clipboard. The key lives in the URL fragment, which the
711
- browser does not send to the server on page load.
712
- 4. Whoever opens the link: the browser fetches the ciphertext from
713
- /api/short/<id>, reads \`#k=\` from window.location.hash, decrypts
714
- in JavaScript, and renders.
715
-
716
- What the server can see:
717
- - That a ciphertext blob was uploaded under id <id>.
718
- - When it was fetched and from which IP (standard server logs).
719
- What the server cannot see:
720
- - The plaintext document. It does not have the key.
721
- - The key. The key never leaves the URL fragment.
722
-
723
- Trade-offs vs the default \`#md=\` hash form:
724
- - + Shorter URL.
725
- - + Survives URL-length limits (some chat apps truncate at ~2k chars).
726
- - - Requires the server to remain reachable (the default form does not).
727
- - - Stored ciphertext can be deleted by the operator at any time.
728
- - - Server logs reveal access patterns even though contents are encrypted.
729
-
730
- If you don't trust an SDocs operator with even those metadata, use the
731
- default \`sdoc share <file>\` (no --short) which never contacts the
732
- server at all.
733
-
734
- VERIFYING THE SERVER (sdoc safe)
735
- \`sdoc safe\` asks https://sdocs.dev what commit it is running, pulls the
736
- authoritative fingerprint list for that commit from GitHub (published by the
737
- publish-manifest workflow on every push to main), downloads every frontend
738
- file from the host, hashes each one with SHA-256, and compares. Bytes come
739
- from the host; fingerprints come from GitHub. The host cannot produce a
740
- match it did not already publish to GitHub.
741
-
742
- It does not prove anything about server-side code (that runs on a machine
743
- we control). \`sdoc safe --audit\` prints GitHub links to the server files
744
- an agent or human would need to read to audit the rest.
745
-
746
- \`sdoc safe --json\` returns structured output for scripting.
747
-
748
- MATH
749
- Inline $...$ and display $$...$$ are rendered as LaTeX via KaTeX.
750
- Inline: The energy is $E = mc^2$.
751
- Display: $$\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}$$
752
- Supported commands: https://katex.org/docs/supported.html
753
-
754
- STYLED MARKDOWN FORMAT
755
- SDocs extends standard .md files with an optional YAML
756
- front matter block (the same standard used by Jekyll, Hugo, Obsidian).
757
- The \`styles\` key controls every visual aspect of the rendered document.
758
-
759
- ---
760
- title: "My Document"
761
- styles:
762
- fontFamily: Inter
763
- baseFontSize: 16
764
- color: "#1c1917"
765
- h1: { fontSize: 2.2, color: "#1a3a5c", fontWeight: 700 }
766
- p: { lineHeight: 1.85, marginBottom: 1.1 }
767
- ---
768
- # My Document
769
- Content here...
770
-
771
- Colors work in both themes automatically — dark mode versions
772
- are generated by inverting lightness. Use \`dark:\` to override.
773
-
774
- COMMENTS
775
- SDocs files can carry reviewer comments in their YAML front matter
776
- under a \`comments:\` key. Comments do not modify the body — they're
777
- resolved at render time by index lookup with a text-based fallback.
778
- A typical use:
779
- 1. an agent generates a draft .md file
780
- 2. a human reads it via \`sdoc <file> --comment\`, leaves comments
781
- 3. the user copies the .md back to the agent (with comments)
782
- 4. the agent processes the comments and regenerates
783
-
784
- Or the inverse: an agent writes comments into the front matter to
785
- flag uncertainty, and runs \`sdoc <file> --comment\` to surface them
786
- for the human.
787
-
788
- Run \`sdoc comments\` for the full format reference and authoring guide.
789
- Run \`sdoc schema\` for the complete list of style properties.
790
- Run \`sdoc charts\` for chart types, options, and styling.
791
- `;
792
-
793
- const COMMENTS_HELP = `
794
- SDocs — Comments
795
- ================
796
- Reviewer comments are stored in YAML front matter under \`comments:\`.
797
- The body is never modified — anchoring happens at render time.
798
- This makes the format safe for round-tripping through agents and
799
- markdown tooling that doesn't understand SDocs-specific markers.
800
-
801
- WHEN TO USE THIS
802
- Two flows benefit from comments:
803
-
804
- 1. Human reviewing agent output. The agent generates a .md file,
805
- the human runs \`sdoc <file> --comment\`, leaves notes, and pastes
806
- the file (with its YAML) back to the agent. The agent reads
807
- \`comments:\` and acts on each entry.
808
-
809
- 2. Agent flagging uncertainty for a human. The agent writes one or
810
- more comments into the front matter, then opens the file with
811
- \`sdoc <file> --comment\` so the user sees the annotations rendered
812
- beside the relevant blocks.
813
-
814
- OPENING IN COMMENT MODE
815
- sdoc <file> --comment Open in comment mode (or --mode comment)
816
-
817
- Comment mode shows a gutter "+" button beside every top-level block
818
- for adding new comments, and renders existing comments as yellow
819
- sidecar cards beneath their anchored blocks.
820
-
821
- TWO INPUT FORMATS
822
- SDocs accepts comments in two interchangeable formats. Both render
823
- identically in comment mode. Pick whichever is more natural for the
824
- context:
825
-
826
- 1. Markdown footnote format (RECOMMENDED FOR AGENTS).
827
- Standard markdown footnote syntax. The agent edits the body,
828
- adding [^cN] markers where the comment anchors. No counting of
829
- element indices required — anchoring is positional, computed
830
- from the marker's position in the body.
831
-
832
- 2. YAML front-matter format.
833
- The canonical on-disk store. Used by the SDocs UI and round-trip
834
- export. Comments live as a structured list under \`comments:\`.
835
-
836
- At load time, SDocs parses both: footnote markers are lifted out of
837
- the body and merged with the YAML list. On save (round-trip export),
838
- comments are normalised to YAML.
839
-
840
- AUTHORING VIA MARKDOWN FOOTNOTES
841
- Recommended path for agents that produce text. No tag:n counting,
842
- no block_text, just standard markdown. Two patterns:
843
-
844
- Inline (anchor a specific phrase):
845
- Wrap the phrase in [phrase][^cN] and add the definition at the
846
- end of the document.
847
-
848
- The migration was [implemented in three weeks][^c1] this quarter.
849
-
850
- [^c1]: agent - actually slipped to five weeks
851
-
852
- Block (anchor an entire paragraph or heading):
853
- Place a lone [^cN] at the end of the block (after the closing
854
- period) and add the definition at the end.
855
-
856
- The reliability picture was equally encouraging.[^c2]
857
-
858
- [^c2]: agent - need to specify what "incident-free" means
859
-
860
- Definitions support optional author and a [resolved] marker:
861
- [^c3]: priya [resolved] - already addressed
862
- [^c4]: agent - check Q2 numbers (block p:5)
863
-
864
- Only footnote ids matching the cN pattern (c1, c2, ...) are treated
865
- as comments. Other footnote ids (e.g. [^citation1]) keep standard
866
- footnote semantics.
867
-
868
- This format renders sensibly in any markdown viewer — refs as
869
- superscripts, definitions at the bottom — so the file is useful
870
- outside SDocs too.
871
-
872
- COMMENT KINDS
873
- block Anchored to an entire block element (paragraph, heading,
874
- list, code block, table, blockquote).
875
- inline Anchored to a specific text span within a block.
876
-
877
- THE BLOCK ID SCHEME
878
- Both kinds carry a \`block\` field of the form "tag:n":
879
- - tag is the lowercased HTML element name (p, h1, h2, h3, h4,
880
- ul, ol, pre, blockquote, table, plus "chart" for chart blocks).
881
- - n is the 0-indexed position of that element among siblings of
882
- the same tag, in render order across the entire document.
883
-
884
- Examples:
885
- "h2:0" First <h2> in the document.
886
- "p:3" Fourth <p> in render order (ignores headings/lists).
887
- "ul:0" First unordered list.
888
- "pre:1" Second code block.
889
-
890
- Per-tag-type indexing is more resilient to reordering than a single
891
- global ordinal, but indices still drift if blocks of the same type
892
- are inserted upstream. See "Survival hints" below.
893
-
894
- SCHEMA — A FULLY-POPULATED EXAMPLE
895
- ---
896
- title: "Q2 Roadmap (Draft)"
897
- # Comments: block "tag:n" = nth (0-indexed) <tag> in render order.
898
- # block kind may carry block_text (first ~60 chars) as a survival hint when the index drifts.
899
- # inline kind anchors via quote (+ optional prefix/suffix). resolved: true marks addressed.
900
- comments:
901
- - id: c1
902
- kind: block
903
- block: "h2:0"
904
- block_text: "Context"
905
- author: priya
906
- color: "#ffbb00"
907
- at: "2026-04-22T09:14:00Z"
908
- text: "rename this to 'Where Q1 left us' — sharper"
909
- - id: c2
910
- kind: inline
911
- quote: "shipped on time"
912
- prefix: "every committed feature "
913
- suffix: " and within budget"
914
- block: "p:0"
915
- author: priya
916
- color: "#ffbb00"
917
- at: "2026-04-22T09:15:00Z"
918
- text: "auth migration slipped 2 weeks — please correct"
919
- - id: c3
920
- kind: block
921
- block: "p:5"
922
- block_text: "Cost discipline becomes more visible in Q2"
923
- author: priya
924
- color: "#ffbb00"
925
- at: "2026-04-22T09:24:00Z"
926
- text: "align the $180k figure with finance before publishing"
927
- resolved: true
928
- ---
929
-
930
- # Q2 Roadmap (Draft)
931
- ## Context
932
- Q1 closed strong: every committed feature shipped on time and within budget...
933
-
934
- FIELDS
935
- Required for both kinds:
936
- id Stable identifier. Convention: c1, c2, c3...
937
- kind "block" or "inline"
938
- text The reviewer's note (the comment body).
939
-
940
- Required for inline:
941
- quote The exact text span in the rendered body to highlight.
942
-
943
- Optional but recommended:
944
- block The "tag:n" anchor. Used as a fast lookup. Optional
945
- for inline (the quote alone is enough), required for
946
- block (it's the only anchor).
947
- block_text For block kind only. The first ~60 characters of
948
- the block's plain text at the time of writing.
949
- Survival hint: when "tag:n" no longer matches (the
950
- document was edited and indices drifted), readers
951
- fall back to scanning for a block whose start
952
- matches block_text.
953
- prefix For inline kind. Up to 60 chars of the rendered
954
- text immediately before the quote, used to
955
- disambiguate when the quote appears multiple times.
956
- suffix Same as prefix but for the text immediately after.
957
- resolved true if the comment has been addressed. Preserved
958
- for audit; readers should skip resolved comments
959
- when generating action lists.
960
- author Display name on the rendered card. Default: "user".
961
- color Card tint, hex (#rrggbb). Default: "#ffbb00" (yellow).
962
- at ISO 8601 timestamp. Default: now (browser side).
963
-
964
- ID GENERATION
965
- Use c1, c2, c3... in chronological order. To pick the next id, take
966
- the highest cN currently in the file and add 1. Don't reuse ids of
967
- deleted comments — gaps are fine. Non-cN ids are tolerated but lose
968
- the auto-increment guarantee.
969
-
970
- ANCHOR RESOLUTION (HOW READERS RECOVER FROM DRIFT)
971
- When a tool (the SDocs renderer or another agent) loads the file,
972
- each comment is resolved in this order:
973
-
974
- Block kind:
975
- 1. Try \`block: "tag:n"\` exactly.
976
- 2. If found, optionally verify the resolved block's leading text
977
- matches \`block_text\`. If not, fall through.
978
- 3. Search the document for any block whose first ~60 chars start
979
- with \`block_text\`.
980
- 4. Give up — comment is orphaned.
981
-
982
- Inline kind:
983
- 1. Find the block via \`block: "tag:n"\`.
984
- 2. Inside that block, find \`prefix + quote + suffix\`.
985
- 3. Fall back to \`prefix + quote + suffix\` anywhere in the body.
986
- 4. Fall back to \`quote\` alone, anywhere in the body.
987
- 5. Give up — comment is orphaned.
988
-
989
- AUTHORING TIPS FOR AGENTS
990
- - Prefer the markdown-footnote authoring path (above). It avoids
991
- the index-counting work the YAML path requires and is the most
992
- reliable way for an LLM to write a comment that anchors correctly.
993
- - If you do author in YAML directly:
994
- - Compute "tag:n" by counting same-tag elements in render order.
995
- Headings, paragraphs, lists each have their own counters.
996
- - Counting errors are common. The fallback tiers (block_text
997
- for block kind, prefix/suffix or quote-only search for inline)
998
- will rescue an off-by-one index — but only if you populate them.
999
- - For block comments, ALWAYS populate block_text (first ~60 chars
1000
- of the block's plain text).
1001
- - For inline comments, ensure the comment is uniquely resolvable:
1002
- either pick a long unique quote, or populate prefix/suffix.
1003
- - To mark a comment addressed without losing audit trail, set
1004
- \`resolved: true\` (YAML) or add \`[resolved]\` after the author
1005
- name in the footnote definition.
1006
- - When acting on comments, skip those marked resolved — they
1007
- describe past work, not pending requests.
1008
- `;
1009
-
1010
- const SCHEMA = `
1011
- SDocs — Styles Schema
1012
- =====================
1013
- All style values live under the \`styles:\` key in YAML front matter.
1014
- Every property is optional — omit anything you want left at its default.
1015
-
1016
- GENERAL
1017
- fontFamily string Any of the supported fonts (see FONTS below)
1018
- Default: "Inter"
1019
- baseFontSize number Base font size in px. All rem/em values scale from this.
1020
- Default: 16
1021
- background string Page background color (hex).
1022
- Default: "#ffffff" (light) / "#2c2a26" (dark)
1023
- color string Master body text color (hex). Cascades to headings,
1024
- paragraphs, and lists unless those are overridden.
1025
- Default: "#1c1917"
1026
- lineHeight number Global line-height multiplier.
1027
- Default: 1.75
1028
-
1029
- HEADINGS (general heading controls)
1030
- headers:
1031
- scale number Relative size multiplier applied across all heading levels.
1032
- Default: 1.0
1033
- marginBottom number Space below headings (em). Default: 0.4
1034
- color string Heading color — cascades to h1/h2/h3/h4 unless overridden.
1035
- Default: inherits \`color\`
1036
-
1037
- PER-HEADING (each independently overrides the heading defaults above)
1038
- h1: { fontSize: number, color: string, fontWeight: number }
1039
- h2: { fontSize: number, color: string, fontWeight: number }
1040
- h3: { fontSize: number, color: string, fontWeight: number }
1041
- h4: { fontSize: number, color: string, fontWeight: number }
1042
-
1043
- fontSize is in rem (relative to baseFontSize).
1044
- Sensible defaults: h1 2.2, h2 1.55, h3 1.2, h4 1.0
1045
- fontWeight: 400 (regular) · 600 (semibold) · 700 (bold)
1046
-
1047
- PARAGRAPH
1048
- p:
1049
- lineHeight number Line height for body paragraphs. Default: 1.75
1050
- marginBottom number Space between paragraphs (em). Default: 1.1
1051
- color string Paragraph text color. Default: inherits \`color\`
1052
-
1053
- LISTS
1054
- list:
1055
- color string Color for list items and bullet/number markers.
1056
- Default: inherits paragraph color
1057
-
1058
- LINKS
1059
- link:
1060
- color string Link color. Default: "#2563eb"
1061
- decoration string "underline" | "none". Default: "underline"
1062
-
1063
- CODE
1064
- code:
1065
- fontFamily string Monospace font. Default: "ui-monospace, monospace"
1066
- background string Inline/block code background color. Default: "#F1EDE8"
1067
- padding number Inline code padding (em). Default: 0.2
1068
-
1069
- BLOCKQUOTE
1070
- blockquote:
1071
- borderColor string Left border accent color. Default: "#2563eb"
1072
- borderWidth number Left border thickness (px). Default: 3
1073
- background string Quote background color. Default: "#f7f5f2"
1074
- color string Quote text color. Default: "#6b6560"
1075
-
1076
- BLOCKS (shared styling for code, blockquote, and chart blocks)
1077
- blocks:
1078
- background string Background for all block types. Cascades to code,
1079
- blockquote, and chart backgrounds unless overridden.
1080
- color string Text color for all block types. Cascades to code,
1081
- blockquote, and chart text unless overridden.
1082
-
1083
- CHARTS
1084
- chart:
1085
- accent string Palette base color (hex). Default: "#3b82f6"
1086
- palette string Palette mode. Default: "monochrome"
1087
- Options: monochrome, complementary, analogous, triadic,
1088
- pastel, warm, cool, earth
1089
- background string Chart background. Default: inherits blocks.background
1090
- textColor string Chart labels/axes. Default: inherits blocks.color
1091
-
1092
- Run \`sdoc charts\` for the full chart reference — chart types, JSON
1093
- format, axis/legend/annotation options, and per-chart styling overrides.
1094
-
1095
- COLOR CASCADE
1096
- Colors cascade from general → specific:
1097
- color → headers.color → h1.color, h2.color, h3.color, h4.color
1098
- color → p.color → list.color
1099
- blocks.background → code.background, blockquote.background, chart.background
1100
- blocks.color → code.color, blockquote.color, chart.textColor
1101
- Set a child color only when you want it to differ from its parent.
1102
-
1103
- THEME COLORS
1104
- Top-level colors are light-mode colors. Dark mode is auto-generated
1105
- by inverting lightness (same hue, flipped brightness). Light backgrounds
1106
- become dark, dark text becomes light. Colors already very dark (like a
1107
- dark code block background) are kept as-is.
1108
-
1109
- This means you only need to specify colors ONCE:
1110
-
1111
- ---
1112
- styles:
1113
- color: "#2d1810"
1114
- background: "#fdf6f0"
1115
- headers: { color: "#8b2500" }
1116
- blocks:
1117
- background: "#f5e6d8"
1118
- color: "#5a3e2e"
1119
- ---
1120
-
1121
- Dark mode will automatically get inverted versions of all colors above.
1122
-
1123
- To override specific dark-mode colors, add a \`dark:\` block:
1124
-
1125
- ---
1126
- styles:
1127
- color: "#2d1810"
1128
- background: "#fdf6f0"
1129
- blocks:
1130
- background: "#f5e6d8"
1131
- dark:
1132
- background: "#1a1210"
1133
- blocks:
1134
- background: "#2a1a1a"
1135
- ---
1136
-
1137
- Non-color properties (fonts, sizes, spacing, weights) remain at the
1138
- top level and are shared across both themes.
1139
-
1140
- FONTS (24 supported, loaded lazily from Google Fonts)
1141
- Inter · Roboto · Open Sans · Lato · Montserrat · Source Sans 3
1142
- Oswald · Raleway · Poppins · Merriweather · Ubuntu · Nunito
1143
- Playfair Display · Roboto Slab · PT Sans · Lora · Mulish · Noto Sans
1144
- Rubik · Dosis · Josefin Sans · PT Serif · Libre Franklin · Crimson Text
1145
-
1146
- EXAMPLE — editorial article with colored heading tiers
1147
- ---
1148
- styles:
1149
- fontFamily: Lora
1150
- baseFontSize: 17
1151
- background: "#fffaf5"
1152
- color: "#1a1a2e"
1153
- h1: { fontSize: 2.3, fontWeight: 700, color: "#c0392b" }
1154
- h2: { fontSize: 1.55, fontWeight: 600, color: "#8e44ad" }
1155
- h3: { fontSize: 1.2, fontWeight: 600, color: "#16a085" }
1156
- p: { lineHeight: 1.9, marginBottom: 1.2 }
1157
- link: { color: "#e67e22" }
1158
- blocks:
1159
- background: "#faf0eb"
1160
- blockquote: { borderColor: "#c0392b", color: "#7f8c8d" }
1161
- dark:
1162
- background: "#1a1520"
1163
- h1: { color: "#ef6f5e" }
1164
- h2: { color: "#c490e4" }
1165
- blockquote: { borderColor: "#ef6f5e" }
1166
- ---
1167
- `;
1168
-
1169
- const CHARTS_HELP = `
1170
- SDocs — Charts
1171
- ==============
1172
- Render beautiful charts in markdown using \`\`\`chart code blocks.
1173
- Charts are powered by Chart.js, loaded lazily from CDN only when needed.
1174
-
1175
- BASIC SYNTAX
1176
- Wrap a JSON object in a \`\`\`chart fenced code block:
1177
-
1178
- \`\`\`chart
1179
- {
1180
- "type": "bar",
1181
- "title": "Monthly Revenue",
1182
- "labels": ["Jan", "Feb", "Mar"],
1183
- "values": [100, 150, 130]
1184
- }
1185
- \`\`\`
1186
-
1187
- CHART TYPES
1188
- pie Circular segments (use "color" for monochrome shading)
1189
- doughnut Hollow-center pie (alias: donut)
1190
- bar Vertical bars
1191
- horizontal_bar Horizontal bars (alias: hbar)
1192
- stacked_bar Stacked vertical bars
1193
- line Line graph with data points
1194
- area Line with filled area beneath
1195
- stacked_area Multiple filled areas stacked (alias: stacked_line)
1196
- radar Spider/web chart for multi-axis comparison
1197
- polarArea Like pie but equal angles, varying radius
1198
- scatter X/Y point plots
1199
- bubble Like scatter with size dimension
1200
- mixed Combo chart — bar + line on same plot (alias: combo)
1201
-
1202
- DATA FORMATS
1203
- Simple (single dataset):
1204
- "labels": ["A", "B", "C"],
1205
- "values": [10, 20, 15]
1206
-
1207
- Multi-dataset:
1208
- "labels": ["Q1", "Q2"],
1209
- "datasets": [
1210
- { "label": "2024", "values": [10, 20] },
1211
- { "label": "2025", "values": [12, 25] }
1212
- ]
1213
-
1214
- Scatter/Bubble:
1215
- "datasets": [
1216
- { "label": "Group", "data": [{"x": 1, "y": 2}, {"x": 3, "y": 5}] }
1217
- ]
1218
-
1219
- CHART OPTIONS
1220
- title string Chart heading
1221
- subtitle string Smaller text below title
1222
- labels string[] Category labels
1223
- values number[] Data for a single dataset
1224
- datasets array Multiple datasets (see above)
1225
- color string Single accent color (hex)
1226
- colors string[] Per-segment/bar custom colors
1227
-
1228
- AXIS OPTIONS
1229
- xAxis / xLabel string X-axis label
1230
- yAxis / yLabel string Y-axis label
1231
- y2Axis string Right y-axis label (enables dual axis)
1232
- min number Minimum value on value axis
1233
- max number Maximum value on value axis
1234
- stepSize number Tick interval
1235
- beginAtZero boolean Default true. Set false for auto-range.
1236
-
1237
- NUMBER FORMATTING
1238
- format string "currency" ($), "euro" (€), "pound" (£),
1239
- "percent" (%), "comma" (1,000)
1240
- prefix string Custom value prefix (e.g. "£")
1241
- suffix string Custom value suffix (e.g. " kg", "°C")
1242
- y2Format string Format for right y-axis
1243
- y2Prefix string Prefix for right y-axis
1244
- y2Suffix string Suffix for right y-axis
1245
-
1246
- DISPLAY OPTIONS
1247
- legend boolean Show/hide legend (auto by default)
1248
- legendPosition string "top", "bottom" (default), "left", "right"
1249
- dataLabels boolean Show values on chart (default true). Set false for clean look.
1250
- aspectRatio number Width/height ratio (e.g. 2 for wide, 0.8 for tall)
1251
- stacked boolean Force stacking on bar/line charts
1252
-
1253
- DATASET OPTIONS (inside each dataset object)
1254
- label string Name shown in legend
1255
- values number[] Data points
1256
- data object[] For scatter: [{x, y}], for bubble: [{x, y, r}]
1257
- color string Dataset color (hex)
1258
- colors string[] Per-bar colors within dataset
1259
- type string Override type in mixed charts ("bar" or "line")
1260
- yAxisID string "y" (left) or "y2" (right) for dual-axis charts
1261
- fill boolean Fill area under line
1262
- tension number Line smoothing (0 = straight, 0.4 = smooth)
1263
- order number Draw order (lower = rendered on top)
1264
-
1265
- ANNOTATIONS (reference lines)
1266
- "annotations": [
1267
- { "y": 60, "label": "Target", "color": "#ef4444" },
1268
- { "x": "Mar", "label": "Launch", "dashed": true }
1269
- ]
1270
-
1271
- y / x number/string Position of the reference line
1272
- label string Text label on the line
1273
- color string Line color
1274
- width number Line thickness (default 2)
1275
- dashed boolean Dashed style (default true)
1276
- position string Label position: "start", "center", "end"
1277
-
1278
- CHART STYLING (via front matter or style panel)
1279
- Charts inherit background and text colors from the block cascade:
1280
-
1281
- ---
1282
- styles:
1283
- blocks:
1284
- background: "#1a1a2e" # all blocks: code, blockquote, charts
1285
- color: "#c8c3bc" # text in all blocks
1286
- chart:
1287
- accent: "#6366f1" # palette base color
1288
- palette: monochrome # palette generation mode
1289
- background: "#0e4a1a" # override blocks.background for charts only
1290
- textColor: "#c8f0d8" # override blocks.color for charts only
1291
- ---
1292
-
1293
- COLOR CASCADE FOR BLOCKS
1294
- blocks.background → code.background, blockquote.background, chart.background
1295
- blocks.color → code.color, blockquote.color, chart.textColor
1296
- Set a child value only when you want it to differ from the parent.
1297
-
1298
- DARK MODE
1299
- All colors auto-generate dark-mode counterparts (lightness inverted).
1300
- Add a \`dark:\` block to override specific values:
1301
- dark:
1302
- blocks:
1303
- background: "#2a1a1a"
1304
-
1305
- PALETTE MODES
1306
- monochrome Same hue, varying lightness (default)
1307
- complementary Hues spread evenly around the color wheel
1308
- analogous Neighboring hues for a harmonious feel
1309
- triadic Three base hues 120° apart
1310
- pastel Soft, light colors
1311
- warm Reds, oranges, yellows
1312
- cool Blues, teals, purples
1313
- earth Browns, olives, muted greens
1314
-
1315
- Per-chart override: set "accent" and/or "palette" directly in the chart JSON.
1316
- Per-chart colors: set "colors": ["#hex", ...] to override the palette entirely.
1317
- Single-color pie: set "color": "#hex" on a pie/doughnut for monochrome shading.
1318
-
1319
- MIXED CHART EXAMPLE (dual y-axis)
1320
- \`\`\`chart
1321
- {
1322
- "type": "mixed",
1323
- "title": "Revenue vs Growth",
1324
- "labels": ["Q1", "Q2", "Q3", "Q4"],
1325
- "datasets": [
1326
- { "label": "Revenue", "type": "bar", "values": [50, 65, 80, 95], "yAxisID": "y" },
1327
- { "label": "Growth", "type": "line", "values": [12, 30, 23, 19], "yAxisID": "y2" }
1328
- ],
1329
- "yAxis": "Revenue ($M)",
1330
- "y2Axis": "Growth %",
1331
- "format": "currency",
1332
- "y2Format": "percent"
1333
- }
1334
- \`\`\`
1335
- `;
1336
-
1337
- const DIAGRAMS_HELP = `
1338
- SDocs — Diagrams
1339
- ================
1340
- Render Mermaid diagrams in markdown using \`\`\`mermaid code blocks.
1341
- Mermaid is loaded lazily from CDN only when a diagram is present.
1342
-
1343
- BASIC SYNTAX
1344
- \`\`\`mermaid
1345
- graph TD
1346
- A[Start] --> B{Decision}
1347
- B -- yes --> C[Do this]
1348
- B -- no --> D[Do that]
1349
- \`\`\`
1350
-
1351
- STANDALONE .mmd FILES
1352
- \`sdoc graph.mmd\` works like \`sdoc file.md\` - the CLI wraps the
1353
- contents in a \`\`\`mermaid fence before opening. Same for share:
1354
- \`sdoc share graph.mmd\`. \`.mermaid\` files work the same way.
1355
-
1356
- SUPPORTED DIAGRAM TYPES
1357
- flowchart / graph flowchart TD, LR, etc.
1358
- sequenceDiagram interaction sequences
1359
- classDiagram UML-style class relationships
1360
- stateDiagram-v2 state machines
1361
- erDiagram entity-relationship
1362
- gantt timelines
1363
- pie proportional breakdown
1364
- journey user-journey diagrams
1365
- gitGraph git history visualisation
1366
- mindmap mind maps
1367
- timeline chronological events
1368
- quadrantChart 2x2 matrix
1369
- sankey-beta flow diagrams
1370
- See https://mermaid.js.org for the full syntax reference.
1371
-
1372
- THEMING
1373
- Diagrams inherit colors from the SDocs blocks cascade:
1374
-
1375
- \`\`\`yaml
1376
- styles:
1377
- blocks:
1378
- background: "#f4f1ed" # diagram wrapper bg
1379
- color: "#6b6560" # node text / lines
1380
- \`\`\`
1381
-
1382
- In dark mode the inverted block colors apply automatically.
1383
- For finer-grained control, set Mermaid theme variables in the
1384
- diagram source itself, but note that \`%%{init:...}%%\` directives
1385
- are stripped by SDocs as a security measure (they can otherwise
1386
- override sanitisation settings at parse time).
1387
-
1388
- LIMITS
1389
- - Per-diagram source cap: 64 KB.
1390
- - Per-document diagram cap: 50 (excess rendered as plain code).
1391
- - Per-render timeout: 5 seconds (large or pathological graphs error out).
1392
-
1393
- SECURITY
1394
- Mermaid runs with \`securityLevel: 'strict'\` and \`htmlLabels: true\`.
1395
- htmlLabels lets long node labels wrap inside a \`<foreignObject>\`,
1396
- which is otherwise a script-injection vector; SDocs makes that safe
1397
- by post-sanitising the SVG before render. \`<script>\`, \`<iframe>\`,
1398
- \`<form>\`, \`<input>\`, \`<use>\`, animation tags, \`on*\` event handlers
1399
- and \`javascript:\` URLs are stripped (inside foreignObject and out).
1400
- Source caps and a render timeout cover the DoS surface. Treat diagram
1401
- source as untrusted - it travels in the URL hash with the rest of
1402
- the document.
1403
-
1404
- EXAMPLE
1405
- \`\`\`mermaid
1406
- sequenceDiagram
1407
- participant U as User
1408
- participant S as SDocs
1409
- participant C as CDN
1410
- U->>S: open page with diagram
1411
- S->>C: load mermaid.min.js (lazy, first time only)
1412
- C-->>S: script
1413
- S->>S: render() → SVG
1414
- S->>U: paint diagram
1415
- \`\`\`
1416
- `;
1417
-
1418
- // ── Compression (brotli + base64url) ─────────────────
1419
-
1420
- function toBase64Url(buf) {
1421
- return Buffer.from(buf).toString('base64')
1422
- .replace(/\+/g, '-')
1423
- .replace(/\//g, '_')
1424
- .replace(/=+$/, '');
1425
- }
1426
-
1427
- function fromBase64Url(b64url) {
1428
- let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
1429
- const pad = (4 - b64.length % 4) % 4;
1430
- b64 += '='.repeat(pad);
1431
- return Buffer.from(b64, 'base64');
1432
- }
1433
-
1434
- function compressToBase64Url(text) {
1435
- const compressed = zlib.brotliCompressSync(Buffer.from(text, 'utf-8'), {
1436
- params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
1437
- });
1438
- return toBase64Url(compressed);
1439
- }
1440
-
1441
- function decompressFromBase64Url(b64url) {
1442
- const buf = fromBase64Url(b64url);
1443
- // Try brotli first, fall back to deflate for old URLs
1444
- try {
1445
- return zlib.brotliDecompressSync(buf).toString('utf-8');
1446
- } catch (_) {
1447
- return zlib.inflateRawSync(buf).toString('utf-8');
1448
- }
1449
- }
1450
-
1451
- // ── Short-link encrypt + upload (AES-GCM, client-held key) ─
1452
-
1453
- // Compress with brotli, then encrypt with AES-256-GCM. Returns
1454
- // { keyBytes, cipherB64url } where keyBytes never leaves this process.
1455
- // The blob format (nonce(12) + ciphertext + tag(16)) matches the browser.
1456
- function compressAndEncrypt(content) {
1457
- const compressed = zlib.brotliCompressSync(Buffer.from(content, 'utf-8'), {
1458
- params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
1459
- });
1460
- const keyBytes = crypto.randomBytes(32);
1461
- const nonce = crypto.randomBytes(12);
1462
- const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes, nonce);
1463
- const ct = Buffer.concat([cipher.update(compressed), cipher.final()]);
1464
- const tag = cipher.getAuthTag();
1465
- const blob = Buffer.concat([nonce, ct, tag]);
1466
- return { keyBytes, cipherB64url: toBase64Url(blob) };
1467
- }
1468
-
1469
- function uploadShortLink(ciphertextB64, baseUrl) {
1470
- return new Promise((resolve, reject) => {
1471
- const u = new URL('/api/short', baseUrl);
1472
- const isHttps = u.protocol === 'https:';
1473
- const mod = isHttps ? https : http;
1474
- const payload = JSON.stringify({ ciphertext: ciphertextB64 });
1475
- const req = mod.request({
1476
- method: 'POST',
1477
- protocol: u.protocol,
1478
- hostname: u.hostname,
1479
- port: u.port || (isHttps ? 443 : 80),
1480
- path: u.pathname,
1481
- headers: {
1482
- 'Content-Type': 'application/json',
1483
- 'Content-Length': Buffer.byteLength(payload),
1484
- },
1485
- timeout: 10000,
1486
- }, (res) => {
1487
- let body = '';
1488
- res.on('data', (chunk) => { body += chunk; });
1489
- res.on('end', () => {
1490
- let json;
1491
- try { json = JSON.parse(body); } catch (_) { json = null; }
1492
- if (res.statusCode >= 200 && res.statusCode < 300 && json && json.id) {
1493
- resolve(json.id);
1494
- } else {
1495
- const err = (json && json.error) || ('http_' + res.statusCode);
1496
- reject(new Error(err));
1497
- }
1498
- });
1499
- });
1500
- req.on('error', reject);
1501
- req.on('timeout', () => { req.destroy(new Error('timeout')); });
1502
- req.write(payload);
1503
- req.end();
1504
- });
1505
- }
1506
-
1507
- async function buildShortUrl(content, opts) {
1508
- if (!content) throw new Error('short link requires file content');
1509
-
1510
- // Mirror the hash-build's default-stripping so the encrypted payload is
1511
- // identical to what the browser would encode.
1512
- const parsed = SDocYaml.parseFrontMatter(content);
1513
- if (parsed.meta && parsed.meta.styles) {
1514
- const stripped = SDocStyles.stripStyleDefaults(parsed.meta.styles);
1515
- if (Object.keys(stripped).length > 0) parsed.meta.styles = stripped;
1516
- else delete parsed.meta.styles;
1517
- content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
1518
- }
1519
-
1520
- const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
1521
- const { keyBytes, cipherB64url } = compressAndEncrypt(content);
1522
- const id = await uploadShortLink(cipherB64url, baseUrl);
1523
- const keyB64 = toBase64Url(keyBytes);
1524
-
1525
- const params = new URLSearchParams();
1526
- params.set('k', keyB64);
1527
- const mode = opts.mode;
1528
- if (mode && mode !== 'read') params.set('mode', mode);
1529
- if (opts.theme) params.set('theme', opts.theme);
1530
- if (opts.section) params.set('sec', slugify(opts.section));
1531
-
1532
- return `${baseUrl}/s/${id}#${params.toString()}`;
1533
- }
1534
-
1535
- // ── Slugify (shared module) ───────────────────────────────
1536
-
1537
- var slugify = require('../public/sdocs-slugify').slugify;
1538
-
1539
- // ── sdoc safe: verify frontend hashes + point agents at the server source ──
1540
- //
1541
- // 1. Asks the SDocs host what commit it is running (/trust/manifest, .commit).
1542
- // 2. Fetches the authoritative fingerprint list for that commit from GitHub
1543
- // (raw.githubusercontent.com/.../trust-manifests/<sha>.json), published on
1544
- // every push to main by .github/workflows/publish-manifest.yml.
1545
- // 3. Downloads each file from the host, hashes it with SHA-256, compares to
1546
- // GitHub's list.
1547
- // Bytes come from the host. Fingerprints come from GitHub. The host cannot
1548
- // produce a match it did not already publish to GitHub.
1549
- //
1550
- // Server-side code (request handling, storage) still cannot be verified by
1551
- // hashing. With --audit, the command prints direct GitHub links to the files
1552
- // an agent should read to review that part.
1553
-
1554
- // Server-side files that a curious human or agent needs to read to audit
1555
- // what `sdoc safe` cannot prove by hashing. Kept small on purpose: these
1556
- // are the only files that touch server-side request handling.
1557
- const AUDIT_SOURCE_FILES = [
1558
- 'server.js',
1559
- 'short-links/db.js',
1560
- 'short-links/rate-limit.js',
1561
- 'analytics/db.js',
1562
- 'analytics/query.js',
1563
- ];
1564
-
1565
- const TRUST_RAW_BASE = 'https://raw.githubusercontent.com/espressoplease/SDocs/trust-manifests';
1566
-
1567
- function fetchJson(url) {
1568
- return new Promise((resolve, reject) => {
1569
- const u = new URL(url);
1570
- const mod = u.protocol === 'https:' ? https : http;
1571
- mod.get(u, { timeout: 8000 }, (res) => {
1572
- if (res.statusCode < 200 || res.statusCode >= 300) {
1573
- reject(new Error('HTTP ' + res.statusCode + ' for ' + url));
1574
- res.resume();
1575
- return;
1576
- }
1577
- let body = '';
1578
- res.on('data', (c) => { body += c; });
1579
- res.on('end', () => {
1580
- try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('invalid JSON from ' + url)); }
1581
- });
1582
- }).on('error', reject).on('timeout', function () { this.destroy(new Error('timeout')); });
1583
- });
1584
- }
1585
-
1586
- function fetchBuffer(url) {
1587
- return new Promise((resolve, reject) => {
1588
- const u = new URL(url);
1589
- const mod = u.protocol === 'https:' ? https : http;
1590
- mod.get(u, { timeout: 15000 }, (res) => {
1591
- if (res.statusCode < 200 || res.statusCode >= 300) {
1592
- reject(new Error('HTTP ' + res.statusCode));
1593
- res.resume();
1594
- return;
1595
- }
1596
- const chunks = [];
1597
- res.on('data', (c) => { chunks.push(c); });
1598
- res.on('end', () => { resolve(Buffer.concat(chunks)); });
1599
- }).on('error', reject).on('timeout', function () { this.destroy(new Error('timeout')); });
1600
- });
1601
- }
1602
-
1603
- async function runSafe(opts) {
1604
- const base = (opts.url || process.env.SDOCS_URL || DEFAULT_URL).replace(/\/$/, '');
1605
- const jsonOut = !!opts.jsonFlag;
1606
- const audit = !!opts.auditFlag;
1607
- const rawBase = (opts.rawBase || process.env.SDOCS_TRUST_RAW || TRUST_RAW_BASE).replace(/\/$/, '');
1608
-
1609
- // Step 1: learn the commit the host reports.
1610
- let serverReport;
1611
- try {
1612
- serverReport = await fetchJson(base + '/trust/manifest');
1613
- } catch (e) {
1614
- if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'server_fetch_failed', message: e.message })); }
1615
- else { console.error('sdoc safe: could not fetch ' + base + '/trust/manifest - ' + e.message); }
1616
- process.exit(2);
1617
- }
1618
- const commit = serverReport.commit;
1619
- if (!commit || commit === 'unknown') {
1620
- if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'no_commit_reported' })); }
1621
- else { console.error('sdoc safe: host did not report a commit.'); }
1622
- process.exit(2);
1623
- }
1624
-
1625
- // Step 2: pull the authoritative fingerprint list from GitHub for that commit.
1626
- const manifestUrl = rawBase + '/' + commit + '.json';
1627
- let manifest;
1628
- try {
1629
- manifest = await fetchJson(manifestUrl);
1630
- } catch (e) {
1631
- const pending = /HTTP 404/.test(e.message);
1632
- if (jsonOut) {
1633
- console.log(JSON.stringify({
1634
- ok: false,
1635
- error: pending ? 'manifest_not_yet_published' : 'manifest_fetch_failed',
1636
- host: base, commit, manifestUrl, message: e.message,
1637
- }));
1638
- } else if (pending) {
1639
- console.error('sdoc safe: no fingerprint list published on GitHub for commit ' + commit.slice(0, 7) + ' yet.');
1640
- console.error(' (publish-manifest.yml runs on push to main; give it a minute.)');
1641
- console.error(' looked for: ' + manifestUrl);
1642
- } else {
1643
- console.error('sdoc safe: could not fetch ' + manifestUrl + ' - ' + e.message);
1644
- }
1645
- process.exit(pending ? 2 : 3);
1646
- }
1647
-
1648
- if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
1649
- if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'manifest_has_no_files', manifestUrl })); }
1650
- else { console.error('sdoc safe: GitHub manifest at ' + manifestUrl + ' has no files array.'); }
1651
- process.exit(3);
1652
- }
1653
-
1654
- // Step 3: hash files from the host, compare to GitHub's list.
1655
- const results = [];
1656
- let ok = 0, fail = 0;
1657
- for (const file of manifest.files) {
1658
- const fileUrl = base + '/public' + file.path;
1659
- try {
1660
- const buf = await fetchBuffer(fileUrl);
1661
- const got = crypto.createHash('sha256').update(buf).digest('hex');
1662
- const match = got === file.sha256;
1663
- results.push({ path: file.path, bytes: file.bytes, expected: file.sha256, got, match });
1664
- if (match) ok++; else fail++;
1665
- } catch (e) {
1666
- results.push({ path: file.path, bytes: file.bytes, expected: file.sha256, error: e.message, match: false });
1667
- fail++;
1668
- }
1669
- }
1670
-
1671
- const repo = manifest.repo || 'https://github.com/espressoplease/SDocs';
1672
- const auditLinks = audit ? AUDIT_SOURCE_FILES.map(f => ({
1673
- file: f,
1674
- url: repo + '/blob/' + commit + '/' + f,
1675
- })) : null;
1676
-
1677
- if (jsonOut) {
1678
- console.log(JSON.stringify({
1679
- ok: fail === 0,
1680
- host: base,
1681
- commit,
1682
- builtAt: manifest.builtAt,
1683
- manifestUrl,
1684
- totals: { ok, fail, total: results.length },
1685
- files: results,
1686
- audit: auditLinks,
1687
- unverified: {
1688
- note: 'Server-side code (request handling, storage) cannot be verified by hashing. Read the source files listed under audit to review what a malicious operator could theoretically modify.',
1689
- files: AUDIT_SOURCE_FILES,
1690
- },
1691
- }, null, 2));
1692
- } else {
1693
- console.log('');
1694
- console.log(' sdoc safe - verifying ' + base);
1695
- console.log(' commit ' + commit);
1696
- console.log(' built at ' + (manifest.builtAt || '?'));
1697
- console.log(' tree ' + repo + '/tree/' + commit);
1698
- console.log(' list ' + manifestUrl);
1699
- console.log('');
1700
- for (const r of results) {
1701
- const glyph = r.match ? '\u2713' : '\u2717';
1702
- const line = ' ' + glyph + ' ' + r.path.padEnd(32) + ' ' + (r.match ? 'match' : (r.error || 'MISMATCH'));
1703
- console.log(line);
1704
- }
1705
- console.log('');
1706
- if (fail === 0) {
1707
- console.log(' \u2713 ' + ok + ' / ' + results.length + ' files match the list GitHub published for this commit.');
1708
- console.log(' Bytes came from this host; fingerprints came from GitHub.');
1709
- } else {
1710
- console.log(' \u2717 ' + fail + ' / ' + results.length + ' files FAILED to match GitHub\'s list for this commit.');
1711
- console.log(' The host is serving different bytes than GitHub published for ' + commit.slice(0, 7) + '.');
1712
- }
1713
- console.log('');
1714
- console.log(' What this does not prove:');
1715
- console.log(' Server-side request handling cannot be verified by hashing alone.');
1716
- console.log(' The only way to audit it is to read the source. Start here:');
1717
- console.log('');
1718
- for (const f of AUDIT_SOURCE_FILES) {
1719
- console.log(' ' + repo + '/blob/' + commit + '/' + f);
1720
- }
1721
- console.log('');
1722
- if (!audit) {
1723
- console.log(' Re-run with --audit for machine-readable audit pointers, or --json for full output.');
1724
- console.log('');
1725
- }
1726
- }
1727
-
1728
- process.exit(fail === 0 ? 0 : 1);
1729
- }
1730
-
1731
- // ── Parse args ────────────────────────────────────────────
1732
-
1733
- const SUBCOMMANDS = new Set(['new', 'share', 'schema', 'defaults', 'help', 'charts', 'diagrams', 'comments', 'setup', 'safe', 'auto-update']);
1734
-
1735
- function parseArgs(argv) {
1736
- const args = argv || process.argv.slice(2);
1737
- let file = null;
1738
- let mode = null;
1739
- let url = null;
1740
- let subcommand = null;
1741
- let section = null;
1742
- let theme = null;
1743
- let resetFlag = false;
1744
- let shortFlag = false;
1745
- let jsonFlag = false;
1746
- let auditFlag = false;
1747
-
1748
- for (let i = 0; i < args.length; i++) {
1749
- const arg = args[i];
1750
-
1751
- // Legacy / shortcut flags that map to subcommands
1752
- if (arg === '--help' || arg === '-h') { subcommand = 'help'; continue; }
1753
- if (arg === '--schema') { subcommand = 'schema'; continue; }
1754
-
1755
- // Mode shorthand flags
1756
- if (arg === '--write') { mode = 'write'; continue; }
1757
- if (arg === '--style') { mode = 'style'; continue; }
1758
- if (arg === '--raw') { mode = 'raw'; continue; }
1759
- if (arg === '--read') { mode = 'read'; continue; }
1760
- if (arg === '--comment') { mode = 'comment'; continue; }
1761
- if (arg === '--light') { theme = 'light'; continue; }
1762
- if (arg === '--dark') { theme = 'dark'; continue; }
1763
-
1764
- // Long-form --mode
1765
- if (arg === '--mode' || arg === '-m') {
1766
- mode = args[++i];
1767
- if (!['read', 'write', 'style', 'raw', 'comment'].includes(mode)) {
1768
- console.error(`sdoc: unknown mode "${mode}" — use read, write, style, raw, or comment`);
1769
- process.exit(1);
1770
- }
1771
- continue;
1772
- }
1773
-
1774
- // --url flag
1775
- if (arg === '--url') { url = args[++i]; continue; }
1776
-
1777
- // --section flag
1778
- if (arg === '--section' || arg === '-s') { section = args[++i]; continue; }
1779
-
1780
- // --reset flag (for defaults subcommand)
1781
- if (arg === '--reset') { resetFlag = true; continue; }
1782
-
1783
- // --short flag (share subcommand only): encrypt + upload, return /s/... URL
1784
- if (arg === '--short') { shortFlag = true; continue; }
1785
-
1786
- // --json flag (safe subcommand): machine-readable output
1787
- if (arg === '--json') { jsonFlag = true; continue; }
1788
-
1789
- // --audit flag (safe subcommand): also print server-side source audit links
1790
- if (arg === '--audit') { auditFlag = true; continue; }
1791
-
1792
- // Positional: check for subcommand first, then file
1793
- if (!subcommand && SUBCOMMANDS.has(arg)) {
1794
- subcommand = arg;
1795
- continue;
1796
- }
1797
-
1798
- if (!file) { file = arg; continue; }
1799
- }
1800
-
1801
- return { file, mode, url, subcommand, section, theme, resetFlag, shortFlag, jsonFlag, auditFlag };
1802
- }
1803
-
1804
- // ── Build URL ─────────────────────────────────────────────
1805
-
1806
- function buildUrl(content, opts) {
1807
- const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
1808
- const params = new URLSearchParams();
1809
-
1810
- // Runtime-only metadata (paths). Stripped from the URL by the browser on load,
1811
- // so anything the user copies from the address bar won't contain them.
1812
- if (opts.local && Object.keys(opts.local).length > 0) {
1813
- const json = JSON.stringify(opts.local);
1814
- const b64 = Buffer.from(json, 'utf-8').toString('base64')
1815
- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1816
- params.set('local', b64);
1817
- }
1818
-
1819
- if (content) {
1820
- // Strip default style values to produce shorter URLs
1821
- const parsed = SDocYaml.parseFrontMatter(content);
1822
- if (parsed.meta && parsed.meta.styles) {
1823
- const stripped = SDocStyles.stripStyleDefaults(parsed.meta.styles);
1824
- if (Object.keys(stripped).length > 0) {
1825
- parsed.meta.styles = stripped;
1826
- } else {
1827
- delete parsed.meta.styles;
1828
- }
1829
- content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
1830
- }
1831
- params.set('md', compressToBase64Url(content));
1832
- } else if (opts.defaultStyles) {
1833
- const stylesJson = JSON.stringify(opts.defaultStyles);
1834
- params.set('styles', encodeURIComponent(Buffer.from(stylesJson, 'utf-8').toString('base64')));
1835
- }
1836
-
1837
- const mode = opts.mode || (content ? 'read' : 'style');
1838
- if (mode && mode !== 'read') params.set('mode', mode);
1839
-
1840
- if (opts.theme) params.set('theme', opts.theme);
1841
-
1842
- if (opts.section) {
1843
- params.set('sec', slugify(opts.section));
1844
- }
1845
-
1846
- const qs = params.toString();
1847
- return qs ? `${baseUrl}/#${qs}` : baseUrl;
1848
- }
1849
-
1850
- // ── YAML parsing (shared module) ──
1851
- const { parseSimpleYaml, parseFrontMatter, serializeFrontMatter } = SDocYaml;
1852
-
1853
- // ── ~/.sdocs/styles.yaml default styles ────────────────────
1854
-
1855
- function getDefaultsPath() {
1856
- return path.join(require('os').homedir(), '.sdocs', 'styles.yaml');
1857
- }
1858
-
1859
- function loadDefaultStyles() {
1860
- const configPath = getDefaultsPath();
1861
- if (!fs.existsSync(configPath)) return null;
1862
- try {
1863
- const yaml = fs.readFileSync(configPath, 'utf-8');
1864
- return parseSimpleYaml(yaml);
1865
- } catch {
1866
- return null;
1867
- }
1868
- }
1869
-
1870
- function showDefaults() {
1871
- const configPath = getDefaultsPath();
1872
- if (!fs.existsSync(configPath)) {
1873
- console.log('No default styles set (~/.sdocs/styles.yaml not found).');
1874
- console.log('\nTo set defaults, style a document in SDocs and use');
1875
- console.log('the "Save as Default" panel to generate the command.');
1876
- return;
1877
- }
1878
- console.log(fs.readFileSync(configPath, 'utf-8'));
1879
- }
1880
-
1881
- function resetDefaults() {
1882
- const configPath = getDefaultsPath();
1883
- if (!fs.existsSync(configPath)) {
1884
- console.log('No default styles to remove.');
1885
- return;
1886
- }
1887
- fs.unlinkSync(configPath);
1888
- console.log('Removed ' + configPath);
1889
- }
1890
-
1891
- // Deep merge: defaults under file styles (file wins on conflict)
1892
- // Recursive for light:/dark: sub-objects that contain nested objects
1893
- function mergeStyles(defaults, fileStyles) {
1894
- if (!defaults) return fileStyles || {};
1895
- if (!fileStyles) return { ...defaults };
1896
- const merged = { ...defaults };
1897
- for (const [k, v] of Object.entries(fileStyles)) {
1898
- if (typeof v === 'object' && v !== null && typeof merged[k] === 'object' && merged[k] !== null) {
1899
- // Recurse one level deeper for light/dark blocks that contain nested objects (e.g. h1: { color: ... })
1900
- const inner = { ...merged[k] };
1901
- for (const [ik, iv] of Object.entries(v)) {
1902
- if (typeof iv === 'object' && iv !== null && typeof inner[ik] === 'object' && inner[ik] !== null) {
1903
- inner[ik] = { ...inner[ik], ...iv };
1904
- } else {
1905
- inner[ik] = iv;
1906
- }
1907
- }
1908
- merged[k] = inner;
1909
- } else {
1910
- merged[k] = v;
1911
- }
1912
- }
1913
- return merged;
1914
- }
1915
-
1916
- // Apply default styles to content, returning modified content
1917
- function applyDefaultStyles(content) {
1918
- const defaults = loadDefaultStyles();
1919
- if (!defaults) return content;
1920
-
1921
- const { meta, body } = parseFrontMatter(content);
1922
- const mergedStyles = mergeStyles(defaults, meta.styles);
1923
- const newMeta = { ...meta, styles: mergedStyles };
1924
- return serializeFrontMatter(newMeta) + '\n' + body;
1925
- }
1926
-
1927
- // ── Read content ───────────────────────────────────────────
1928
-
1929
- async function readContent(file) {
1930
- if (file) {
1931
- const resolved = path.resolve(file);
1932
- if (!fs.existsSync(resolved)) {
1933
- console.error(`sdoc: file not found: ${file}`);
1934
- process.exit(1);
1935
- }
1936
- var raw = fs.readFileSync(resolved, 'utf-8');
1937
- // .mmd / .mermaid files (standalone Mermaid sources) are wrapped in a
1938
- // fenced block so the renderer picks them up. No special CLI path needed.
1939
- if (/\.(mmd|mermaid)$/i.test(file)) {
1940
- raw = '```mermaid\n' + raw.replace(/\s+$/, '') + '\n```\n';
1941
- }
1942
- return raw;
1943
- }
1944
-
1945
- // Check if stdin has data (piped input)
1946
- if (!process.stdin.isTTY) {
1947
- return new Promise((resolve, reject) => {
1948
- let data = '';
1949
- process.stdin.setEncoding('utf-8');
1950
- process.stdin.on('data', chunk => data += chunk);
1951
- process.stdin.on('end', () => resolve(data));
1952
- process.stdin.on('error', reject);
1953
- });
1954
- }
1955
-
1956
- return null; // no content — just open studio
1957
- }
1958
-
1959
- // ── Open browser ───────────────────────────────────────────
1960
-
1961
- function openBrowser(url) {
1962
- try {
1963
- if (process.platform === 'darwin') execFileSync('open', [url]);
1964
- else if (process.platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url]);
1965
- else execFileSync('xdg-open', [url]);
1966
- } catch {
1967
- console.log(`Open in browser: ${url}`);
1968
- }
18
+ const SDocYaml = require('../shared/sdocs-yaml.js');
19
+ const SDocSlugify = require('../shared/sdocs-slugify.js');
20
+
21
+ const constants = require('../lib/constants');
22
+ const router = require('../lib/router');
23
+ const url = require('../lib/url');
24
+ const shortLink = require('../lib/short-link');
25
+ const agentBlock = require('../lib/agent-block');
26
+ const agentFiles = require('../lib/agent-files');
27
+ const updateCheck = require('../lib/update-check');
28
+ const setup = require('../lib/setup');
29
+ const safe = require('../lib/safe');
30
+ const styles = require('../lib/styles');
31
+ const io = require('../lib/io');
32
+ const helpText = require('../lib/help-text');
33
+ const commands = require('../lib/commands');
34
+ const cellsVerify = require('../lib/cells-verify');
35
+ const bridgeCommands = require('../lib/bridge-commands');
36
+ const libraryCommands = require('../lib/library-commands');
37
+
38
+ // ── Router ────────────────────────────────────────────────
39
+ // One place that knows the full set of verbs. New chunks register here.
40
+
41
+ function buildRouter() {
42
+ const r = new router.CommandRouter();
43
+
44
+ // Help-text verbs print and exit.
45
+ r.register('help', { handler: () => { console.log(helpText.HELP); process.exit(0); } });
46
+ r.register('version', { handler: () => { console.log(constants.VERSION); process.exit(0); } });
47
+ r.register('schema', { handler: () => { console.log(helpText.SCHEMA); process.exit(0); } });
48
+ r.register('charts', { handler: () => { console.log(helpText.CHARTS_HELP); process.exit(0); } });
49
+ r.register('diagrams', { handler: () => { console.log(helpText.DIAGRAMS_HELP); process.exit(0); } });
50
+ // `sdoc cells` prints the reference; `sdoc cells verify <file>` evaluates a
51
+ // document's tabs headlessly and prints the computed values (the handler
52
+ // calls process.exit with the 0/1/2 result code).
53
+ r.register('cells', { handler: (opts) => {
54
+ if ((opts.file || '').toLowerCase() === 'verify') return cellsVerify.cellsVerifyCommand(opts);
55
+ console.log(helpText.CELLS_HELP); process.exit(0);
56
+ } });
57
+ r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
58
+
59
+ // Setup / refresh / auto-update.
60
+ r.register('setup', { handler: async (opts) => { await setup.runSetup({ force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag }); process.exit(0); } });
61
+ r.register('refresh', { handler: async () => { await setup.runRefresh(); process.exit(0); } });
62
+ r.register('auto-update', { handler: (opts) => {
63
+ // Sub-arg lives in opts.file (positional). Accept on/off/empty.
64
+ setup.runAutoUpdateSubcommand((opts.file || '').toLowerCase());
65
+ process.exit(0);
66
+ } });
67
+ r.register('upgrade', { handler: () => { updateCheck.runUpgrade(); process.exit(0); } });
68
+
69
+ // Trust verification (calls process.exit internally with a result code).
70
+ r.register('safe', { handler: (opts) => safe.runSafe(opts) });
71
+
72
+ // Defaults: show / reset.
73
+ r.register('defaults', { handler: (opts) => { commands.defaultsCommand(opts); process.exit(0); } });
74
+
75
+ // `sdoc color-analysis <file>` — WCAG contrast lint for custom palettes.
76
+ // Exits 1 on unreadable pairs (the handler calls process.exit itself).
77
+ r.register('color-analysis', { handler: (opts) => commands.colorAnalysisCommand(opts) });
78
+
79
+ // `sdoc new`: open blank /new editor.
80
+ r.register('new', { handler: (opts) => { commands.newCommand(opts); process.exit(0); } });
81
+
82
+ // `sdoc bridge <file>` — live editing session. Starts a local bridge so the
83
+ // browser is connected to the file on disk (autosave back, external changes
84
+ // pushed to the page). Blocks the terminal until the tab closes or Ctrl-C.
85
+ // The browser will ask to talk to a local process ("Apps on device" /
86
+ // local-network permission); the user must accept for the live link to work.
87
+ r.register('bridge', { handler: (opts) => bridgeCommands.runBridgedOpen(opts) });
88
+
89
+ // `sdoc feedback <file> --message "..."` — agent handoff. Bridge in
90
+ // feedback mode: Done returns 0, close-without-Done returns 2.
91
+ r.register('feedback', { handler: (opts) => bridgeCommands.feedbackCommand(opts) });
92
+
93
+ // `sdoc slides` family reference text + helper subcommands.
94
+ r.register('slides', { handler: (opts) => { commands.slidesCommand(opts); process.exit(0); } });
95
+ // `sdoc present <file>` — same as the default open flow but enters
96
+ // fullscreen slide view on load.
97
+ r.register('present', { handler: (opts) => commands.presentCommand(opts) });
98
+
99
+ // `sdoc share <file>` (URL-only, non-blocking) and the default file-open
100
+ // flow. The default handler starts a Bridge when given a real file path,
101
+ // and falls back to URL-encoded snapshot for stdin / no-file.
102
+ r.register('share', { handler: (opts) => commands.shareCommand(opts) });
103
+
104
+ // `sdoc library [enable|disable|status|rebuild]`. No sub-arg opens
105
+ // the library UI.
106
+ r.register('library', { handler: async (opts) => { await libraryCommands.libraryCommand(opts); /* libraryOpen blocks */ } });
107
+
108
+ r.register(null, { handler: (opts) => {
109
+ // Index-on-open tap: fires before the open so any +tag CLI args land
110
+ // in the file's front matter before the browser receives the content.
111
+ libraryCommands.tapOpen(opts);
112
+ return commands.openCommand(opts);
113
+ } });
114
+
115
+ return r;
1969
116
  }
1970
117
 
1971
118
  // ── Main ───────────────────────────────────────────────────
1972
119
 
1973
120
  if (require.main === module) {
1974
121
  (async () => {
1975
- const opts = parseArgs();
1976
-
1977
- // Subcommand dispatch
1978
- if (opts.subcommand === 'help') { console.log(HELP); process.exit(0); }
1979
- if (opts.subcommand === 'schema') { console.log(SCHEMA); process.exit(0); }
1980
- if (opts.subcommand === 'charts') { console.log(CHARTS_HELP); process.exit(0); }
1981
- if (opts.subcommand === 'diagrams') { console.log(DIAGRAMS_HELP); process.exit(0); }
1982
- if (opts.subcommand === 'comments') { console.log(COMMENTS_HELP); process.exit(0); }
1983
- if (opts.subcommand === 'setup') { await runSetup({ force: true }); process.exit(0); }
1984
- if (opts.subcommand === 'auto-update') {
1985
- // Sub-arg lives in opts.file (positional). Accept on/off/empty.
1986
- runAutoUpdateSubcommand((opts.file || '').toLowerCase());
1987
- process.exit(0);
1988
- }
1989
- if (opts.subcommand === 'safe') { await runSafe(opts); return; }
1990
- if (opts.subcommand === 'defaults') {
1991
- if (opts.resetFlag) resetDefaults();
1992
- else showDefaults();
1993
- process.exit(0);
1994
- }
1995
- if (opts.subcommand === 'new') {
1996
- const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
1997
- const url = baseUrl + '/new';
1998
- openBrowser(url);
1999
- console.log(`SDocs → ${url}`);
2000
- process.exit(0);
2001
- }
2002
-
2003
- // File / stdin handling
2004
- let content = await readContent(opts.file);
2005
-
2006
- // Apply ~/.sdocs/styles.yaml defaults
2007
- const defaults = loadDefaultStyles();
2008
- if (content && defaults) {
2009
- content = applyDefaultStyles(content);
2010
- }
2011
-
2012
- // Inject `file:` into front matter (basename only — safe to share).
2013
- // Respects user-set file: if already present.
2014
- if (content && opts.file) {
2015
- const parsed = parseFrontMatter(content);
2016
- if (!parsed.meta.file) {
2017
- parsed.meta.file = path.basename(opts.file);
2018
- content = serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
2019
- }
2020
- }
2021
-
2022
- // Runtime-only local metadata for the opener's view.
2023
- // `share` omits it so shared URLs never carry paths.
2024
- let local = null;
2025
- if (opts.file && opts.subcommand !== 'share') {
2026
- const abs = path.resolve(opts.file);
2027
- const rel = path.relative(process.cwd(), abs);
2028
- local = { fullPath: abs };
2029
- // Only include a relative path if the file is inside cwd, otherwise
2030
- // `path` would just duplicate `fullPath`.
2031
- if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
2032
- local.path = './' + rel;
2033
- }
2034
- }
2035
-
2036
- let url;
2037
- if (opts.shortFlag) {
2038
- if (opts.subcommand !== 'share') {
2039
- console.error('sdoc: --short is only valid with the `share` subcommand');
2040
- process.exit(1);
2041
- }
2042
- if (!content) {
2043
- console.error('sdoc: --short needs content (a file path or piped stdin)');
2044
- process.exit(1);
2045
- }
2046
- try {
2047
- url = await buildShortUrl(content, {
2048
- url: opts.url,
2049
- mode: opts.mode,
2050
- theme: opts.theme,
2051
- section: opts.section,
2052
- });
2053
- } catch (e) {
2054
- console.error('sdoc: could not create short link -', e.message);
2055
- process.exit(1);
2056
- }
2057
- } else {
2058
- url = buildUrl(content, {
2059
- url: opts.url,
2060
- mode: opts.mode,
2061
- theme: opts.theme,
2062
- defaultStyles: !content ? defaults : null,
2063
- section: opts.section,
2064
- local: local,
2065
- });
2066
- }
2067
-
2068
- // Share: copy to clipboard
2069
- if (opts.subcommand === 'share') {
2070
- try {
2071
- const clip = process.platform === 'darwin' ? 'pbcopy'
2072
- : execSync('which xclip 2>/dev/null', { encoding: 'utf-8' }).trim() ? 'xclip -selection clipboard'
2073
- : 'xsel --clipboard --input';
2074
- execSync(clip, { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
2075
- const name = opts.file ? path.basename(opts.file) : 'stdin';
2076
- const label = opts.shortFlag ? 'Short link' : 'Link';
2077
- console.log(`\u2713 ${label} for ${name} copied to clipboard`);
2078
- if (opts.shortFlag) console.log(` ${url}`);
2079
- } catch (_) {
2080
- process.stdout.write(url + '\n');
2081
- }
2082
- refreshUpdateCache();
2083
- await maybeUpdateBinary();
2084
- await runSetup();
2085
- await maybeAutoRefresh();
2086
- return;
2087
- }
2088
-
2089
- // Default: open browser
2090
- openBrowser(url);
2091
- console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
2092
- refreshUpdateCache();
2093
- await maybeUpdateBinary();
2094
- await runSetup();
2095
- await maybeAutoRefresh();
122
+ const opts = io.parseArgs();
123
+ const r = buildRouter();
124
+ await r.dispatch(opts);
2096
125
  })().catch(e => {
2097
126
  console.error('sdoc:', e.message);
2098
127
  process.exit(1);
2099
128
  });
2100
129
  }
2101
130
 
2102
- // ── Exports (for tests) ───────────────────────────────────
131
+ // ── Public API for tests ───────────────────────────────────
132
+ // `test/test-cli.js` and `test/test-agent-block.js` import these by name.
133
+ // Keep the surface intentional — if a test needs something else, add it
134
+ // to its source module's exports and re-export it here, don't reach into
135
+ // internal modules from the test directly.
2103
136
 
2104
137
  module.exports = {
2105
- mergeStyles,
2106
- applyDefaultStyles,
2107
- parseFrontMatter,
2108
- serializeFrontMatter,
2109
- parseSimpleYaml,
2110
- parseArgs,
2111
- buildUrl,
2112
- slugify,
2113
- compressToBase64Url,
2114
- decompressFromBase64Url,
2115
- compressAndEncrypt,
2116
- uploadShortLink,
2117
- buildShortUrl,
2118
- // Agent block (pure functions for tests)
2119
- AGENT_BLOCK_VERSION,
2120
- AGENT_BLOCK_BODY,
2121
- formatAgentBlock,
2122
- findBookendedBlock,
2123
- findLegacyBlock,
2124
- refreshContent,
2125
- compareVersions,
2126
- migrateSetupState,
138
+ // YAML + slugify (passed through from shared modules)
139
+ parseFrontMatter: SDocYaml.parseFrontMatter,
140
+ serializeFrontMatter: SDocYaml.serializeFrontMatter,
141
+ parseSimpleYaml: SDocYaml.parseSimpleYaml,
142
+ slugify: SDocSlugify.slugify,
143
+
144
+ // URL building / compression
145
+ toBase64Url: url.toBase64Url,
146
+ fromBase64Url: url.fromBase64Url,
147
+ compressToBase64Url: url.compressToBase64Url,
148
+ decompressFromBase64Url: url.decompressFromBase64Url,
149
+ buildUrl: url.buildUrl,
150
+
151
+ // Short links
152
+ compressAndEncrypt: shortLink.compressAndEncrypt,
153
+ uploadShortLink: shortLink.uploadShortLink,
154
+ buildShortUrl: shortLink.buildShortUrl,
155
+
156
+ // Default styles
157
+ mergeStyles: styles.mergeStyles,
158
+ applyDefaultStyles: styles.applyDefaultStyles,
159
+
160
+ // CLI parsing
161
+ parseArgs: io.parseArgs,
162
+
163
+ // Agent block (pure functions and constants)
164
+ AGENT_BLOCK_VERSION: agentBlock.AGENT_BLOCK_VERSION,
165
+ AGENT_BLOCK_BODY: agentBlock.AGENT_BLOCK_BODY,
166
+ formatAgentBlock: agentBlock.formatAgentBlock,
167
+ findBookendedBlock: agentBlock.findBookendedBlock,
168
+ findLegacyBlock: agentBlock.findLegacyBlock,
169
+ refreshContent: agentBlock.refreshContent,
170
+ compareVersions: agentBlock.compareVersions,
171
+ migrateSetupState: agentBlock.migrateSetupState,
172
+ implicitConsentState: agentBlock.implicitConsentState,
173
+
174
+ // Router (so contract tests can exercise it directly)
175
+ buildRouter,
176
+ CommandRouter: router.CommandRouter,
2127
177
  };