sdocs-dev 1.6.1 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sdocs-bridge.js +974 -0
- package/bin/sdocs-dev.js +145 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +111 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2706 -0
- package/lib/io.js +173 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +280 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +332 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -18
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
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 + body). Tilde fences and
|
|
26
|
+
// inline-data blocks are left untouched.
|
|
27
|
+
const CELLS_BLOCK = /(^|\n)```cells[ \t]*\n([\s\S]*?)\n```/g;
|
|
28
|
+
const REFERENCE = /^\{\{\s*([^}]+?)\s*\}\}$/;
|
|
29
|
+
// A trailing :range suffix like :B5:J32 or :B5 (a view hint; data is baked in
|
|
30
|
+
// whole regardless, so we just strip it off the path for now).
|
|
31
|
+
const RANGE_SUFFIX = /:([A-Za-z]+\d+(?::[A-Za-z]+\d+)?)$/;
|
|
32
|
+
|
|
33
|
+
function directiveValue(s) {
|
|
34
|
+
return /\s|"/.test(s) ? JSON.stringify(s) : s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bakeBlock(boundary, ref, baseDir, readFile, preLines) {
|
|
38
|
+
var range = '';
|
|
39
|
+
var filePath = ref;
|
|
40
|
+
var rm = ref.match(RANGE_SUFFIX);
|
|
41
|
+
if (rm) { range = rm[1]; filePath = ref.slice(0, ref.length - rm[0].length); }
|
|
42
|
+
|
|
43
|
+
var base = path.basename(filePath);
|
|
44
|
+
// Author format: lines (e.g. `format: B=$`) sit before the reference and are
|
|
45
|
+
// preserved verbatim above the baked data.
|
|
46
|
+
var head = (preLines && preLines.length ? preLines.join('\n') + '\n' : '');
|
|
47
|
+
var csv;
|
|
48
|
+
try {
|
|
49
|
+
csv = readFile(path.resolve(baseDir, filePath));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return boundary + '```cells\n' + head + 'sdoc-cells: error=' +
|
|
52
|
+
directiveValue('Could not read ' + base) + '\n```';
|
|
53
|
+
}
|
|
54
|
+
csv = String(csv).replace(/\s+$/, '');
|
|
55
|
+
var directive = 'sdoc-cells: source=' + directiveValue(base) +
|
|
56
|
+
(range ? ' range=' + range : '');
|
|
57
|
+
return boundary + '```cells\n' + head + directive + '\n' + csv + '\n```';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Replace every {{file.csv}} cells block in `content` with the baked data.
|
|
61
|
+
// `readFile` is injectable for tests; defaults to fs.readFileSync(utf-8).
|
|
62
|
+
function transcludeCells(content, baseDir, readFile) {
|
|
63
|
+
if (typeof content !== 'string' || content.indexOf('```cells') === -1) return content;
|
|
64
|
+
var read = readFile || function (p) { return fs.readFileSync(p, 'utf-8'); };
|
|
65
|
+
return content.replace(CELLS_BLOCK, function (whole, boundary, body) {
|
|
66
|
+
// Peel any leading author `format:` lines, then require a sole {{ref}}.
|
|
67
|
+
var lines = body.split('\n');
|
|
68
|
+
var pre = [];
|
|
69
|
+
var i = 0;
|
|
70
|
+
while (i < lines.length && /^\s*format:\s*/i.test(lines[i])) { pre.push(lines[i].trim()); i++; }
|
|
71
|
+
var rest = lines.slice(i).join('\n').trim();
|
|
72
|
+
var m = rest.match(REFERENCE);
|
|
73
|
+
if (!m) return whole; // inline data - leave alone
|
|
74
|
+
return bakeBlock(boundary, m[1], baseDir, read, pre);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wrap a standalone .csv file's contents in a ```cells block (so `sdoc x.csv`
|
|
79
|
+
// opens as a sheet, mirroring the .mmd -> mermaid wrapping).
|
|
80
|
+
function wrapCsvFile(csv, filename) {
|
|
81
|
+
var base = path.basename(filename);
|
|
82
|
+
return '```cells\nsdoc-cells: source=' + directiveValue(base) + '\n' +
|
|
83
|
+
String(csv).replace(/\s+$/, '') + '\n```\n';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A "wrapped" file is one whose renderable document is DERIVED from the file
|
|
87
|
+
// (its contents inside a fenced block) rather than the file itself. The two
|
|
88
|
+
// open paths (URL snapshot via readContent, live sync via the bridge) both
|
|
89
|
+
// wrap these for display, and the bridge refuses to save the derived view
|
|
90
|
+
// back - that would overwrite the .csv / .mmd with fence markup.
|
|
91
|
+
function isWrappedFile(filePath) {
|
|
92
|
+
return /\.(csv|mmd|mermaid)$/i.test(String(filePath || ''));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// File contents -> renderable document. Wrapped types get their fence;
|
|
96
|
+
// everything else (markdown) passes through untouched.
|
|
97
|
+
function wrapForDisplay(raw, filePath) {
|
|
98
|
+
var name = String(filePath || '');
|
|
99
|
+
if (/\.(mmd|mermaid)$/i.test(name)) {
|
|
100
|
+
return '```mermaid\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
|
|
101
|
+
}
|
|
102
|
+
if (/\.csv$/i.test(name)) return wrapCsvFile(raw, name);
|
|
103
|
+
return String(raw);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
transcludeCells: transcludeCells,
|
|
108
|
+
wrapCsvFile: wrapCsvFile,
|
|
109
|
+
isWrappedFile: isWrappedFile,
|
|
110
|
+
wrapForDisplay: wrapForDisplay,
|
|
111
|
+
};
|
package/lib/commands.js
ADDED
|
@@ -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
|
+
};
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Shared constants used across the CLI lib.
|
|
2
|
+
//
|
|
3
|
+
// VERSION resolves to the CLI's own package version (cli/package.json),
|
|
4
|
+
// not the server's root package.json.
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
exports.DEFAULT_URL = 'https://smalldocs.org';
|
|
10
|
+
exports.VERSION = require('../package.json').version;
|
|
11
|
+
exports.UPDATE_CACHE = path.join(os.homedir(), '.sdocs', 'update-check.json');
|
|
12
|
+
exports.SETUP_CACHE = path.join(os.homedir(), '.sdocs', 'setup.json');
|
|
13
|
+
exports.ONE_DAY = 86400000;
|
|
14
|
+
exports.AGENT_CHANGES_URL = 'https://smalldocs.org/agent-changes';
|
|
15
|
+
exports.INSTALL_SH_URL = exports.DEFAULT_URL + '/install';
|
|
16
|
+
exports.GITHUB_REPO_URL = 'https://github.com/espressoplease/SDocs';
|
|
17
|
+
|
|
18
|
+
// Printed by `sdoc feedback` with no args. Goal: an agent can read this
|
|
19
|
+
// once and write valid form blocks afterwards. Short, dense, and every
|
|
20
|
+
// supported field type appears in the example.
|
|
21
|
+
exports.FORM_DSL_REFERENCE = `\
|
|
22
|
+
sdoc feedback — interactive form DSL
|
|
23
|
+
====================================
|
|
24
|
+
|
|
25
|
+
Ask the user something structured. Write a fenced \`\`\`form block into a
|
|
26
|
+
markdown file, then run:
|
|
27
|
+
|
|
28
|
+
sdoc feedback file.md # opens the file, exits on first submit
|
|
29
|
+
|
|
30
|
+
That is the simplest, recommended shape: ONE submit button, single
|
|
31
|
+
process invocation. The user clicks, the file gets the answers, the
|
|
32
|
+
process prints one JSON line on stdout, and exits. You just spawn the
|
|
33
|
+
command, wait for it to finish, and read its stdout.
|
|
34
|
+
|
|
35
|
+
You only need the other modes when you genuinely want multiple submits
|
|
36
|
+
in one session:
|
|
37
|
+
|
|
38
|
+
sdoc feedback file.md --keep-open # bridge stays alive across many
|
|
39
|
+
# submits; you tail stdout to
|
|
40
|
+
# react per click
|
|
41
|
+
sdoc feedback file.md --keep-open \\
|
|
42
|
+
--log-file /tmp/sdoc.jsonl # also mirror events to a file
|
|
43
|
+
# (fallback for harnesses that
|
|
44
|
+
# can't tail a background process)
|
|
45
|
+
sdoc feedback file.md --message "Q" # show "Q" above the document
|
|
46
|
+
|
|
47
|
+
Strong default: write forms with ONE button. Reach for --keep-open + a
|
|
48
|
+
multi-button form only when "the user submits several things during one
|
|
49
|
+
session" is the actual goal.
|
|
50
|
+
|
|
51
|
+
A form block has four sections: id, fields, buttons, and (added by the
|
|
52
|
+
bridge on submit) answers + submissions. You author id, fields, buttons.
|
|
53
|
+
|
|
54
|
+
Full example
|
|
55
|
+
------------
|
|
56
|
+
|
|
57
|
+
\`\`\`form
|
|
58
|
+
id: q3-review
|
|
59
|
+
fields:
|
|
60
|
+
- name: ready
|
|
61
|
+
type: radio
|
|
62
|
+
label: "Are you ready to ship?"
|
|
63
|
+
options: [Yes, "Needs more time", Push out]
|
|
64
|
+
required: true
|
|
65
|
+
default: Yes
|
|
66
|
+
help: "All teams have signed off."
|
|
67
|
+
|
|
68
|
+
- name: notes
|
|
69
|
+
type: textarea
|
|
70
|
+
label: "Detailed thoughts"
|
|
71
|
+
rows: 5
|
|
72
|
+
placeholder: "Anything else?"
|
|
73
|
+
default: |
|
|
74
|
+
Pre-filled text the user can edit.
|
|
75
|
+
Spans multiple lines via the | scalar.
|
|
76
|
+
|
|
77
|
+
- name: name_field
|
|
78
|
+
type: text
|
|
79
|
+
label: "Your name"
|
|
80
|
+
default: "Jane" # pre-fills the input; user can edit to "Jane!"
|
|
81
|
+
|
|
82
|
+
- name: tags
|
|
83
|
+
type: checkbox
|
|
84
|
+
label: "Which areas?"
|
|
85
|
+
options: [api, web, docs, infra]
|
|
86
|
+
default: [api, docs]
|
|
87
|
+
|
|
88
|
+
- name: tier
|
|
89
|
+
type: select
|
|
90
|
+
label: "Pricing tier"
|
|
91
|
+
options: [free, pro, team, enterprise]
|
|
92
|
+
default: pro
|
|
93
|
+
|
|
94
|
+
- name: head_count
|
|
95
|
+
type: number
|
|
96
|
+
label: "How many people?"
|
|
97
|
+
min: 1
|
|
98
|
+
max: 500
|
|
99
|
+
default: 5
|
|
100
|
+
|
|
101
|
+
- name: target_date
|
|
102
|
+
type: date
|
|
103
|
+
label: "Target ship date"
|
|
104
|
+
default: "2026-06-01"
|
|
105
|
+
|
|
106
|
+
buttons:
|
|
107
|
+
- name: send_decision
|
|
108
|
+
label: "Send decision"
|
|
109
|
+
scope: [ready] # this button only submits the 'ready' field
|
|
110
|
+
after: ready # render this button inline, right under the
|
|
111
|
+
# 'ready' field, instead of in the footer row
|
|
112
|
+
|
|
113
|
+
- name: send_all
|
|
114
|
+
label: "Submit everything"
|
|
115
|
+
final: true # this button always ends the session
|
|
116
|
+
\`\`\`
|
|
117
|
+
|
|
118
|
+
Field types
|
|
119
|
+
-----------
|
|
120
|
+
|
|
121
|
+
text single-line input. default, placeholder, required, maxlength
|
|
122
|
+
textarea multi-line. default (block-scalar OK), rows, placeholder, required, maxlength
|
|
123
|
+
radio one of N choices. options[] required. default selects one.
|
|
124
|
+
checkbox multi-select. options[] required. default is an array.
|
|
125
|
+
select dropdown. options[] required. default selects one.
|
|
126
|
+
number numeric input. min, max, step. default is a number.
|
|
127
|
+
date date picker (YYYY-MM-DD). min, max. default is the ISO date string.
|
|
128
|
+
|
|
129
|
+
Per-field keys
|
|
130
|
+
--------------
|
|
131
|
+
|
|
132
|
+
name required, [a-z0-9_-]{1,64}, unique per form
|
|
133
|
+
label shown above the control
|
|
134
|
+
help small grey description under the control
|
|
135
|
+
required true/false
|
|
136
|
+
default pre-fill value the user can edit (array for checkbox, number
|
|
137
|
+
for number, ISO date for date, string otherwise)
|
|
138
|
+
options radio / checkbox / select; array of strings
|
|
139
|
+
placeholder text / textarea / number; greyed-out hint that vanishes on type
|
|
140
|
+
rows textarea only
|
|
141
|
+
min/max number, date
|
|
142
|
+
step number only
|
|
143
|
+
|
|
144
|
+
Buttons
|
|
145
|
+
-------
|
|
146
|
+
|
|
147
|
+
name required, unique per form
|
|
148
|
+
label button text
|
|
149
|
+
scope optional list of field names. Defaults to all fields.
|
|
150
|
+
final optional bool. true means this submit ends the session even
|
|
151
|
+
when --keep-open was passed.
|
|
152
|
+
after optional field name. Renders the button inline right under
|
|
153
|
+
that field instead of in the bottom row. Combine with scope
|
|
154
|
+
for a "submit just this section" pattern.
|
|
155
|
+
help optional one-line override for the auto-generated hint
|
|
156
|
+
(see "Button hints" below). Only set this when your own
|
|
157
|
+
one-liner is clearly better than the default.
|
|
158
|
+
|
|
159
|
+
Button hints (automatic, you do not author these)
|
|
160
|
+
-------------------------------------------------
|
|
161
|
+
|
|
162
|
+
Every button gets a small grey line under it explaining what happens
|
|
163
|
+
when the user clicks. You do not write the hint; it is derived from the
|
|
164
|
+
button's shape:
|
|
165
|
+
|
|
166
|
+
final: true -> "Submitting hands off to the agent and ends
|
|
167
|
+
this session."
|
|
168
|
+
scope: [a, b] -> "Sends just these answers (a, b). You can
|
|
169
|
+
keep editing."
|
|
170
|
+
no scope (non-final) -> "Sends all answers. You can keep editing."
|
|
171
|
+
|
|
172
|
+
After a successful submit, a green italic line also appears under that
|
|
173
|
+
same button: "Saved to <filename> at HH:MM:SS". It updates in place on
|
|
174
|
+
each subsequent click of that button. You do not author this either.
|
|
175
|
+
|
|
176
|
+
Set the per-button \`help: "..."\` key only when your own copy is
|
|
177
|
+
genuinely more useful than the default (e.g. when the field semantics
|
|
178
|
+
are non-obvious and the auto hint would mislead).
|
|
179
|
+
|
|
180
|
+
Multi-round flow (with --keep-open)
|
|
181
|
+
-----------------------------------
|
|
182
|
+
|
|
183
|
+
1. Write a file with a form block, run \`sdoc feedback file.md --keep-open\`.
|
|
184
|
+
2. User edits, clicks a non-final submit. Bridge writes the file with
|
|
185
|
+
answers + a submission entry. The bridge stays alive.
|
|
186
|
+
3. Read the file. \`submissions[-1]\` has the user's latest answer.
|
|
187
|
+
4. Rewrite the file with the next question (keep the form id stable to
|
|
188
|
+
preserve answers + history; change fields or labels as needed).
|
|
189
|
+
5. The browser refreshes automatically. The user answers the new form.
|
|
190
|
+
6. Repeat until the user clicks a button with \`final: true\`, or closes
|
|
191
|
+
the tab.
|
|
192
|
+
|
|
193
|
+
How to know a submit happened (events)
|
|
194
|
+
--------------------------------------
|
|
195
|
+
|
|
196
|
+
Every successful submit emits one JSON line to stdout, e.g.
|
|
197
|
+
|
|
198
|
+
{"event":"submit","form_id":"q1","by":"send_decision",
|
|
199
|
+
"at":"2026-05-24T10:01:32.123Z","scope":["ready"],
|
|
200
|
+
"values":{"ready":"Yes"},"final":false}
|
|
201
|
+
|
|
202
|
+
Startup chatter is on stderr, so stdout is a clean event channel.
|
|
203
|
+
|
|
204
|
+
DO NOT fire-and-forget
|
|
205
|
+
----------------------
|
|
206
|
+
|
|
207
|
+
The submit JSON arrives WHEN the user clicks - which could be seconds
|
|
208
|
+
or minutes after you spawn the command. The whole protocol depends on
|
|
209
|
+
you (or your harness) noticing when the process exits.
|
|
210
|
+
|
|
211
|
+
Wrong:
|
|
212
|
+
|
|
213
|
+
bash -c 'sdoc feedback file.md > log.json 2>&1 &'
|
|
214
|
+
^^ ^^
|
|
215
|
+
starts shell backgrounding;
|
|
216
|
+
immediately parent NEVER notices exit
|
|
217
|
+
|
|
218
|
+
That looks like it worked. The user fills out the form, clicks submit,
|
|
219
|
+
the JSON lands in log.json, the process exits 0... and your agent
|
|
220
|
+
never knows. You just sit there.
|
|
221
|
+
|
|
222
|
+
Right (depends on your harness):
|
|
223
|
+
|
|
224
|
+
Claude Code Bash tool with run_in_background: true
|
|
225
|
+
(you get a notification when the process exits)
|
|
226
|
+
Codex Codex's background-task primitive, same shape
|
|
227
|
+
Plain shell Foreground: sdoc feedback file.md, then wait.
|
|
228
|
+
Or: sdoc feedback file.md & wait \$!
|
|
229
|
+
(the wait \$! is what was missing)
|
|
230
|
+
Make / script Run it foreground and capture stdout - no '&'
|
|
231
|
+
|
|
232
|
+
The CLI prints a stderr warning when stdout is not a TTY at startup,
|
|
233
|
+
so you get a hint if you set it up wrong.
|
|
234
|
+
|
|
235
|
+
Two reading patterns:
|
|
236
|
+
|
|
237
|
+
Single-shot sdoc feedback file.md
|
|
238
|
+
User clicks once, bridge writes the file, prints one
|
|
239
|
+
JSON line to stdout, exits 0. Agent runs the command,
|
|
240
|
+
waits for it to finish, reads stdout. No tailing. Works
|
|
241
|
+
in every harness that can run a child process.
|
|
242
|
+
|
|
243
|
+
Multi-click sdoc feedback file.md --keep-open
|
|
244
|
+
Bridge stays alive. Each click prints another JSON
|
|
245
|
+
line. Agent runs the command in the background and
|
|
246
|
+
reads new lines as they arrive (Claude Code, Codex,
|
|
247
|
+
opencode all support this).
|
|
248
|
+
|
|
249
|
+
Log fallback --log-file PATH
|
|
250
|
+
The same JSON lines, also appended to a file. Use this
|
|
251
|
+
when your harness can run a backgrounded process but
|
|
252
|
+
can't stream its stdout (Aider, older Cursor modes).
|
|
253
|
+
Agent reads the file periodically.
|
|
254
|
+
|
|
255
|
+
On submit, the form block grows two new sections:
|
|
256
|
+
|
|
257
|
+
\`\`\`yaml
|
|
258
|
+
answers:
|
|
259
|
+
ready: Yes
|
|
260
|
+
notes: |
|
|
261
|
+
Multi-line answer text the user kept or edited.
|
|
262
|
+
tags: [api, docs]
|
|
263
|
+
head_count: 5
|
|
264
|
+
target_date: "2026-06-01"
|
|
265
|
+
submissions:
|
|
266
|
+
- by: send_decision
|
|
267
|
+
at: "2026-05-23T10:01:32Z"
|
|
268
|
+
scope: [ready]
|
|
269
|
+
values:
|
|
270
|
+
ready: Yes
|
|
271
|
+
\`\`\`
|
|
272
|
+
|
|
273
|
+
Constraints
|
|
274
|
+
-----------
|
|
275
|
+
|
|
276
|
+
- 64KB max per form block source.
|
|
277
|
+
- Field and button names: [a-z0-9_-]{1,64}.
|
|
278
|
+
- Strings containing triple-backticks are rejected on submit.
|
|
279
|
+
- Markdown around the form block is preserved byte-for-byte.
|
|
280
|
+
|
|
281
|
+
End.
|
|
282
|
+
`;
|
|
283
|
+
|