sdocs-dev 1.6.2 → 1.12.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-bridge.js +974 -0
- package/bin/sdocs-dev.js +145 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +111 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2706 -0
- package/lib/io.js +173 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +280 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +332 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -16
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
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,156 @@
|
|
|
9
15
|
* sdoc # open studio with empty editor
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 bridgeCommands = require('../lib/bridge-commands');
|
|
35
|
+
const libraryCommands = require('../lib/library-commands');
|
|
36
|
+
|
|
37
|
+
// ── Router ────────────────────────────────────────────────
|
|
38
|
+
// One place that knows the full set of verbs. New chunks register here.
|
|
39
|
+
|
|
40
|
+
function buildRouter() {
|
|
41
|
+
const r = new router.CommandRouter();
|
|
42
|
+
|
|
43
|
+
// Help-text verbs print and exit.
|
|
44
|
+
r.register('help', { handler: () => { console.log(helpText.HELP); process.exit(0); } });
|
|
45
|
+
r.register('version', { handler: () => { console.log(constants.VERSION); process.exit(0); } });
|
|
46
|
+
r.register('schema', { handler: () => { console.log(helpText.SCHEMA); process.exit(0); } });
|
|
47
|
+
r.register('charts', { handler: () => { console.log(helpText.CHARTS_HELP); process.exit(0); } });
|
|
48
|
+
r.register('diagrams', { handler: () => { console.log(helpText.DIAGRAMS_HELP); process.exit(0); } });
|
|
49
|
+
r.register('cells', { handler: () => { console.log(helpText.CELLS_HELP); process.exit(0); } });
|
|
50
|
+
r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
|
|
51
|
+
|
|
52
|
+
// Setup / refresh / auto-update.
|
|
53
|
+
r.register('setup', { handler: async (opts) => { await setup.runSetup({ force: true, yes: !!opts.yesFlag }); process.exit(0); } });
|
|
54
|
+
r.register('refresh', { handler: async () => { await setup.runRefresh(); process.exit(0); } });
|
|
55
|
+
r.register('auto-update', { handler: (opts) => {
|
|
56
|
+
// Sub-arg lives in opts.file (positional). Accept on/off/empty.
|
|
57
|
+
setup.runAutoUpdateSubcommand((opts.file || '').toLowerCase());
|
|
58
|
+
process.exit(0);
|
|
59
|
+
} });
|
|
60
|
+
r.register('upgrade', { handler: () => { updateCheck.runUpgrade(); process.exit(0); } });
|
|
61
|
+
|
|
62
|
+
// Trust verification (calls process.exit internally with a result code).
|
|
63
|
+
r.register('safe', { handler: (opts) => safe.runSafe(opts) });
|
|
64
|
+
|
|
65
|
+
// Defaults: show / reset.
|
|
66
|
+
r.register('defaults', { handler: (opts) => { commands.defaultsCommand(opts); process.exit(0); } });
|
|
67
|
+
|
|
68
|
+
// `sdoc color-analysis <file>` — WCAG contrast lint for custom palettes.
|
|
69
|
+
// Exits 1 on unreadable pairs (the handler calls process.exit itself).
|
|
70
|
+
r.register('color-analysis', { handler: (opts) => commands.colorAnalysisCommand(opts) });
|
|
71
|
+
|
|
72
|
+
// `sdoc new`: open blank /new editor.
|
|
73
|
+
r.register('new', { handler: (opts) => { commands.newCommand(opts); process.exit(0); } });
|
|
74
|
+
|
|
75
|
+
// `sdoc bridge <file>` — live editing session. Starts a local bridge so the
|
|
76
|
+
// browser is connected to the file on disk (autosave back, external changes
|
|
77
|
+
// pushed to the page). Blocks the terminal until the tab closes or Ctrl-C.
|
|
78
|
+
// The browser will ask to talk to a local process ("Apps on device" /
|
|
79
|
+
// local-network permission); the user must accept for the live link to work.
|
|
80
|
+
r.register('bridge', { handler: (opts) => bridgeCommands.runBridgedOpen(opts) });
|
|
81
|
+
|
|
82
|
+
// `sdoc feedback <file> --message "..."` — agent handoff. Bridge in
|
|
83
|
+
// feedback mode: Done returns 0, close-without-Done returns 2.
|
|
84
|
+
r.register('feedback', { handler: (opts) => bridgeCommands.feedbackCommand(opts) });
|
|
85
|
+
|
|
86
|
+
// `sdoc slides` family — reference text + helper subcommands.
|
|
87
|
+
r.register('slides', { handler: (opts) => { commands.slidesCommand(opts); process.exit(0); } });
|
|
88
|
+
// `sdoc present <file>` — same as the default open flow but enters
|
|
89
|
+
// fullscreen slide view on load.
|
|
90
|
+
r.register('present', { handler: (opts) => commands.presentCommand(opts) });
|
|
91
|
+
|
|
92
|
+
// `sdoc share <file>` (URL-only, non-blocking) and the default file-open
|
|
93
|
+
// flow. The default handler starts a Bridge when given a real file path,
|
|
94
|
+
// and falls back to URL-encoded snapshot for stdin / no-file.
|
|
95
|
+
r.register('share', { handler: (opts) => commands.shareCommand(opts) });
|
|
96
|
+
|
|
97
|
+
// `sdoc library [enable|disable|status|rebuild]`. No sub-arg opens
|
|
98
|
+
// the library UI.
|
|
99
|
+
r.register('library', { handler: async (opts) => { await libraryCommands.libraryCommand(opts); /* libraryOpen blocks */ } });
|
|
100
|
+
|
|
101
|
+
r.register(null, { handler: (opts) => {
|
|
102
|
+
// Index-on-open tap: fires before the open so any +tag CLI args land
|
|
103
|
+
// in the file's front matter before the browser receives the content.
|
|
104
|
+
libraryCommands.tapOpen(opts);
|
|
105
|
+
return commands.openCommand(opts);
|
|
106
|
+
} });
|
|
107
|
+
|
|
108
|
+
return r;
|
|
1969
109
|
}
|
|
1970
110
|
|
|
1971
111
|
// ── Main ───────────────────────────────────────────────────
|
|
1972
112
|
|
|
1973
113
|
if (require.main === module) {
|
|
1974
114
|
(async () => {
|
|
1975
|
-
const opts = parseArgs();
|
|
1976
|
-
|
|
1977
|
-
|
|
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();
|
|
115
|
+
const opts = io.parseArgs();
|
|
116
|
+
const r = buildRouter();
|
|
117
|
+
await r.dispatch(opts);
|
|
2096
118
|
})().catch(e => {
|
|
2097
119
|
console.error('sdoc:', e.message);
|
|
2098
120
|
process.exit(1);
|
|
2099
121
|
});
|
|
2100
122
|
}
|
|
2101
123
|
|
|
2102
|
-
// ──
|
|
124
|
+
// ── Public API for tests ───────────────────────────────────
|
|
125
|
+
// `test/test-cli.js` and `test/test-agent-block.js` import these by name.
|
|
126
|
+
// Keep the surface intentional — if a test needs something else, add it
|
|
127
|
+
// to its source module's exports and re-export it here, don't reach into
|
|
128
|
+
// internal modules from the test directly.
|
|
2103
129
|
|
|
2104
130
|
module.exports = {
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
//
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
131
|
+
// YAML + slugify (passed through from shared modules)
|
|
132
|
+
parseFrontMatter: SDocYaml.parseFrontMatter,
|
|
133
|
+
serializeFrontMatter: SDocYaml.serializeFrontMatter,
|
|
134
|
+
parseSimpleYaml: SDocYaml.parseSimpleYaml,
|
|
135
|
+
slugify: SDocSlugify.slugify,
|
|
136
|
+
|
|
137
|
+
// URL building / compression
|
|
138
|
+
toBase64Url: url.toBase64Url,
|
|
139
|
+
fromBase64Url: url.fromBase64Url,
|
|
140
|
+
compressToBase64Url: url.compressToBase64Url,
|
|
141
|
+
decompressFromBase64Url: url.decompressFromBase64Url,
|
|
142
|
+
buildUrl: url.buildUrl,
|
|
143
|
+
|
|
144
|
+
// Short links
|
|
145
|
+
compressAndEncrypt: shortLink.compressAndEncrypt,
|
|
146
|
+
uploadShortLink: shortLink.uploadShortLink,
|
|
147
|
+
buildShortUrl: shortLink.buildShortUrl,
|
|
148
|
+
|
|
149
|
+
// Default styles
|
|
150
|
+
mergeStyles: styles.mergeStyles,
|
|
151
|
+
applyDefaultStyles: styles.applyDefaultStyles,
|
|
152
|
+
|
|
153
|
+
// CLI parsing
|
|
154
|
+
parseArgs: io.parseArgs,
|
|
155
|
+
|
|
156
|
+
// Agent block (pure functions and constants)
|
|
157
|
+
AGENT_BLOCK_VERSION: agentBlock.AGENT_BLOCK_VERSION,
|
|
158
|
+
AGENT_BLOCK_BODY: agentBlock.AGENT_BLOCK_BODY,
|
|
159
|
+
formatAgentBlock: agentBlock.formatAgentBlock,
|
|
160
|
+
findBookendedBlock: agentBlock.findBookendedBlock,
|
|
161
|
+
findLegacyBlock: agentBlock.findLegacyBlock,
|
|
162
|
+
refreshContent: agentBlock.refreshContent,
|
|
163
|
+
compareVersions: agentBlock.compareVersions,
|
|
164
|
+
migrateSetupState: agentBlock.migrateSetupState,
|
|
165
|
+
implicitConsentState: agentBlock.implicitConsentState,
|
|
166
|
+
|
|
167
|
+
// Router (so contract tests can exercise it directly)
|
|
168
|
+
buildRouter,
|
|
169
|
+
CommandRouter: router.CommandRouter,
|
|
2127
170
|
};
|