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.
- package/bin/sdocs-dev.js +35 -11
- package/lib/agent-block.js +254 -51
- package/lib/agent-files.js +308 -74
- package/lib/cells-verify.js +1 -0
- package/lib/cloud-bindings.js +85 -0
- package/lib/cloud-commands.js +741 -0
- package/lib/cloud-credentials.js +380 -0
- package/lib/code-langs.js +8 -2
- package/lib/commands.js +121 -40
- package/lib/help-text.js +342 -52
- package/lib/io.js +100 -5
- package/lib/library-commands.js +6 -15
- package/lib/library-scan.js +16 -6
- package/lib/library-server.js +4 -12
- package/lib/setup.js +145 -189
- package/lib/slides-verify.js +109 -0
- package/package.json +1 -1
- package/shared/sdocs-cells-formula.js +547 -73
- package/shared/sdocs-cells.js +164 -18
- package/shared/sdocs-shapes.js +1044 -0
- package/shared/sdocs-slide-resolve.js +258 -0
- package/shared/sdocs-slide-stdlib.js +180 -0
- package/shared/sdocs-styles.js +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
48
|
-
parsed.meta.
|
|
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
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
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,
|