sdocs-dev 1.14.1 → 1.18.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.
@@ -0,0 +1,380 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { execFileSync, spawnSync } = require('child_process');
6
+
7
+ const KEYCHAIN_SERVICE = 'org.smalldocs.cloud';
8
+ const KEYCHAIN_RECORD_VERSION = 2;
9
+ const KEYCHAIN_EXPECT = [
10
+ 'log_user 0',
11
+ 'set timeout 15',
12
+ 'set secret [gets stdin]',
13
+ 'spawn security add-generic-password -U -s "' + KEYCHAIN_SERVICE + '" -a "$env(SDOCS_KEYCHAIN_ACCOUNT)" -w',
14
+ 'expect -re {password.*item.*:}',
15
+ 'send -- "$secret\\r"',
16
+ 'expect -re {retype.*item.*:}',
17
+ 'send -- "$secret\\r"',
18
+ 'expect eof',
19
+ 'catch wait result',
20
+ 'exit [lindex $result 3]',
21
+ ].join('\n');
22
+ const DPAPI_PROTECT = [
23
+ '$ErrorActionPreference = "Stop"',
24
+ 'Add-Type -AssemblyName System.Security',
25
+ '$plain = [Console]::In.ReadToEnd()',
26
+ '$bytes = [Text.Encoding]::UTF8.GetBytes($plain)',
27
+ '$cipher = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
28
+ '[Console]::Out.Write([Convert]::ToBase64String($cipher))',
29
+ ].join('; ');
30
+ const DPAPI_UNPROTECT = [
31
+ '$ErrorActionPreference = "Stop"',
32
+ 'Add-Type -AssemblyName System.Security',
33
+ '$encoded = [Console]::In.ReadToEnd()',
34
+ '$cipher = [Convert]::FromBase64String($encoded)',
35
+ '$plain = [Security.Cryptography.ProtectedData]::Unprotect($cipher, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
36
+ '[Console]::Out.Write([Text.Encoding]::UTF8.GetString($plain))',
37
+ ].join('; ');
38
+
39
+ function cloudDir() {
40
+ return path.join(process.env.SDOCS_HOME || path.join(os.homedir(), '.sdocs'), 'cloud');
41
+ }
42
+
43
+ function credentialFile() { return path.join(cloudDir(), 'credentials.json'); }
44
+ function dpapiCredentialFile(origin) {
45
+ if (!origin) return path.join(cloudDir(), 'credentials.dpapi');
46
+ const id = crypto.createHash('sha256').update(origin).digest('hex');
47
+ return path.join(cloudDir(), 'credentials-' + id + '.dpapi');
48
+ }
49
+ function encryptedCredentialFile(origin, generation) {
50
+ const id = crypto.createHash('sha256').update(origin).digest('hex');
51
+ return path.join(cloudDir(), 'credentials-' + id +
52
+ (generation ? '-' + generation : '') + '.enc');
53
+ }
54
+
55
+ function atomicWriteRaw(file, value) {
56
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
57
+ try { fs.chmodSync(path.dirname(file), 0o700); } catch (_) {}
58
+ const temporary = file + '.tmp-' + process.pid + '-' + crypto.randomBytes(4).toString('hex');
59
+ fs.writeFileSync(temporary, value, { mode: 0o600 });
60
+ try { fs.chmodSync(temporary, 0o600); } catch (_) {}
61
+ fs.renameSync(temporary, file);
62
+ }
63
+
64
+ function atomicWrite(file, value) {
65
+ atomicWriteRaw(file, JSON.stringify(value, null, 2) + '\n');
66
+ }
67
+
68
+ function readJson(file) {
69
+ try {
70
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
71
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
72
+ } catch (_) { return {}; }
73
+ }
74
+
75
+ function readFileStore() { return readJson(credentialFile()); }
76
+
77
+ function keychainAccount(origin) {
78
+ return Buffer.from(origin).toString('base64url');
79
+ }
80
+
81
+ function keychainReadAccount(account) {
82
+ try {
83
+ return execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE,
84
+ '-a', account, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
85
+ } catch (error) {
86
+ if (error && Number(error.status) === 44) return null;
87
+ throw new Error('Could not read the SmallDocs Cloud credential key from macOS Keychain.');
88
+ }
89
+ }
90
+
91
+ function keychainWriteAccount(account, value, execute) {
92
+ const run = execute || spawnSync;
93
+ const result = run('/usr/bin/expect', ['-c', KEYCHAIN_EXPECT], {
94
+ input: value + '\n',
95
+ encoding: 'utf8',
96
+ env: Object.assign({}, process.env, { SDOCS_KEYCHAIN_ACCOUNT: account }),
97
+ stdio: ['pipe', 'ignore', 'ignore'],
98
+ });
99
+ if (result.error) throw result.error;
100
+ if (result.status !== 0) {
101
+ throw new Error('Could not save the Cloud credential key to macOS Keychain.');
102
+ }
103
+ }
104
+
105
+ function keychainDeleteAccount(account) {
106
+ try {
107
+ execFileSync('security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE,
108
+ '-a', account], { stdio: 'ignore' });
109
+ } catch (_) {}
110
+ }
111
+
112
+ function keychainOperations(operations) {
113
+ if (operations && typeof operations === 'object') return operations;
114
+ if (typeof operations === 'function') {
115
+ return {
116
+ read() { return null; },
117
+ write(account, value) { keychainWriteAccount(account, value, operations); },
118
+ remove: keychainDeleteAccount,
119
+ };
120
+ }
121
+ return {
122
+ read: keychainReadAccount,
123
+ write(account, value) { keychainWriteAccount(account, value); },
124
+ remove: keychainDeleteAccount,
125
+ };
126
+ }
127
+
128
+ function parseKeychainRecord(raw) {
129
+ if (!raw) return null;
130
+ try {
131
+ const value = JSON.parse(raw);
132
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
133
+ if (value.v === KEYCHAIN_RECORD_VERSION && typeof value.key === 'string') {
134
+ const key = Buffer.from(value.key, 'base64url');
135
+ const generation = value.file == null ? null : String(value.file);
136
+ if (key.length !== 32 || key.toString('base64url') !== value.key ||
137
+ (generation != null && !/^[a-f0-9]{16}$/.test(generation))) return null;
138
+ return { type: 'key', key, generation };
139
+ }
140
+ return { type: 'legacy', credential: value };
141
+ } catch (_) { return null; }
142
+ }
143
+
144
+ function encryptCredential(credential, key) {
145
+ const iv = crypto.randomBytes(12);
146
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
147
+ const ciphertext = Buffer.concat([
148
+ cipher.update(JSON.stringify(credential), 'utf8'),
149
+ cipher.final(),
150
+ ]);
151
+ return JSON.stringify({
152
+ v: 1,
153
+ iv: iv.toString('base64url'),
154
+ tag: cipher.getAuthTag().toString('base64url'),
155
+ ciphertext: ciphertext.toString('base64url'),
156
+ }) + '\n';
157
+ }
158
+
159
+ function decryptCredential(value, key) {
160
+ const envelope = JSON.parse(value);
161
+ if (!envelope || envelope.v !== 1 || typeof envelope.iv !== 'string' ||
162
+ typeof envelope.tag !== 'string' || typeof envelope.ciphertext !== 'string') return null;
163
+ const iv = Buffer.from(envelope.iv, 'base64url');
164
+ const tag = Buffer.from(envelope.tag, 'base64url');
165
+ if (iv.length !== 12 || tag.length !== 16) return null;
166
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
167
+ decipher.setAuthTag(tag);
168
+ const plain = Buffer.concat([
169
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64url')),
170
+ decipher.final(),
171
+ ]).toString('utf8');
172
+ const credential = JSON.parse(plain);
173
+ return credential && typeof credential === 'object' && !Array.isArray(credential) ? credential : null;
174
+ }
175
+
176
+ function keychainLoad(origin, operations) {
177
+ const ops = keychainOperations(operations);
178
+ const record = parseKeychainRecord(ops.read(keychainAccount(origin)));
179
+ if (!record) return null;
180
+ if (record.type === 'legacy') return record.credential;
181
+ try {
182
+ return decryptCredential(fs.readFileSync(
183
+ encryptedCredentialFile(origin, record.generation), 'utf8'), record.key);
184
+ } catch (_) { return null; }
185
+ }
186
+
187
+ function keychainSave(origin, credential, operations) {
188
+ const ops = keychainOperations(operations);
189
+ const account = keychainAccount(origin);
190
+ const current = parseKeychainRecord(ops.read(account));
191
+ const key = current && current.type === 'key' ? current.key : crypto.randomBytes(32);
192
+ const generation = current && current.type === 'key'
193
+ ? current.generation : crypto.randomBytes(8).toString('hex');
194
+ const file = encryptedCredentialFile(origin, generation);
195
+ atomicWriteRaw(file, encryptCredential(credential, key));
196
+ if (!current || current.type !== 'key') {
197
+ const record = JSON.stringify({ v: KEYCHAIN_RECORD_VERSION,
198
+ key: key.toString('base64url'), file: generation });
199
+ try { ops.write(account, record); }
200
+ catch (error) {
201
+ try { fs.unlinkSync(file); } catch (_) {}
202
+ throw error;
203
+ }
204
+ }
205
+ }
206
+
207
+ function keychainDelete(origin, operations) {
208
+ const ops = keychainOperations(operations);
209
+ const record = parseKeychainRecord(ops.read(keychainAccount(origin)));
210
+ ops.remove(keychainAccount(origin));
211
+ const file = encryptedCredentialFile(origin,
212
+ record && record.type === 'key' ? record.generation : null);
213
+ try { fs.unlinkSync(file); } catch (_) {}
214
+ }
215
+
216
+ function removeFileCredential(origin) {
217
+ const values = readFileStore();
218
+ delete values[origin];
219
+ if (Object.keys(values).length) atomicWrite(credentialFile(), values);
220
+ else {
221
+ try { fs.unlinkSync(credentialFile()); }
222
+ catch (error) { if (!error || error.code !== 'ENOENT') throw error; }
223
+ }
224
+ }
225
+
226
+ function retryFileCredentialCleanup(origin) {
227
+ try { removeFileCredential(origin); } catch (_) {}
228
+ }
229
+
230
+ function macLoad(origin, operations) {
231
+ const credential = keychainLoad(origin, operations);
232
+ if (credential) {
233
+ if (readFileStore()[origin]) retryFileCredentialCleanup(origin);
234
+ return credential;
235
+ }
236
+ const legacy = readFileStore()[origin] || null;
237
+ if (!legacy) return null;
238
+ keychainSave(origin, legacy, operations);
239
+ retryFileCredentialCleanup(origin);
240
+ return legacy;
241
+ }
242
+
243
+ function macSave(origin, credential, operations) {
244
+ keychainSave(origin, credential, operations);
245
+ retryFileCredentialCleanup(origin);
246
+ }
247
+
248
+ function macRemove(origin, operations) {
249
+ keychainDelete(origin, operations);
250
+ removeFileCredential(origin);
251
+ }
252
+
253
+ function runDpapi(script, input, execute) {
254
+ const run = execute || spawnSync;
255
+ const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive',
256
+ '-Command', script], {
257
+ input,
258
+ encoding: 'utf8',
259
+ windowsHide: true,
260
+ stdio: ['pipe', 'pipe', 'ignore'],
261
+ });
262
+ if (result.error) throw result.error;
263
+ if (result.status !== 0 || typeof result.stdout !== 'string' || !result.stdout.trim()) {
264
+ throw new Error('Windows could not protect the SmallDocs Cloud credential.');
265
+ }
266
+ return result.stdout.trim();
267
+ }
268
+
269
+ function validBase64(value) {
270
+ if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
271
+ try {
272
+ const decoded = Buffer.from(value, 'base64');
273
+ return decoded.length > 0 && decoded.toString('base64') === value;
274
+ } catch (_) { return false; }
275
+ }
276
+
277
+ function readDpapiCredential(origin, execute) {
278
+ const file = dpapiCredentialFile(origin);
279
+ if (!fs.existsSync(file)) return null;
280
+ try {
281
+ const plain = runDpapi(DPAPI_UNPROTECT, fs.readFileSync(file, 'utf8').trim(), execute);
282
+ const value = JSON.parse(plain);
283
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid store');
284
+ return value;
285
+ } catch (_) {
286
+ throw new Error('Could not read the Windows-protected SmallDocs Cloud credential.');
287
+ }
288
+ }
289
+
290
+ function writeDpapiCredential(origin, credential, execute) {
291
+ const encrypted = runDpapi(DPAPI_PROTECT, JSON.stringify(credential), execute);
292
+ if (!validBase64(encrypted)) {
293
+ throw new Error('Windows returned an invalid protected SmallDocs Cloud credential.');
294
+ }
295
+ atomicWriteRaw(dpapiCredentialFile(origin), encrypted + '\n');
296
+ }
297
+
298
+ function windowsLoad(origin, execute) {
299
+ const protectedCredential = readDpapiCredential(origin, execute);
300
+ if (protectedCredential) {
301
+ if (readFileStore()[origin]) retryFileCredentialCleanup(origin);
302
+ return protectedCredential;
303
+ }
304
+ const legacy = readFileStore()[origin] || null;
305
+ if (!legacy) return null;
306
+ writeDpapiCredential(origin, legacy, execute);
307
+ retryFileCredentialCleanup(origin);
308
+ return legacy;
309
+ }
310
+
311
+ function windowsSave(origin, credential, execute) {
312
+ writeDpapiCredential(origin, credential, execute);
313
+ retryFileCredentialCleanup(origin);
314
+ }
315
+
316
+ function windowsRemove(origin) {
317
+ try { fs.unlinkSync(dpapiCredentialFile(origin)); }
318
+ catch (error) { if (!error || error.code !== 'ENOENT') throw error; }
319
+ removeFileCredential(origin);
320
+ }
321
+
322
+ function forceFileCredentials() {
323
+ return process.env.SDOCS_CLOUD_FILE_CREDENTIALS === '1';
324
+ }
325
+
326
+ function useKeychain() {
327
+ return process.platform === 'darwin' && !forceFileCredentials();
328
+ }
329
+
330
+ function useDpapi() {
331
+ return process.platform === 'win32' && !forceFileCredentials();
332
+ }
333
+
334
+ function load(origin) {
335
+ if (useKeychain()) return macLoad(origin);
336
+ if (useDpapi()) return windowsLoad(origin);
337
+ return readFileStore()[origin] || null;
338
+ }
339
+
340
+ function save(origin, credential) {
341
+ if (useKeychain()) return macSave(origin, credential);
342
+ if (useDpapi()) return windowsSave(origin, credential);
343
+ const values = readFileStore();
344
+ values[origin] = credential;
345
+ atomicWrite(credentialFile(), values);
346
+ }
347
+
348
+ function remove(origin) {
349
+ if (useKeychain()) return macRemove(origin);
350
+ if (useDpapi()) return windowsRemove(origin);
351
+ const values = readFileStore();
352
+ delete values[origin];
353
+ atomicWrite(credentialFile(), values);
354
+ }
355
+
356
+ module.exports = {
357
+ cloudDir,
358
+ credentialFile,
359
+ dpapiCredentialFile,
360
+ encryptedCredentialFile,
361
+ atomicWrite,
362
+ load,
363
+ save,
364
+ remove,
365
+ useKeychain,
366
+ useDpapi,
367
+ keychainLoad,
368
+ keychainSave,
369
+ keychainDelete,
370
+ macLoad,
371
+ macSave,
372
+ macRemove,
373
+ readDpapiCredential,
374
+ writeDpapiCredential,
375
+ windowsLoad,
376
+ windowsSave,
377
+ windowsRemove,
378
+ DPAPI_PROTECT,
379
+ DPAPI_UNPROTECT,
380
+ };
package/lib/code-langs.js CHANGED
@@ -72,9 +72,15 @@ function isCodeFile(filePath) {
72
72
 
73
73
  // File contents -> a fenced code document. Trailing whitespace is trimmed so a
74
74
  // file's final newline doesn't render as an empty last line in the block.
75
- function wrapCodeFile(raw, filePath) {
75
+ //
76
+ // `label` (optional) is appended to the fence info string after the language,
77
+ // e.g. wrapCodeFile(src, 'app.py', 'app.py') -> ```python app.py. A multi-file
78
+ // code walkthrough uses this so the browser can name each tab; a plain single
79
+ // `sdoc app.py` passes no label and the fence stays ```python.
80
+ function wrapCodeFile(raw, filePath, label) {
76
81
  var lang = langForFile(filePath);
77
- return '```' + lang + '\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
82
+ var info = label ? (lang + ' ' + String(label).trim()) : lang;
83
+ return '```' + info + '\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
78
84
  }
79
85
 
80
86
  module.exports = {
package/lib/commands.js CHANGED
@@ -10,7 +10,7 @@ const { execSync } = require('child_process');
10
10
  const SDocYaml = require('../shared/sdocs-yaml.js');
11
11
 
12
12
  const { DEFAULT_URL } = require('./constants');
13
- const { readContent, openBrowser } = require('./io');
13
+ const { readContent, readCodewalkContent, openBrowser } = require('./io');
14
14
  const { loadDefaultStyles, applyDefaultStyles, showDefaults, resetDefaults } = require('./styles');
15
15
  const { buildUrl } = require('./url');
16
16
  const { buildShortUrl } = require('./short-link');
@@ -28,8 +28,110 @@ async function postCommandHooks() {
28
28
  // Load content (file or stdin), apply ~/.sdocs/styles.yaml defaults, inject
29
29
  // `file:` into front matter, and build either a hash URL or a short URL.
30
30
  // Returns { url, contentPresent }.
31
+ // Build a hash URL (or short URL for `share --short`) from finished content.
32
+ // Shared by the single-file and the code-walkthrough paths so both honour
33
+ // `--short`, mode, theme, section, and present identically.
34
+ async function finishUrl(opts, content, local, defaults) {
35
+ if (opts.shortFlag) {
36
+ if (opts.subcommand !== 'share') {
37
+ console.error('sdoc: --short is only valid with the `share` subcommand');
38
+ process.exit(1);
39
+ }
40
+ if (!content) {
41
+ console.error('sdoc: --short needs content (a file path or piped stdin)');
42
+ process.exit(1);
43
+ }
44
+ try {
45
+ const url = await buildShortUrl(content, {
46
+ url: opts.url, mode: opts.mode, theme: opts.theme, section: opts.section,
47
+ });
48
+ return { url, contentPresent: !!content };
49
+ } catch (e) {
50
+ console.error('sdoc: could not create short link -', e.message);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ const url = buildUrl(content, {
55
+ url: opts.url,
56
+ mode: opts.mode,
57
+ theme: opts.theme,
58
+ defaultStyles: !content ? defaults : null,
59
+ section: opts.section,
60
+ local,
61
+ present: opts.present,
62
+ });
63
+ return { url, contentPresent: !!content };
64
+ }
65
+
66
+ // `sdoc file1.py 4:"..." file2.py 13:"..."` — two or more source files become
67
+ // one code-walkthrough document: a tabbed multi-file view whose annotations
68
+ // step in command order across the tabs. The browser keys off `codewalk: true`
69
+ // in front matter. Front matter carries only basenames, so it is share-safe.
70
+ async function prepareCodewalkUrl(opts) {
71
+ const { body, files } = readCodewalkContent(opts.files);
72
+
73
+ const meta = { codewalk: true, files };
74
+ const anns = (opts.annotations || []).map((a) => {
75
+ // Bind to the cursor file's basename; fall back to the first tab when an
76
+ // annotation was given before any file (or its file dropped out).
77
+ let base = a.file ? path.basename(a.file) : files[0];
78
+ if (files.indexOf(base) === -1) base = files[0];
79
+ return { file: base, line: a.line, endLine: a.endLine, text: a.text };
80
+ });
81
+ if (anns.length) meta.annotations = anns;
82
+
83
+ let content = SDocYaml.serializeFrontMatter(meta) + '\n' + body;
84
+ const defaults = loadDefaultStyles();
85
+ if (defaults) content = applyDefaultStyles(content);
86
+
87
+ // local (the edit-this-file affordance) is single-file today; the
88
+ // walkthrough renders entirely from the shared front matter for now.
89
+ return finishUrl(opts, content, null, defaults);
90
+ }
91
+
92
+ function isMarkdownPath(file) {
93
+ return /\.(?:md|markdown|mdown|mkd)$/i.test(String(file || ''));
94
+ }
95
+
96
+ function frontMatterLineOffset(content) {
97
+ const match = String(content || '').match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
98
+ return match ? (match[0].match(/\n/g) || []).length : 0;
99
+ }
100
+
101
+ // A regular Markdown file uses the same line annotation syntax as a source
102
+ // file. `docwalk: true` tells the reader to resolve those source lines against
103
+ // rendered prose and rich blocks instead of offering them to the code viewer.
104
+ function applyDocumentWalkthrough(meta, file, annotations, lineOffset) {
105
+ if (!isMarkdownPath(file) || !Array.isArray(annotations) || !annotations.length) {
106
+ return false;
107
+ }
108
+ const offset = Math.max(0, parseInt(lineOffset, 10) || 0);
109
+ meta.docwalk = true;
110
+ meta.annotations = annotations.flatMap(({ file: _file, ...rest }) => {
111
+ const sourceLine = parseInt(rest.line, 10);
112
+ const parsedEnd = parseInt(rest.endLine, 10);
113
+ const sourceEnd = parsedEnd >= sourceLine ? parsedEnd : sourceLine;
114
+ if (!(sourceLine >= 1) || sourceEnd <= offset) return [];
115
+ const line = Math.max(1, sourceLine - offset);
116
+ const endLine = Math.max(line, sourceEnd - offset);
117
+ return [{ ...rest, line, endLine }];
118
+ });
119
+ return true;
120
+ }
121
+
31
122
  async function prepareUrl(opts) {
123
+ // Annotations render as a walkthrough: a tabbed tour for 2+ files, a single-
124
+ // tab stepper for one. A plain `sdoc app.py` with no annotations stays the
125
+ // ordinary single-file view. Walkthrough order is the order the annotations
126
+ // were given on the command line, not their line order.
127
+ const files = opts.files || [];
128
+ const anns = opts.annotations || [];
129
+ if (files.length > 1 || (files.length >= 1 && anns.length > 0)) {
130
+ return prepareCodewalkUrl(opts);
131
+ }
132
+
32
133
  let content = await readContent(opts.file);
134
+ const documentLineOffset = frontMatterLineOffset(content);
33
135
  const defaults = loadDefaultStyles();
34
136
  if (content && defaults) {
35
137
  content = applyDefaultStyles(content);
@@ -44,8 +146,14 @@ async function prepareUrl(opts) {
44
146
  const parsed = SDocYaml.parseFrontMatter(content);
45
147
  let changed = false;
46
148
  if (!parsed.meta.file) { parsed.meta.file = path.basename(opts.file); changed = true; }
47
- if (opts.annotations && opts.annotations.length) {
48
- parsed.meta.annotations = opts.annotations;
149
+ if (applyDocumentWalkthrough(
150
+ parsed.meta, opts.file, opts.annotations, documentLineOffset)) {
151
+ changed = true;
152
+ } else if (opts.annotations && opts.annotations.length) {
153
+ // A single file needs no per-annotation `file` binding — drop it so the
154
+ // serialized shape stays {line, endLine, text}. (Multi-file keeps it, in
155
+ // prepareCodewalkUrl.)
156
+ parsed.meta.annotations = opts.annotations.map(({ file, ...rest }) => rest);
49
157
  changed = true;
50
158
  }
51
159
  if (changed) {
@@ -65,40 +173,7 @@ async function prepareUrl(opts) {
65
173
  }
66
174
  }
67
175
 
68
- let url;
69
- if (opts.shortFlag) {
70
- if (opts.subcommand !== 'share') {
71
- console.error('sdoc: --short is only valid with the `share` subcommand');
72
- process.exit(1);
73
- }
74
- if (!content) {
75
- console.error('sdoc: --short needs content (a file path or piped stdin)');
76
- process.exit(1);
77
- }
78
- try {
79
- url = await buildShortUrl(content, {
80
- url: opts.url,
81
- mode: opts.mode,
82
- theme: opts.theme,
83
- section: opts.section,
84
- });
85
- } catch (e) {
86
- console.error('sdoc: could not create short link -', e.message);
87
- process.exit(1);
88
- }
89
- } else {
90
- url = buildUrl(content, {
91
- url: opts.url,
92
- mode: opts.mode,
93
- theme: opts.theme,
94
- defaultStyles: !content ? defaults : null,
95
- section: opts.section,
96
- local,
97
- present: opts.present,
98
- });
99
- }
100
-
101
- return { url, contentPresent: !!content };
176
+ return finishUrl(opts, content, local, defaults);
102
177
  }
103
178
 
104
179
  // Default flow: `sdoc <file>` or `sdoc` (no args, or piped stdin).
@@ -197,6 +272,7 @@ function newCommand(opts) {
197
272
  // sdoc slides list -> built-in template registry
198
273
  // sdoc slides custom-shapes -> raw-shape reference
199
274
  // sdoc slides icons [query] -> Lucide icon name listing
275
+ // sdoc slides verify <file> -> handled by slides-verify in the router
200
276
  function slidesCommand(opts) {
201
277
  const helpText = require('./help-text');
202
278
  const sub = opts.file;
@@ -214,9 +290,11 @@ function presentCommand(opts) {
214
290
  }
215
291
 
216
292
  function printSlideStdlib() {
217
- // Require lazily so the browser-side slide stdlib (which uses window
218
- // globals) is only loaded when this command actually runs.
219
- const SDocSlideStdlib = require('../../public/sdocs-slide-stdlib.js');
293
+ // The registry lives inside the published CLI package and is symlinked
294
+ // into public/ for the browser. Reaching through ../../public works from
295
+ // a repository checkout but fails after npm or URL installation because
296
+ // the server-side public/ directory is not part of the CLI tarball.
297
+ const SDocSlideStdlib = require('../shared/sdocs-slide-stdlib.js');
220
298
  const names = SDocSlideStdlib.names || Object.keys(SDocSlideStdlib.templates || {});
221
299
  const slots = SDocSlideStdlib.slots || {};
222
300
  console.log('Built-in slide templates');
@@ -288,6 +366,9 @@ function printIconList(query) {
288
366
  }
289
367
 
290
368
  module.exports = {
369
+ applyDocumentWalkthrough,
370
+ frontMatterLineOffset,
371
+ isMarkdownPath,
291
372
  prepareUrl,
292
373
  openCommand,
293
374
  shareCommand,