sdocs-dev 1.6.2 → 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 -16
- 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,605 @@
|
|
|
1
|
+
// sdocs-form-block.js — parse / serialise / hash a fenced ```form block.
|
|
2
|
+
//
|
|
3
|
+
// Shared by browser (renderer) and Node (bridge + tests). UMD pattern,
|
|
4
|
+
// same as the other cli/shared modules.
|
|
5
|
+
//
|
|
6
|
+
// A form block carries four top-level sections:
|
|
7
|
+
//
|
|
8
|
+
// - id (string, required, [a-z0-9_-]{1,64})
|
|
9
|
+
// - fields[] (array, agent-owned schema, immutable after author)
|
|
10
|
+
// - buttons[] (array, agent-owned action surface)
|
|
11
|
+
// - answers (map, bridge-owned current values)
|
|
12
|
+
// - submissions (array, append-only history)
|
|
13
|
+
//
|
|
14
|
+
// We only support a strict subset of YAML — enough for the DSL, no more.
|
|
15
|
+
// Anything off-spec is a parse error so the agent gets immediate
|
|
16
|
+
// feedback rather than a silently-mangled document.
|
|
17
|
+
|
|
18
|
+
(function (exports) {
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
// ─── Constants ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
var MAX_BLOCK_BYTES = 64 * 1024; // hard cap per form block
|
|
24
|
+
var NAME_RE = /^[a-z0-9_-]{1,64}$/;
|
|
25
|
+
var ALLOWED_TYPES = { text: 1, textarea: 1, radio: 1,
|
|
26
|
+
checkbox: 1, select: 1, number: 1, date: 1 };
|
|
27
|
+
var FIELD_KEYS = ['name','type','label','help','required',
|
|
28
|
+
'default','placeholder','options','rows',
|
|
29
|
+
'maxlength','min','max','step'];
|
|
30
|
+
var BUTTON_KEYS = ['name','label','scope','final','after','help'];
|
|
31
|
+
|
|
32
|
+
// ─── Scalar parsing (string in, JS in) ────────────────────────
|
|
33
|
+
|
|
34
|
+
function parseScalar(v) {
|
|
35
|
+
if (v == null) return v;
|
|
36
|
+
v = String(v);
|
|
37
|
+
var t = v.trim();
|
|
38
|
+
if (t === '') return '';
|
|
39
|
+
// Quoted
|
|
40
|
+
if (t.charAt(0) === '"' && t.charAt(t.length-1) === '"') {
|
|
41
|
+
return JSON.parse(t);
|
|
42
|
+
}
|
|
43
|
+
if (t.charAt(0) === "'" && t.charAt(t.length-1) === "'") {
|
|
44
|
+
return t.slice(1, -1).replace(/''/g, "'");
|
|
45
|
+
}
|
|
46
|
+
// Booleans
|
|
47
|
+
if (t === 'true') return true;
|
|
48
|
+
if (t === 'false') return false;
|
|
49
|
+
if (t === 'null') return null;
|
|
50
|
+
// Number — only if the whole string parses
|
|
51
|
+
if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
|
|
52
|
+
// Plain scalar
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Tokeniser: convert text → flat list of (indent, content) ──
|
|
57
|
+
|
|
58
|
+
function tokenise(text) {
|
|
59
|
+
// Normalise line endings, strip trailing whitespace per line.
|
|
60
|
+
var raw = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
61
|
+
var out = [];
|
|
62
|
+
for (var i = 0; i < raw.length; i++) {
|
|
63
|
+
var line = raw[i].replace(/\s+$/, '');
|
|
64
|
+
var indent = 0;
|
|
65
|
+
while (indent < line.length && line.charAt(indent) === ' ') indent++;
|
|
66
|
+
out.push({ indent: indent, text: line.slice(indent), raw: line, lineNo: i + 1 });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Block-scalar (`|` style) capture ─────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// `key: |` followed by indented lines collects those lines verbatim.
|
|
74
|
+
// Common indent is stripped. Trailing newline kept (chomp +).
|
|
75
|
+
|
|
76
|
+
function captureBlockScalar(tokens, startIdx, baseIndent) {
|
|
77
|
+
// We treat the next non-empty indented line as defining the block
|
|
78
|
+
// indent. All subsequent lines with at least that indent are part
|
|
79
|
+
// of the scalar. The first less-indented (or fence) line ends it.
|
|
80
|
+
var i = startIdx;
|
|
81
|
+
// Skip leading empty lines (still inside the scalar).
|
|
82
|
+
while (i < tokens.length && tokens[i].text === '' && tokens[i].indent === 0) i++;
|
|
83
|
+
if (i >= tokens.length) return { value: '', nextIdx: i };
|
|
84
|
+
var blockIndent = tokens[i].indent;
|
|
85
|
+
if (blockIndent <= baseIndent) return { value: '', nextIdx: startIdx };
|
|
86
|
+
var lines = [];
|
|
87
|
+
while (i < tokens.length) {
|
|
88
|
+
var t = tokens[i];
|
|
89
|
+
if (t.text === '' && t.indent === 0) { lines.push(''); i++; continue; }
|
|
90
|
+
if (t.indent < blockIndent) break;
|
|
91
|
+
lines.push(' '.repeat(t.indent - blockIndent) + t.text);
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
// Trim trailing empty lines but keep one.
|
|
95
|
+
while (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
96
|
+
return { value: lines.join('\n') + '\n', nextIdx: i };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Mapping parser ───────────────────────────────────────────
|
|
100
|
+
//
|
|
101
|
+
// Recursive on indent. Returns { obj, nextIdx, error? } at the first
|
|
102
|
+
// fence line or first line less-indented than `baseIndent`.
|
|
103
|
+
|
|
104
|
+
function parseMapping(tokens, startIdx, baseIndent) {
|
|
105
|
+
var obj = {};
|
|
106
|
+
var i = startIdx;
|
|
107
|
+
var keyOrder = [];
|
|
108
|
+
while (i < tokens.length) {
|
|
109
|
+
var t = tokens[i];
|
|
110
|
+
if (t.text === '') { i++; continue; }
|
|
111
|
+
if (t.indent < baseIndent) break;
|
|
112
|
+
if (t.indent > baseIndent) {
|
|
113
|
+
return { error: 'unexpected indent on line ' + t.lineNo };
|
|
114
|
+
}
|
|
115
|
+
var m = t.text.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
116
|
+
if (!m) return { error: 'expected `key:` on line ' + t.lineNo + ' got: ' + t.text };
|
|
117
|
+
var key = m[1];
|
|
118
|
+
var rest = m[2];
|
|
119
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
120
|
+
return { error: 'reserved key on line ' + t.lineNo };
|
|
121
|
+
}
|
|
122
|
+
keyOrder.push(key);
|
|
123
|
+
// Inline value
|
|
124
|
+
if (rest !== '') {
|
|
125
|
+
if (rest === '|') {
|
|
126
|
+
var bs = captureBlockScalar(tokens, i + 1, baseIndent);
|
|
127
|
+
obj[key] = bs.value;
|
|
128
|
+
i = bs.nextIdx;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Inline array [a, b, c]
|
|
132
|
+
if (rest.charAt(0) === '[' && rest.charAt(rest.length - 1) === ']') {
|
|
133
|
+
var inner = rest.slice(1, -1);
|
|
134
|
+
if (inner.trim() === '') {
|
|
135
|
+
obj[key] = [];
|
|
136
|
+
} else {
|
|
137
|
+
// Split on commas, but respect quoted segments.
|
|
138
|
+
var items = splitInlineList(inner);
|
|
139
|
+
obj[key] = items.map(parseScalar);
|
|
140
|
+
}
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Inline scalar
|
|
145
|
+
obj[key] = parseScalar(rest);
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Block value follows
|
|
150
|
+
// Look ahead: next non-empty line at deeper indent starting `- ` → array.
|
|
151
|
+
// Or block mapping at deeper indent.
|
|
152
|
+
var lookI = i + 1;
|
|
153
|
+
while (lookI < tokens.length && tokens[lookI].text === '') lookI++;
|
|
154
|
+
if (lookI >= tokens.length || tokens[lookI].indent <= baseIndent) {
|
|
155
|
+
obj[key] = null;
|
|
156
|
+
i = lookI;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
var deeper = tokens[lookI].indent;
|
|
160
|
+
if (tokens[lookI].text.charAt(0) === '-' && (tokens[lookI].text.charAt(1) === ' ' || tokens[lookI].text.length === 1)) {
|
|
161
|
+
// Array
|
|
162
|
+
var ar = parseArray(tokens, lookI, deeper);
|
|
163
|
+
if (ar.error) return { error: ar.error };
|
|
164
|
+
obj[key] = ar.arr;
|
|
165
|
+
i = ar.nextIdx;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Nested map
|
|
169
|
+
var sub = parseMapping(tokens, lookI, deeper);
|
|
170
|
+
if (sub.error) return { error: sub.error };
|
|
171
|
+
obj[key] = sub.obj;
|
|
172
|
+
i = sub.nextIdx;
|
|
173
|
+
}
|
|
174
|
+
return { obj: obj, nextIdx: i };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function splitInlineList(s) {
|
|
178
|
+
var out = [];
|
|
179
|
+
var depth = 0;
|
|
180
|
+
var inQuote = null;
|
|
181
|
+
var buf = '';
|
|
182
|
+
for (var i = 0; i < s.length; i++) {
|
|
183
|
+
var c = s.charAt(i);
|
|
184
|
+
if (inQuote) {
|
|
185
|
+
buf += c;
|
|
186
|
+
if (c === inQuote && s.charAt(i - 1) !== '\\') inQuote = null;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (c === '"' || c === "'") { inQuote = c; buf += c; continue; }
|
|
190
|
+
if (c === '[' || c === '{') { depth++; buf += c; continue; }
|
|
191
|
+
if (c === ']' || c === '}') { depth--; buf += c; continue; }
|
|
192
|
+
if (c === ',' && depth === 0) { out.push(buf); buf = ''; continue; }
|
|
193
|
+
buf += c;
|
|
194
|
+
}
|
|
195
|
+
if (buf.trim() !== '') out.push(buf);
|
|
196
|
+
return out.map(function (x) { return x.trim(); });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseArray(tokens, startIdx, baseIndent) {
|
|
200
|
+
var out = [];
|
|
201
|
+
var i = startIdx;
|
|
202
|
+
while (i < tokens.length) {
|
|
203
|
+
var t = tokens[i];
|
|
204
|
+
if (t.text === '') { i++; continue; }
|
|
205
|
+
if (t.indent < baseIndent) break;
|
|
206
|
+
if (t.indent > baseIndent || t.text.charAt(0) !== '-') {
|
|
207
|
+
return { error: 'malformed array item on line ' + t.lineNo };
|
|
208
|
+
}
|
|
209
|
+
var rest = t.text.slice(1).replace(/^\s+/, '');
|
|
210
|
+
// Item is either a scalar (`- foo`) or a map whose first key is on the same line.
|
|
211
|
+
if (rest === '') {
|
|
212
|
+
// map body follows on next deeper-indented lines
|
|
213
|
+
i++;
|
|
214
|
+
var sub = parseMapping(tokens, i, baseIndent + 2);
|
|
215
|
+
if (sub.error) return { error: sub.error };
|
|
216
|
+
out.push(sub.obj);
|
|
217
|
+
i = sub.nextIdx;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Same-line key:value start
|
|
221
|
+
var km = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
222
|
+
if (km) {
|
|
223
|
+
// Build a map for this item: first key inline, additional keys on
|
|
224
|
+
// subsequent lines indented baseIndent + 2.
|
|
225
|
+
var obj = {};
|
|
226
|
+
var key = km[1];
|
|
227
|
+
var val = km[2];
|
|
228
|
+
var inlineToken = { indent: baseIndent + 2, text: key + ': ' + val, lineNo: t.lineNo };
|
|
229
|
+
var synthetic = [inlineToken];
|
|
230
|
+
// Capture continuation lines (mapping body at baseIndent + 2).
|
|
231
|
+
var j = i + 1;
|
|
232
|
+
while (j < tokens.length) {
|
|
233
|
+
if (tokens[j].text === '') { synthetic.push(tokens[j]); j++; continue; }
|
|
234
|
+
if (tokens[j].indent < baseIndent + 2) break;
|
|
235
|
+
synthetic.push(tokens[j]);
|
|
236
|
+
j++;
|
|
237
|
+
}
|
|
238
|
+
var sub2 = parseMapping(synthetic, 0, baseIndent + 2);
|
|
239
|
+
if (sub2.error) return { error: sub2.error };
|
|
240
|
+
out.push(sub2.obj);
|
|
241
|
+
i = j;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Scalar item
|
|
245
|
+
out.push(parseScalar(rest));
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
return { arr: out, nextIdx: i };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Public parse ─────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
function parseFormBlock(text) {
|
|
254
|
+
if (typeof text !== 'string') {
|
|
255
|
+
return { error: 'form block must be a string' };
|
|
256
|
+
}
|
|
257
|
+
if (text.length > MAX_BLOCK_BYTES) {
|
|
258
|
+
return { error: 'form block exceeds ' + MAX_BLOCK_BYTES + ' bytes' };
|
|
259
|
+
}
|
|
260
|
+
var tokens = tokenise(text);
|
|
261
|
+
var top = parseMapping(tokens, 0, 0);
|
|
262
|
+
if (top.error) return { error: top.error };
|
|
263
|
+
|
|
264
|
+
var raw = top.obj || {};
|
|
265
|
+
var out = {
|
|
266
|
+
id: raw.id,
|
|
267
|
+
fields: Array.isArray(raw.fields) ? raw.fields : [],
|
|
268
|
+
buttons: Array.isArray(raw.buttons) ? raw.buttons : [],
|
|
269
|
+
answers: (raw.answers && typeof raw.answers === 'object' && !Array.isArray(raw.answers)) ? raw.answers : {},
|
|
270
|
+
submissions: Array.isArray(raw.submissions) ? raw.submissions : [],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
var v = validate(out);
|
|
274
|
+
if (v.error) return { error: v.error };
|
|
275
|
+
return { value: out };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validate(block) {
|
|
279
|
+
if (!block.id || typeof block.id !== 'string' || !NAME_RE.test(block.id)) {
|
|
280
|
+
return { error: 'form id must match [a-z0-9_-]{1,64}' };
|
|
281
|
+
}
|
|
282
|
+
if (!block.fields.length) return { error: 'form must have at least one field' };
|
|
283
|
+
var seen = {};
|
|
284
|
+
for (var i = 0; i < block.fields.length; i++) {
|
|
285
|
+
var f = block.fields[i];
|
|
286
|
+
if (!f || typeof f !== 'object') return { error: 'field ' + i + ' is not an object' };
|
|
287
|
+
if (!f.name || !NAME_RE.test(f.name)) return { error: 'field ' + i + ' name must match [a-z0-9_-]{1,64}' };
|
|
288
|
+
if (seen[f.name]) return { error: 'duplicate field name: ' + f.name };
|
|
289
|
+
seen[f.name] = true;
|
|
290
|
+
if (!ALLOWED_TYPES[f.type]) return { error: 'field "' + f.name + '" has unknown type: ' + f.type };
|
|
291
|
+
if ((f.type === 'radio' || f.type === 'checkbox' || f.type === 'select') &&
|
|
292
|
+
(!Array.isArray(f.options) || f.options.length === 0)) {
|
|
293
|
+
return { error: f.type + ' field "' + f.name + '" requires options[]' };
|
|
294
|
+
}
|
|
295
|
+
if (f.type === 'checkbox' && f.default !== undefined && f.default !== null &&
|
|
296
|
+
!Array.isArray(f.default)) {
|
|
297
|
+
return { error: 'checkbox field "' + f.name + '" default must be an array of option strings' };
|
|
298
|
+
}
|
|
299
|
+
if (f.type === 'number' && f.default !== undefined && f.default !== null &&
|
|
300
|
+
typeof f.default !== 'number') {
|
|
301
|
+
return { error: 'number field "' + f.name + '" default must be a number' };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Buttons: at least one required, names unique.
|
|
305
|
+
if (!block.buttons.length) return { error: 'form must have at least one button' };
|
|
306
|
+
var bseen = {};
|
|
307
|
+
for (var j = 0; j < block.buttons.length; j++) {
|
|
308
|
+
var b = block.buttons[j];
|
|
309
|
+
if (!b || typeof b !== 'object') return { error: 'button ' + j + ' is not an object' };
|
|
310
|
+
if (!b.name || !NAME_RE.test(b.name)) return { error: 'button ' + j + ' name invalid' };
|
|
311
|
+
if (bseen[b.name]) return { error: 'duplicate button name: ' + b.name };
|
|
312
|
+
bseen[b.name] = true;
|
|
313
|
+
if (!b.label || typeof b.label !== 'string') return { error: 'button "' + b.name + '" missing label' };
|
|
314
|
+
if (b.scope !== undefined && !Array.isArray(b.scope)) {
|
|
315
|
+
return { error: 'button "' + b.name + '" scope must be an array' };
|
|
316
|
+
}
|
|
317
|
+
if (Array.isArray(b.scope)) {
|
|
318
|
+
for (var k = 0; k < b.scope.length; k++) {
|
|
319
|
+
if (!seen[b.scope[k]]) return { error: 'button "' + b.name + '" scope refers to unknown field: ' + b.scope[k] };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (b.after !== undefined && b.after !== null) {
|
|
323
|
+
if (typeof b.after !== 'string' || !seen[b.after]) {
|
|
324
|
+
return { error: 'button "' + b.name + '" after refers to unknown field: ' + b.after };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { ok: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Serialise ────────────────────────────────────────────────
|
|
332
|
+
//
|
|
333
|
+
// Deterministic output. Strings that contain newlines or fence markers
|
|
334
|
+
// are emitted as block scalars. Strings that contain a triple-backtick
|
|
335
|
+
// are forbidden in user input — we reject at submit time.
|
|
336
|
+
|
|
337
|
+
function serializeFormBlock(block) {
|
|
338
|
+
var lines = [];
|
|
339
|
+
lines.push('id: ' + scalarOut(block.id));
|
|
340
|
+
if (block.fields && block.fields.length) {
|
|
341
|
+
lines.push('fields:');
|
|
342
|
+
block.fields.forEach(function (f) { emitObjectItem(lines, f, FIELD_KEYS, 2); });
|
|
343
|
+
}
|
|
344
|
+
if (block.buttons && block.buttons.length) {
|
|
345
|
+
lines.push('buttons:');
|
|
346
|
+
block.buttons.forEach(function (b) { emitObjectItem(lines, b, BUTTON_KEYS, 2); });
|
|
347
|
+
}
|
|
348
|
+
if (block.answers && Object.keys(block.answers).length) {
|
|
349
|
+
lines.push('answers:');
|
|
350
|
+
var akeys = Object.keys(block.answers);
|
|
351
|
+
akeys.forEach(function (k) {
|
|
352
|
+
emitKeyValue(lines, k, block.answers[k], 2);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (block.submissions && block.submissions.length) {
|
|
356
|
+
lines.push('submissions:');
|
|
357
|
+
block.submissions.forEach(function (s) {
|
|
358
|
+
var keys = ['by', 'at', 'scope', 'values'];
|
|
359
|
+
emitObjectItem(lines, s, keys, 2);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return lines.join('\n');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// indent = column where each item's `-` will be written.
|
|
366
|
+
// Keys after `- ` therefore start at column indent + 2.
|
|
367
|
+
function emitObjectItem(lines, obj, keyOrder, indent) {
|
|
368
|
+
var pad = ' '.repeat(indent);
|
|
369
|
+
var firstWritten = false;
|
|
370
|
+
keyOrder.forEach(function (k) {
|
|
371
|
+
if (!(k in obj)) return;
|
|
372
|
+
var val = obj[k];
|
|
373
|
+
var prefix = firstWritten ? pad + ' ' : pad + '- ';
|
|
374
|
+
firstWritten = true;
|
|
375
|
+
emitKeyValue(lines, k, val, indent + 2, prefix);
|
|
376
|
+
});
|
|
377
|
+
// Trailing unknown keys are dropped on purpose (schema strip).
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// indent = column where the key starts when no prefix is given.
|
|
381
|
+
// prefix (optional) overrides the leading whitespace with something
|
|
382
|
+
// like " - " (array-item marker). Block-scalar continuation lines
|
|
383
|
+
// land at column indent + 2.
|
|
384
|
+
function emitKeyValue(lines, key, val, indent, prefix) {
|
|
385
|
+
var pad = prefix !== undefined ? prefix : ' '.repeat(indent);
|
|
386
|
+
if (val == null) {
|
|
387
|
+
lines.push(pad + key + ': null');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (typeof val === 'string') {
|
|
391
|
+
if (val.indexOf('\n') >= 0) {
|
|
392
|
+
lines.push(pad + key + ': |');
|
|
393
|
+
var ipad = ' '.repeat(indent + 2);
|
|
394
|
+
var parts = val.split('\n');
|
|
395
|
+
if (parts[parts.length - 1] === '') parts.pop();
|
|
396
|
+
parts.forEach(function (p) { lines.push(ipad + p); });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
lines.push(pad + key + ': ' + scalarOut(val));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (typeof val === 'boolean' || typeof val === 'number') {
|
|
403
|
+
lines.push(pad + key + ': ' + String(val));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (Array.isArray(val)) {
|
|
407
|
+
var allSimple = val.every(function (v) {
|
|
408
|
+
return (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') &&
|
|
409
|
+
(typeof v !== 'string' || (v.indexOf(',') < 0 && v.indexOf('\n') < 0));
|
|
410
|
+
});
|
|
411
|
+
if (allSimple && val.length <= 8) {
|
|
412
|
+
lines.push(pad + key + ': [' + val.map(scalarOut).join(', ') + ']');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
lines.push(pad + key + ':');
|
|
416
|
+
var apad = ' '.repeat(indent + 2);
|
|
417
|
+
val.forEach(function (item) {
|
|
418
|
+
if (item != null && typeof item === 'object' && !Array.isArray(item)) {
|
|
419
|
+
var ks = Object.keys(item);
|
|
420
|
+
var first = true;
|
|
421
|
+
ks.forEach(function (kk) {
|
|
422
|
+
var p = first ? apad + '- ' : apad + ' ';
|
|
423
|
+
first = false;
|
|
424
|
+
emitKeyValue(lines, kk, item[kk], indent + 4, p);
|
|
425
|
+
});
|
|
426
|
+
} else {
|
|
427
|
+
lines.push(apad + '- ' + scalarOut(item));
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (typeof val === 'object') {
|
|
433
|
+
lines.push(pad + key + ':');
|
|
434
|
+
Object.keys(val).forEach(function (kk) {
|
|
435
|
+
emitKeyValue(lines, kk, val[kk], indent + 2);
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
lines.push(pad + key + ': ' + scalarOut(val));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function scalarOut(v) {
|
|
443
|
+
if (v === null) return 'null';
|
|
444
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
445
|
+
var s = String(v);
|
|
446
|
+
// Force double-quote any string that could ambiguate or contain a fence.
|
|
447
|
+
if (s === '' || /^(true|false|null|~)$/i.test(s) || /^-?\d/.test(s) ||
|
|
448
|
+
/[:#\[\]\{\},&*!|>'"%@`?-]/.test(s.charAt(0)) || /\s$/.test(s) ||
|
|
449
|
+
s.indexOf('\n') >= 0 || s.indexOf('```') >= 0) {
|
|
450
|
+
return JSON.stringify(s);
|
|
451
|
+
}
|
|
452
|
+
return s;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Token (canonical JSON over fields + buttons) ─────────────
|
|
456
|
+
|
|
457
|
+
function canonicalJson(value) {
|
|
458
|
+
if (value === null) return 'null';
|
|
459
|
+
if (typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
|
|
460
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
461
|
+
if (Array.isArray(value)) {
|
|
462
|
+
return '[' + value.map(canonicalJson).join(',') + ']';
|
|
463
|
+
}
|
|
464
|
+
if (typeof value === 'object') {
|
|
465
|
+
var keys = Object.keys(value).sort();
|
|
466
|
+
return '{' + keys.map(function (k) {
|
|
467
|
+
return JSON.stringify(k) + ':' + canonicalJson(value[k]);
|
|
468
|
+
}).join(',') + '}';
|
|
469
|
+
}
|
|
470
|
+
return 'null';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function canonicalFormSignature(fields, buttons) {
|
|
474
|
+
// Strip schema to allowed keys before hashing so cosmetic unknown
|
|
475
|
+
// keys can't shift the token under our feet.
|
|
476
|
+
var f = (fields || []).map(function (x) { return stripToKeys(x, FIELD_KEYS); });
|
|
477
|
+
var b = (buttons || []).map(function (x) { return stripToKeys(x, BUTTON_KEYS); });
|
|
478
|
+
return canonicalJson({ fields: f, buttons: b });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function stripToKeys(obj, keys) {
|
|
482
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
483
|
+
var out = {};
|
|
484
|
+
keys.forEach(function (k) { if (k in obj) out[k] = obj[k]; });
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Tiny non-cryptographic hash. Adequate for revision tokens — we only
|
|
489
|
+
// need to detect schema changes, not resist preimage attacks. SHA-1
|
|
490
|
+
// would be cleaner but introduces a Web Crypto async surface across
|
|
491
|
+
// browser + node + sync code paths. We pick FNV-1a 32 bit, doubled to
|
|
492
|
+
// 64 bits via a second pass with a different basis, to get a short
|
|
493
|
+
// hex token with low collision risk for the schema shapes we expect.
|
|
494
|
+
function fnv1a64Hex(s) {
|
|
495
|
+
var h1 = 0x811c9dc5 >>> 0;
|
|
496
|
+
var h2 = 0xcbf29ce4 >>> 0;
|
|
497
|
+
for (var i = 0; i < s.length; i++) {
|
|
498
|
+
var c = s.charCodeAt(i);
|
|
499
|
+
h1 = (h1 ^ c) >>> 0;
|
|
500
|
+
h1 = Math.imul(h1, 0x01000193) >>> 0;
|
|
501
|
+
h2 = (h2 ^ (c + 0x100)) >>> 0;
|
|
502
|
+
h2 = Math.imul(h2, 0x01000193) >>> 0;
|
|
503
|
+
}
|
|
504
|
+
var hex1 = ('00000000' + h1.toString(16)).slice(-8);
|
|
505
|
+
var hex2 = ('00000000' + h2.toString(16)).slice(-8);
|
|
506
|
+
return hex1 + hex2;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function formRevisionToken(fields, buttons) {
|
|
510
|
+
return fnv1a64Hex(canonicalFormSignature(fields, buttons));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ─── Locate form blocks in a markdown document ────────────────
|
|
514
|
+
//
|
|
515
|
+
// Returns an array of { id, startByte, endByte, innerText, error? }
|
|
516
|
+
// for every fenced ```form block in the document. Used by the bridge
|
|
517
|
+
// to splice updates back in without touching surrounding bytes.
|
|
518
|
+
|
|
519
|
+
function findFormBlocks(doc) {
|
|
520
|
+
var out = [];
|
|
521
|
+
if (typeof doc !== 'string') return out;
|
|
522
|
+
// Match ```form fences. Opening fence must be on its own line.
|
|
523
|
+
var lines = doc.split('\n');
|
|
524
|
+
// Pre-compute byte offsets of each line start.
|
|
525
|
+
var offsets = [0];
|
|
526
|
+
for (var i = 0; i < lines.length; i++) {
|
|
527
|
+
offsets.push(offsets[i] + lines[i].length + 1); // +1 for \n
|
|
528
|
+
}
|
|
529
|
+
var i2 = 0;
|
|
530
|
+
while (i2 < lines.length) {
|
|
531
|
+
var line = lines[i2];
|
|
532
|
+
if (/^```form\s*$/.test(line)) {
|
|
533
|
+
var startByte = offsets[i2];
|
|
534
|
+
var bodyStart = i2 + 1;
|
|
535
|
+
var j = bodyStart;
|
|
536
|
+
while (j < lines.length && !/^```\s*$/.test(lines[j])) j++;
|
|
537
|
+
var bodyEnd = j;
|
|
538
|
+
var endByte = (j < lines.length) ? offsets[j + 1] : offsets[j];
|
|
539
|
+
var innerText = lines.slice(bodyStart, bodyEnd).join('\n');
|
|
540
|
+
var parsed = parseFormBlock(innerText);
|
|
541
|
+
var entry = {
|
|
542
|
+
startByte: startByte,
|
|
543
|
+
endByte: endByte,
|
|
544
|
+
innerText: innerText,
|
|
545
|
+
lineStart: i2 + 1, // 1-based
|
|
546
|
+
lineEnd: j + 1,
|
|
547
|
+
};
|
|
548
|
+
if (parsed.error) {
|
|
549
|
+
entry.error = parsed.error;
|
|
550
|
+
} else {
|
|
551
|
+
entry.id = parsed.value.id;
|
|
552
|
+
entry.parsed = parsed.value;
|
|
553
|
+
}
|
|
554
|
+
out.push(entry);
|
|
555
|
+
i2 = j + 1;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
i2++;
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ─── Splice a form block back into the document ───────────────
|
|
564
|
+
//
|
|
565
|
+
// Pre-condition: `block` was loaded from `doc`, its `startByte` and
|
|
566
|
+
// `endByte` reference the existing fenced region. We replace the body
|
|
567
|
+
// (between the fences) with a freshly serialised form block.
|
|
568
|
+
//
|
|
569
|
+
// Returns { doc, startByte, endByte } where startByte is unchanged
|
|
570
|
+
// from input and endByte is the new closing-fence-and-newline boundary.
|
|
571
|
+
// The caller is expected to call findFormBlocks() on the returned doc
|
|
572
|
+
// and verify the byte slice [0, startByte] and [endByte..] are
|
|
573
|
+
// identical to the original — that's the boundary-stability check
|
|
574
|
+
// the bridge enforces.
|
|
575
|
+
|
|
576
|
+
function spliceFormBlock(doc, block, newParsed) {
|
|
577
|
+
if (!block || typeof doc !== 'string') {
|
|
578
|
+
return { error: 'spliceFormBlock: bad arguments' };
|
|
579
|
+
}
|
|
580
|
+
if (typeof block.startByte !== 'number' || typeof block.endByte !== 'number') {
|
|
581
|
+
return { error: 'spliceFormBlock: missing offsets' };
|
|
582
|
+
}
|
|
583
|
+
var serialized = serializeFormBlock(newParsed);
|
|
584
|
+
var replacement = '```form\n' + serialized + '\n```\n';
|
|
585
|
+
var newDoc = doc.slice(0, block.startByte) + replacement + doc.slice(block.endByte);
|
|
586
|
+
return {
|
|
587
|
+
doc: newDoc,
|
|
588
|
+
startByte: block.startByte,
|
|
589
|
+
endByte: block.startByte + replacement.length,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ─── Public exports ───────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
exports.MAX_BLOCK_BYTES = MAX_BLOCK_BYTES;
|
|
596
|
+
exports.NAME_RE = NAME_RE;
|
|
597
|
+
exports.ALLOWED_TYPES = ALLOWED_TYPES;
|
|
598
|
+
exports.parseFormBlock = parseFormBlock;
|
|
599
|
+
exports.serializeFormBlock = serializeFormBlock;
|
|
600
|
+
exports.canonicalFormSignature = canonicalFormSignature;
|
|
601
|
+
exports.formRevisionToken = formRevisionToken;
|
|
602
|
+
exports.findFormBlocks = findFormBlocks;
|
|
603
|
+
exports.spliceFormBlock = spliceFormBlock;
|
|
604
|
+
|
|
605
|
+
})(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocFormBlock = {}));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Pure tag handling. Browser and Node both use this (UMD).
|
|
2
|
+
// Two things:
|
|
3
|
+
// - merge tags from multiple sources, deduped, preserving order
|
|
4
|
+
// - parse `+tag` argv tokens into bare tag strings
|
|
5
|
+
|
|
6
|
+
(function (exports) {
|
|
7
|
+
|
|
8
|
+
function mergeTags(...sources) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const source of sources) {
|
|
12
|
+
if (!source) continue;
|
|
13
|
+
for (const raw of source) {
|
|
14
|
+
if (raw == null) continue;
|
|
15
|
+
const tag = String(raw).trim().replace(/^#/, '').toLowerCase();
|
|
16
|
+
if (!tag) continue;
|
|
17
|
+
if (seen.has(tag)) continue;
|
|
18
|
+
seen.add(tag);
|
|
19
|
+
out.push(tag);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Tokens of the form "+word" in argv; returns the bare tag strings.
|
|
26
|
+
// `+` is shell-safe; we don't accept `#` here because shells treat it
|
|
27
|
+
// as a comment marker, so the args would never reach the CLI.
|
|
28
|
+
function parseTagArgs(args) {
|
|
29
|
+
const tags = [];
|
|
30
|
+
for (const a of args || []) {
|
|
31
|
+
if (typeof a === 'string' && /^\+[A-Za-z][\w-]{0,63}$/.test(a)) {
|
|
32
|
+
tags.push(a.slice(1).toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return tags;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
exports.mergeTags = mergeTags;
|
|
39
|
+
exports.parseTagArgs = parseTagArgs;
|
|
40
|
+
|
|
41
|
+
})(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocLibraryTags = {}));
|