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.
@@ -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 = {}));