sdocs-dev 1.6.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ // cells-transclude.js - bake {{path/to/file.csv}} references into ```cells
2
+ // blocks at CLI time.
3
+ //
4
+ // The browser can never read a local file, so a CSV reference only means
5
+ // something while the CLI is involved. On open (and on share), every
6
+ // ```cells block whose body is a bare {{...}} reference is replaced with the
7
+ // file's full contents plus a metadata line the renderer reads:
8
+ //
9
+ // ```cells ```cells
10
+ // {{data/report.csv}} -> sdoc-cells: source=report.csv
11
+ // ``` Region,Q1,Q2
12
+ // North,100,150
13
+ // ...
14
+ // ```
15
+ //
16
+ // The whole file is baked in (the user chose "whole CSV always travels"), so
17
+ // the resulting doc is self-contained and a share link never errors. Only the
18
+ // basename is recorded as `source=` - the full local path would leak the
19
+ // author's directory structure into a shared link. A read failure bakes an
20
+ // `error=` directive the renderer surfaces instead.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ // A ```cells fenced block (captures leading boundary, optional fence name, and
26
+ // body). The name (```cells Sales) is preserved on the baked block so a tab
27
+ // loaded from a CSV keeps its identity. Tilde fences and inline-data blocks are
28
+ // left untouched.
29
+ const CELLS_BLOCK = /(^|\n)```cells[ \t]*([^\n]*)\n([\s\S]*?)\n```/g;
30
+ const REFERENCE = /^\{\{\s*([^}]+?)\s*\}\}$/;
31
+ // A trailing :range suffix like :B5:J32 or :B5 (a view hint; data is baked in
32
+ // whole regardless, so we just strip it off the path for now).
33
+ const RANGE_SUFFIX = /:([A-Za-z]+\d+(?::[A-Za-z]+\d+)?)$/;
34
+
35
+ function directiveValue(s) {
36
+ return /\s|"/.test(s) ? JSON.stringify(s) : s;
37
+ }
38
+
39
+ function bakeBlock(boundary, name, ref, baseDir, readFile, preLines) {
40
+ var range = '';
41
+ var filePath = ref;
42
+ var rm = ref.match(RANGE_SUFFIX);
43
+ if (rm) { range = rm[1]; filePath = ref.slice(0, ref.length - rm[0].length); }
44
+
45
+ var fence = '```cells' + (name ? ' ' + name : '');
46
+ var base = path.basename(filePath);
47
+ // Author format: lines (e.g. `format: B=$`) sit before the reference and are
48
+ // preserved verbatim above the baked data.
49
+ var head = (preLines && preLines.length ? preLines.join('\n') + '\n' : '');
50
+ var csv;
51
+ try {
52
+ csv = readFile(path.resolve(baseDir, filePath));
53
+ } catch (e) {
54
+ return boundary + fence + '\n' + head + 'sdoc-cells: error=' +
55
+ directiveValue('Could not read ' + base) + '\n```';
56
+ }
57
+ csv = String(csv).replace(/\s+$/, '');
58
+ var directive = 'sdoc-cells: source=' + directiveValue(base) +
59
+ (range ? ' range=' + range : '');
60
+ return boundary + fence + '\n' + head + directive + '\n' + csv + '\n```';
61
+ }
62
+
63
+ // Replace every {{file.csv}} cells block in `content` with the baked data.
64
+ // `readFile` is injectable for tests; defaults to fs.readFileSync(utf-8).
65
+ function transcludeCells(content, baseDir, readFile) {
66
+ if (typeof content !== 'string' || content.indexOf('```cells') === -1) return content;
67
+ var read = readFile || function (p) { return fs.readFileSync(p, 'utf-8'); };
68
+ return content.replace(CELLS_BLOCK, function (whole, boundary, name, body) {
69
+ // Peel any leading author `format:` lines, then require a sole {{ref}}.
70
+ var lines = body.split('\n');
71
+ var pre = [];
72
+ var i = 0;
73
+ while (i < lines.length && /^\s*format:\s*/i.test(lines[i])) { pre.push(lines[i].trim()); i++; }
74
+ var rest = lines.slice(i).join('\n').trim();
75
+ var m = rest.match(REFERENCE);
76
+ if (!m) return whole; // inline data - leave alone
77
+ return bakeBlock(boundary, (name || '').trim(), m[1], baseDir, read, pre);
78
+ });
79
+ }
80
+
81
+ // Wrap a standalone .csv file's contents in a ```cells block (so `sdoc x.csv`
82
+ // opens as a sheet, mirroring the .mmd -> mermaid wrapping).
83
+ function wrapCsvFile(csv, filename) {
84
+ var base = path.basename(filename);
85
+ return '```cells\nsdoc-cells: source=' + directiveValue(base) + '\n' +
86
+ String(csv).replace(/\s+$/, '') + '\n```\n';
87
+ }
88
+
89
+ // A "wrapped" file is one whose renderable document is DERIVED from the file
90
+ // (its contents inside a fenced block) rather than the file itself. The two
91
+ // open paths (URL snapshot via readContent, live sync via the bridge) both
92
+ // wrap these for display, and the bridge refuses to save the derived view
93
+ // back - that would overwrite the .csv / .mmd with fence markup.
94
+ function isWrappedFile(filePath) {
95
+ return /\.(csv|mmd|mermaid)$/i.test(String(filePath || ''));
96
+ }
97
+
98
+ // File contents -> renderable document. Wrapped types get their fence;
99
+ // everything else (markdown) passes through untouched.
100
+ function wrapForDisplay(raw, filePath) {
101
+ var name = String(filePath || '');
102
+ if (/\.(mmd|mermaid)$/i.test(name)) {
103
+ return '```mermaid\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
104
+ }
105
+ if (/\.csv$/i.test(name)) return wrapCsvFile(raw, name);
106
+ return String(raw);
107
+ }
108
+
109
+ module.exports = {
110
+ transcludeCells: transcludeCells,
111
+ wrapCsvFile: wrapCsvFile,
112
+ isWrappedFile: isWrappedFile,
113
+ wrapForDisplay: wrapForDisplay,
114
+ };
@@ -0,0 +1,165 @@
1
+ // cells-verify.js - headless evaluation of a document's ```cells tabs.
2
+ //
3
+ // `sdoc cells verify <file.md>` parses every ```cells block, names the tabs
4
+ // exactly as the browser does, recalculates them together as one workbook
5
+ // (so cross-tab references like Sales!A1 resolve), and prints the COMPUTED
6
+ // values. It is the agent's feedback loop: write formulas, run this, read the
7
+ // numbers back - no browser, same engine the page runs (cli/shared/), so what
8
+ // it prints is what ships.
9
+ //
10
+ // Output:
11
+ // default CSV per tab, each under a "# sheet: <name>" banner. Human view;
12
+ // a data row could itself start with "# sheet:", so this form is
13
+ // documented as not machine-round-trippable - agents use --json.
14
+ // --json { ok, sheets:[{name, values:[[...]]}], errors:[{sheet,cell,code}] }
15
+ // --sheet N only that tab (case-insensitive). Absent name -> exit 2.
16
+ //
17
+ // Exit: 0 = no cell errors in the emitted tabs; 1 = at least one cell error
18
+ // (#REF!/#CIRC!/#DIV0!/...); 2 = bad usage (no file, --sheet names no tab).
19
+
20
+ const path = require('path');
21
+ const io = require('./io');
22
+ const CELLS = require('../shared/sdocs-cells.js');
23
+ const FX = require('../shared/sdocs-cells-formula.js');
24
+
25
+ // Scan ```cells (or ~~~cells) fenced blocks, capturing the fence info string
26
+ // as the tab name and the block body. Approximate vs marked (no nested-fence
27
+ // awareness) but matches how agents author docs. The closing fence must be the
28
+ // same run of fence characters (backreference) at a line start.
29
+ function scanCellsBlocks(md) {
30
+ var re = /(?:^|\n)(```+|~~~+)cells[ \t]*([^\n]*)\n([\s\S]*?)\n\1[ \t]*(?=\n|$)/g;
31
+ var blocks = [];
32
+ var m;
33
+ while ((m = re.exec(md))) {
34
+ blocks.push({ name: (m[2] || '').trim().replace(/"/g, ''), body: m[3] });
35
+ }
36
+ return blocks;
37
+ }
38
+
39
+ // Parse + name every block into a workbook, mirroring the renderer: a fence
40
+ // name wins, then an explicit `name=` directive, then auto Sheet1/Sheet2... by
41
+ // order among the blocks that became real grids. Non-grid blocks (empty /
42
+ // unresolved) are skipped; a parse error is recorded so the exit code reflects it.
43
+ function buildWorkbook(md) {
44
+ var blocks = scanCellsBlocks(md);
45
+ var sheets = []; // { name, model }
46
+ var parseErrors = [];
47
+ var autoIdx = 0;
48
+ for (var i = 0; i < blocks.length; i++) {
49
+ var model;
50
+ try { model = CELLS.parseCells(blocks[i].body); }
51
+ catch (e) { parseErrors.push((e && e.message) || 'parse error'); continue; }
52
+ if (model.error) { parseErrors.push(model.error); continue; }
53
+ if (model.unresolved || model.empty) continue;
54
+ var name = blocks[i].name || (model.name && String(model.name).trim()) || ('Sheet' + (++autoIdx));
55
+ sheets.push({ name: name, model: model });
56
+ }
57
+ return { sheets: sheets, parseErrors: parseErrors };
58
+ }
59
+
60
+ // Render one sheet's computed grid to a 2D array of display strings: a formula
61
+ // cell shows its result (number, or the error code), every other cell shows its
62
+ // literal text verbatim. fxGrid is this sheet's recalcWorkbook results.
63
+ function valuesFor(model, fxGrid) {
64
+ var out = [];
65
+ for (var r = 0; r < model.cells.length; r++) {
66
+ var line = model.cells[r];
67
+ var row = [];
68
+ for (var c = 0; c < line.length; c++) {
69
+ var cell = line[c];
70
+ if (FX.isFormula(cell.raw)) {
71
+ var fx = (fxGrid[r] && fxGrid[r][c]) || { kind: 'empty' };
72
+ if (fx.kind === 'number') row.push(String(fx.value));
73
+ else if (fx.kind === 'error') row.push(fx.code);
74
+ else if (fx.kind === 'text') row.push(String(fx.value));
75
+ else row.push('');
76
+ } else {
77
+ row.push(cell.raw);
78
+ }
79
+ }
80
+ out.push(row);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // Collect cell errors across a sheet for the exit code + --json errors list.
86
+ function errorsFor(name, model, fxGrid) {
87
+ var errs = [];
88
+ for (var r = 0; r < fxGrid.length; r++) {
89
+ var line = fxGrid[r] || [];
90
+ for (var c = 0; c < line.length; c++) {
91
+ var fx = line[c];
92
+ if (fx && fx.kind === 'error') {
93
+ errs.push({ sheet: name, cell: CELLS.colName(c) + (r + 1), code: fx.code });
94
+ }
95
+ }
96
+ }
97
+ return errs;
98
+ }
99
+
100
+ async function cellsVerifyCommand(opts) {
101
+ var file = opts.extra; // `sdoc cells verify <file>` -> file lands in extra
102
+ if (!file) {
103
+ console.error('sdoc: cells verify needs a file - usage: sdoc cells verify <file.md> [--json] [--sheet <name>]');
104
+ process.exit(2);
105
+ }
106
+
107
+ var content = await io.readContent(file); // same baking the browser receives
108
+ if (content == null) {
109
+ console.error('sdoc: nothing to read from ' + file);
110
+ process.exit(2);
111
+ }
112
+
113
+ var wb = buildWorkbook(content);
114
+ var fxGrids = FX.recalcWorkbook(wb.sheets.map(function (s) {
115
+ return { name: s.name, model: s.model };
116
+ }));
117
+
118
+ // Build the per-sheet view once; --sheet filters it afterwards.
119
+ var rendered = wb.sheets.map(function (s, i) {
120
+ return {
121
+ name: s.name,
122
+ values: valuesFor(s.model, fxGrids[i] || []),
123
+ errors: errorsFor(s.name, s.model, fxGrids[i] || []),
124
+ };
125
+ });
126
+
127
+ if (opts.sheetName) {
128
+ var want = String(opts.sheetName).toLowerCase();
129
+ var only = rendered.filter(function (r) { return r.name.toLowerCase() === want; });
130
+ if (!only.length) {
131
+ console.error('sdoc: no tab named "' + opts.sheetName + '" in ' + file);
132
+ process.exit(2);
133
+ }
134
+ rendered = only;
135
+ }
136
+
137
+ var allErrors = [];
138
+ rendered.forEach(function (r) { allErrors = allErrors.concat(r.errors); });
139
+ var ok = allErrors.length === 0 && wb.parseErrors.length === 0;
140
+
141
+ if (opts.jsonFlag) {
142
+ process.stdout.write(JSON.stringify({
143
+ ok: ok,
144
+ sheets: rendered.map(function (r) { return { name: r.name, values: r.values }; }),
145
+ errors: allErrors,
146
+ parseErrors: wb.parseErrors,
147
+ }, null, 2) + '\n');
148
+ } else {
149
+ if (!rendered.length) console.error('sdoc: no cells tabs found in ' + file);
150
+ var chunks = rendered.map(function (r) {
151
+ return '# sheet: ' + r.name + '\n' + CELLS.serializeCsv(r.values);
152
+ });
153
+ if (chunks.length) process.stdout.write(chunks.join('\n') + '\n');
154
+ wb.parseErrors.forEach(function (e) { console.error('sdoc: cells parse error - ' + e); });
155
+ }
156
+
157
+ process.exit(ok ? 0 : 1);
158
+ }
159
+
160
+ module.exports = {
161
+ cellsVerifyCommand: cellsVerifyCommand,
162
+ scanCellsBlocks: scanCellsBlocks,
163
+ buildWorkbook: buildWorkbook,
164
+ valuesFor: valuesFor,
165
+ };
@@ -0,0 +1,291 @@
1
+ // Verb handlers wired into the router.
2
+ //
3
+ // Each handler takes parsed opts and returns a Promise (or void). They
4
+ // share `prepareUrl` for the load-content / apply-defaults / build-URL
5
+ // flow that `open` and `share` both need.
6
+
7
+ const path = require('path');
8
+ const { execSync } = require('child_process');
9
+
10
+ const SDocYaml = require('../shared/sdocs-yaml.js');
11
+
12
+ const { DEFAULT_URL } = require('./constants');
13
+ const { readContent, openBrowser } = require('./io');
14
+ const { loadDefaultStyles, applyDefaultStyles, showDefaults, resetDefaults } = require('./styles');
15
+ const { buildUrl } = require('./url');
16
+ const { buildShortUrl } = require('./short-link');
17
+ const { refreshUpdateCache, maybeUpdateBinary } = require('./update-check');
18
+ const { runSetup, maybeAutoRefresh } = require('./setup');
19
+
20
+ // Shared "after the command ran" tail used by `open` and `share`.
21
+ async function postCommandHooks() {
22
+ refreshUpdateCache();
23
+ await maybeUpdateBinary();
24
+ await runSetup();
25
+ await maybeAutoRefresh();
26
+ }
27
+
28
+ // Load content (file or stdin), apply ~/.sdocs/styles.yaml defaults, inject
29
+ // `file:` into front matter, and build either a hash URL or a short URL.
30
+ // Returns { url, contentPresent }.
31
+ async function prepareUrl(opts) {
32
+ let content = await readContent(opts.file);
33
+ const defaults = loadDefaultStyles();
34
+ if (content && defaults) {
35
+ content = applyDefaultStyles(content);
36
+ }
37
+
38
+ // Inject `file:` into front matter (basename only — safe to share).
39
+ // Respects user-set file: if already present.
40
+ if (content && opts.file) {
41
+ const parsed = SDocYaml.parseFrontMatter(content);
42
+ if (!parsed.meta.file) {
43
+ parsed.meta.file = path.basename(opts.file);
44
+ content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
45
+ }
46
+ }
47
+
48
+ // Runtime-only local metadata for the opener's view.
49
+ // `share` omits it so shared URLs never carry paths.
50
+ let local = null;
51
+ if (opts.file && opts.subcommand !== 'share') {
52
+ const abs = path.resolve(opts.file);
53
+ const rel = path.relative(process.cwd(), abs);
54
+ local = { fullPath: abs };
55
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
56
+ local.path = './' + rel;
57
+ }
58
+ }
59
+
60
+ let url;
61
+ if (opts.shortFlag) {
62
+ if (opts.subcommand !== 'share') {
63
+ console.error('sdoc: --short is only valid with the `share` subcommand');
64
+ process.exit(1);
65
+ }
66
+ if (!content) {
67
+ console.error('sdoc: --short needs content (a file path or piped stdin)');
68
+ process.exit(1);
69
+ }
70
+ try {
71
+ url = await buildShortUrl(content, {
72
+ url: opts.url,
73
+ mode: opts.mode,
74
+ theme: opts.theme,
75
+ section: opts.section,
76
+ });
77
+ } catch (e) {
78
+ console.error('sdoc: could not create short link -', e.message);
79
+ process.exit(1);
80
+ }
81
+ } else {
82
+ url = buildUrl(content, {
83
+ url: opts.url,
84
+ mode: opts.mode,
85
+ theme: opts.theme,
86
+ defaultStyles: !content ? defaults : null,
87
+ section: opts.section,
88
+ local,
89
+ present: opts.present,
90
+ });
91
+ }
92
+
93
+ return { url, contentPresent: !!content };
94
+ }
95
+
96
+ // Default flow: `sdoc <file>` or `sdoc` (no args, or piped stdin).
97
+ //
98
+ // The document travels in the URL hash and renders read-only-by-default in the
99
+ // browser; nothing connects back to disk. This is the everywhere-works path -
100
+ // no local socket, no browser permission prompt. The live, autosaving session
101
+ // (browser <-> file on disk) is opt-in via `sdoc bridge <file>`.
102
+ //
103
+ // The non-blocking, share-by-URL case is `sdoc share <file>`.
104
+ async function openCommand(opts) {
105
+ const { url } = await prepareUrl(opts);
106
+ openBrowser(url);
107
+ console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
108
+ await postCommandHooks();
109
+ }
110
+
111
+ async function shareCommand(opts) {
112
+ const { url } = await prepareUrl(opts);
113
+ try {
114
+ const clip = process.platform === 'darwin' ? 'pbcopy'
115
+ : execSync('which xclip 2>/dev/null', { encoding: 'utf-8' }).trim() ? 'xclip -selection clipboard'
116
+ : 'xsel --clipboard --input';
117
+ execSync(clip, { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
118
+ const name = opts.file ? path.basename(opts.file) : 'stdin';
119
+ const label = opts.shortFlag ? 'Short link' : 'Link';
120
+ console.log(`✓ ${label} for ${name} copied to clipboard`);
121
+ if (opts.shortFlag) console.log(` ${url}`);
122
+ } catch (_) {
123
+ process.stdout.write(url + '\n');
124
+ }
125
+ await postCommandHooks();
126
+ }
127
+
128
+ function defaultsCommand(opts) {
129
+ if (opts.resetFlag) resetDefaults();
130
+ else showDefaults();
131
+ }
132
+
133
+ // `sdoc color-analysis <file>` — grade every text-on-background pair in the
134
+ // document's custom palette against WCAG ratios, for both the light and dark
135
+ // themes. Exits 1 if anything is unreadable so an agent (or CI) notices.
136
+ async function colorAnalysisCommand(opts) {
137
+ const SDocContrast = require('../shared/sdocs-contrast.js');
138
+ const content = await readContent(opts.file);
139
+ if (!content) {
140
+ console.error('sdoc color-analysis: pass a markdown file (or pipe one in)');
141
+ console.error(' e.g. sdoc color-analysis report.md');
142
+ process.exit(1);
143
+ }
144
+ const meta = SDocYaml.parseFrontMatter(content).meta || {};
145
+ const styles = meta.styles || null;
146
+ const name = opts.file ? path.basename(opts.file) : 'stdin';
147
+
148
+ if (!styles || !SDocContrast.hasCustomColors(styles)) {
149
+ console.log(`sdoc color-analysis: ${name}`);
150
+ console.log(' No custom colours set - the built-in palette is contrast-safe in both themes.');
151
+ process.exit(0);
152
+ }
153
+
154
+ const a = SDocContrast.analyzeStyles(styles);
155
+ const pad = (s, n) => (s + ' '.repeat(n)).slice(0, n);
156
+ const minRatio = SDocContrast.MIN_CONTRAST;
157
+ function line(p) {
158
+ const tag = p.ok ? 'ok ' : 'FAIL';
159
+ const ratio = p.ratio == null ? ' ? ' : (p.ratio.toFixed(2) + ':1');
160
+ return ` ${tag} ${pad(p.label, 16)} ${pad(p.fg + ' on ' + p.bg, 22)} ${pad(ratio, 9)} ${p.ok ? '' : '(needs ' + minRatio + ':1)'}`;
161
+ }
162
+
163
+ console.log(`sdoc color-analysis: ${name}\n`);
164
+ console.log('LIGHT THEME');
165
+ a.light.forEach(p => console.log(line(p)));
166
+ console.log('\nDARK THEME');
167
+ a.dark.forEach(p => console.log(line(p)));
168
+
169
+ console.log('');
170
+ if (a.fails.length === 0) {
171
+ console.log('All text/background pairs meet WCAG AA. ✓');
172
+ process.exit(0);
173
+ }
174
+ console.log(`${a.fails.length} unreadable pair${a.fails.length === 1 ? '' : 's'} (contrast below ${minRatio}:1).`);
175
+ console.log('Fix the flagged colours, or add a `dark:` override so the dark theme has its own readable values.');
176
+ console.log('Reminder: top-level colours are the LIGHT theme; dark mode is auto-derived unless you set `dark:`.');
177
+ process.exit(1);
178
+ }
179
+
180
+ function newCommand(opts) {
181
+ const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
182
+ const url = baseUrl + '/new';
183
+ openBrowser(url);
184
+ console.log(`SDocs → ${url}`);
185
+ }
186
+
187
+ // `sdoc slides` family. Dispatches on the positional after `slides`:
188
+ // sdoc slides -> prints SLIDES_HELP
189
+ // sdoc slides list -> built-in template registry
190
+ // sdoc slides custom-shapes -> raw-shape reference
191
+ // sdoc slides icons [query] -> Lucide icon name listing
192
+ function slidesCommand(opts) {
193
+ const helpText = require('./help-text');
194
+ const sub = opts.file;
195
+ if (sub === 'list') { printSlideStdlib(); return; }
196
+ if (sub === 'custom-shapes') { console.log(helpText.SLIDES_CUSTOM_SHAPES_HELP); return; }
197
+ if (sub === 'icons') { printIconList(opts.extra); return; }
198
+ console.log(helpText.SLIDES_HELP);
199
+ }
200
+
201
+ // `sdoc present <file>` opens the file straight into fullscreen slide
202
+ // view. Delegates to openCommand with `present: true` set so the URL
203
+ // gets `&present=0` and the browser auto-enters present mode on load.
204
+ function presentCommand(opts) {
205
+ return openCommand(Object.assign({}, opts, { present: true }));
206
+ }
207
+
208
+ function printSlideStdlib() {
209
+ // Require lazily so the browser-side slide stdlib (which uses window
210
+ // globals) is only loaded when this command actually runs.
211
+ const SDocSlideStdlib = require('../../public/sdocs-slide-stdlib.js');
212
+ const names = SDocSlideStdlib.names || Object.keys(SDocSlideStdlib.templates || {});
213
+ const slots = SDocSlideStdlib.slots || {};
214
+ console.log('Built-in slide templates');
215
+ console.log('========================');
216
+ const pad = 22;
217
+ for (let i = 0; i < names.length; i++) {
218
+ const n = names[i];
219
+ let label = '@extends ' + n;
220
+ while (label.length < pad) label += ' ';
221
+ const slotList = (slots[n] || []).join(', ');
222
+ console.log(label + ' ' + slotList);
223
+ }
224
+ console.log('');
225
+ console.log('`!` marks a required slot (resolver errors when omitted).');
226
+ console.log('Use a built-in by adding `@extends <name>` to a slide block.');
227
+ console.log('Define a user @template with the same name to override (you\'ll get a warning).');
228
+ }
229
+
230
+ function printIconList(query) {
231
+ let names;
232
+ try {
233
+ // The manifest sits next to this file (via cli/bin/). Require by
234
+ // resolved path so it works whether we're invoked from a globally
235
+ // installed binary or from a checkout.
236
+ names = require('../bin/sdocs-icon-names.js');
237
+ } catch (e) {
238
+ console.error('sdoc: icon names manifest missing (cli/bin/sdocs-icon-names.js).');
239
+ console.error('Run `node scripts/build-icons.js` to generate it.');
240
+ process.exit(1);
241
+ }
242
+
243
+ const q = (query || '').toLowerCase().trim();
244
+ const matches = q ? names.filter(n => n.indexOf(q) !== -1) : names;
245
+
246
+ if (q && matches.length === 0) {
247
+ console.log('No Lucide icons match "' + query + '".');
248
+ console.log('Browse the full set at https://lucide.dev/icons/ or run `sdoc slides icons` to list everything.');
249
+ return;
250
+ }
251
+
252
+ if (q) {
253
+ console.log('Lucide icons matching "' + query + '" (' + matches.length + ' of ' + names.length + ')');
254
+ } else {
255
+ console.log('Lucide icons available to the `icon` shape kind (' + names.length + ' total)');
256
+ }
257
+ console.log('Source: https://lucide.dev/icons/ - use `name=<icon>` in slides');
258
+ console.log('');
259
+
260
+ const longest = matches.reduce((m, n) => n.length > m ? n.length : m, 0);
261
+ const colWidth = longest + 2;
262
+ const cols = 4;
263
+ const rows = Math.ceil(matches.length / cols);
264
+ for (let r = 0; r < rows; r++) {
265
+ let line = '';
266
+ for (let c = 0; c < cols; c++) {
267
+ const idx = c * rows + r;
268
+ if (idx >= matches.length) break;
269
+ let name = matches[idx];
270
+ while (name.length < colWidth) name += ' ';
271
+ line += name;
272
+ }
273
+ console.log(line.replace(/\s+$/, ''));
274
+ }
275
+
276
+ if (!q) {
277
+ console.log('');
278
+ console.log('Tip: filter with `sdoc slides icons <substring>` (e.g. `sdoc slides icons cloud`).');
279
+ }
280
+ }
281
+
282
+ module.exports = {
283
+ prepareUrl,
284
+ openCommand,
285
+ shareCommand,
286
+ defaultsCommand,
287
+ colorAnalysisCommand,
288
+ newCommand,
289
+ slidesCommand,
290
+ presentCommand,
291
+ };