sdocs-dev 1.15.0 → 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 +253 -50
- 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/commands.js +44 -4
- package/lib/help-text.js +272 -70
- package/lib/io.js +44 -3
- 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/commands.js
CHANGED
|
@@ -89,6 +89,36 @@ async function prepareCodewalkUrl(opts) {
|
|
|
89
89
|
return finishUrl(opts, content, null, defaults);
|
|
90
90
|
}
|
|
91
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
|
+
|
|
92
122
|
async function prepareUrl(opts) {
|
|
93
123
|
// Annotations render as a walkthrough: a tabbed tour for 2+ files, a single-
|
|
94
124
|
// tab stepper for one. A plain `sdoc app.py` with no annotations stays the
|
|
@@ -101,6 +131,7 @@ async function prepareUrl(opts) {
|
|
|
101
131
|
}
|
|
102
132
|
|
|
103
133
|
let content = await readContent(opts.file);
|
|
134
|
+
const documentLineOffset = frontMatterLineOffset(content);
|
|
104
135
|
const defaults = loadDefaultStyles();
|
|
105
136
|
if (content && defaults) {
|
|
106
137
|
content = applyDefaultStyles(content);
|
|
@@ -115,7 +146,10 @@ async function prepareUrl(opts) {
|
|
|
115
146
|
const parsed = SDocYaml.parseFrontMatter(content);
|
|
116
147
|
let changed = false;
|
|
117
148
|
if (!parsed.meta.file) { parsed.meta.file = path.basename(opts.file); changed = true; }
|
|
118
|
-
if (
|
|
149
|
+
if (applyDocumentWalkthrough(
|
|
150
|
+
parsed.meta, opts.file, opts.annotations, documentLineOffset)) {
|
|
151
|
+
changed = true;
|
|
152
|
+
} else if (opts.annotations && opts.annotations.length) {
|
|
119
153
|
// A single file needs no per-annotation `file` binding — drop it so the
|
|
120
154
|
// serialized shape stays {line, endLine, text}. (Multi-file keeps it, in
|
|
121
155
|
// prepareCodewalkUrl.)
|
|
@@ -238,6 +272,7 @@ function newCommand(opts) {
|
|
|
238
272
|
// sdoc slides list -> built-in template registry
|
|
239
273
|
// sdoc slides custom-shapes -> raw-shape reference
|
|
240
274
|
// sdoc slides icons [query] -> Lucide icon name listing
|
|
275
|
+
// sdoc slides verify <file> -> handled by slides-verify in the router
|
|
241
276
|
function slidesCommand(opts) {
|
|
242
277
|
const helpText = require('./help-text');
|
|
243
278
|
const sub = opts.file;
|
|
@@ -255,9 +290,11 @@ function presentCommand(opts) {
|
|
|
255
290
|
}
|
|
256
291
|
|
|
257
292
|
function printSlideStdlib() {
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
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');
|
|
261
298
|
const names = SDocSlideStdlib.names || Object.keys(SDocSlideStdlib.templates || {});
|
|
262
299
|
const slots = SDocSlideStdlib.slots || {};
|
|
263
300
|
console.log('Built-in slide templates');
|
|
@@ -329,6 +366,9 @@ function printIconList(query) {
|
|
|
329
366
|
}
|
|
330
367
|
|
|
331
368
|
module.exports = {
|
|
369
|
+
applyDocumentWalkthrough,
|
|
370
|
+
frontMatterLineOffset,
|
|
371
|
+
isMarkdownPath,
|
|
332
372
|
prepareUrl,
|
|
333
373
|
openCommand,
|
|
334
374
|
shareCommand,
|