@gregiteen/ssss-cli 0.11.0 → 0.11.2
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/CHANGELOG.md +28 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +15 -3
- package/package.json +2 -2
- package/registry/extensions/defaults.json +5 -0
- package/registry/extensions/festech.json +1055 -74
- package/registry/extensions/field-crm.json +1018 -0
- package/scripts/audit-registry-field-usage.mjs +6 -0
- package/scripts/autolink.mjs +8 -13
- package/scripts/check-events-streaming.mjs +46 -1
- package/scripts/check-frontmatter.mjs +117 -0
- package/scripts/cmd-export.mjs +3 -0
- package/scripts/cmd-import.mjs +7 -1
- package/src/bundle.mjs +28 -8
- package/src/engine.mjs +4 -1
- package/src/events.mjs +100 -8
- package/src/frontmatter.mjs +274 -59
- package/src/kernel.mjs +22 -0
- package/src/registry.mjs +48 -6
package/src/frontmatter.mjs
CHANGED
|
@@ -10,8 +10,20 @@
|
|
|
10
10
|
* operation patches without dropping nested frontmatter.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Repair a document a broken writer flattened onto one line with literal `\n`
|
|
15
|
+
* text in place of newlines (`---\ntype: "memory"\n...`). Real documents with
|
|
16
|
+
* any actual line break are returned unchanged.
|
|
17
|
+
*/
|
|
18
|
+
export function repairEscapedNewlines(content) {
|
|
19
|
+
if (typeof content !== 'string' || !content.startsWith('---\\n') || content.trim().includes('\n')) return content;
|
|
20
|
+
const fixed = content.replace(/\\n/g, '\n');
|
|
21
|
+
return fixed.endsWith('\n') ? fixed : `${fixed}\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
/** Split a document into its raw frontmatter block and body. */
|
|
14
25
|
export function splitFrontmatter(content) {
|
|
26
|
+
content = repairEscapedNewlines(content);
|
|
15
27
|
if (typeof content !== 'string' || !content.startsWith('---')) {
|
|
16
28
|
return { fm: '', body: content || '', hasFm: false };
|
|
17
29
|
}
|
|
@@ -25,36 +37,199 @@ export function splitFrontmatter(content) {
|
|
|
25
37
|
return { fm: inner, body: content.slice(sliceEnd), hasFm: true };
|
|
26
38
|
}
|
|
27
39
|
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
function countIndent(line) {
|
|
41
|
+
const m = line.match(/^ */);
|
|
42
|
+
return m ? m[0].length : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Index of the quote that closes the quoted scalar starting at `v[0]`, or -1. */
|
|
46
|
+
function findClosingQuote(v) {
|
|
47
|
+
const q = v[0];
|
|
48
|
+
for (let i = 1; i < v.length; i++) {
|
|
49
|
+
if (q === '"') {
|
|
50
|
+
if (v[i] === '\\') i++;
|
|
51
|
+
else if (v[i] === '"') return i;
|
|
52
|
+
} else if (v[i] === "'") {
|
|
53
|
+
if (v[i + 1] === "'") i++;
|
|
54
|
+
else return i;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const YAML_ESCAPES = {
|
|
61
|
+
0: '\0', a: '\x07', b: '\b', t: '\t', '\t': '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b',
|
|
62
|
+
' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029'
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Decode a YAML double-quoted scalar body (a superset of JSON string escapes). */
|
|
66
|
+
function unescapeDouble(body) {
|
|
67
|
+
return body.replace(/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|([\s\S]))/g, (m, x, u, U, c) => {
|
|
68
|
+
const hex = x ?? u ?? U;
|
|
69
|
+
if (hex) {
|
|
70
|
+
const cp = parseInt(hex, 16);
|
|
71
|
+
return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
|
|
72
|
+
}
|
|
73
|
+
return Object.hasOwn(YAML_ESCAPES, c) ? YAML_ESCAPES[c] : m;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unquote(tok) {
|
|
78
|
+
if (tok[0] === '"') {
|
|
32
79
|
// Double-quoted scalars carry escapes; the serializer writes them with JSON.stringify.
|
|
33
|
-
try { return JSON.parse(
|
|
80
|
+
try { return JSON.parse(tok); } catch { return unescapeDouble(tok.slice(1, -1)); }
|
|
34
81
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
82
|
+
return tok.slice(1, -1).replace(/''/g, "'");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drop a trailing ` # comment` from an unquoted value. */
|
|
86
|
+
function stripComment(v) {
|
|
87
|
+
return v.replace(/(^|\s)#.*$/, '').trimEnd();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const INT_RE = /^[-+]?(?:0|[1-9]\d*)$/;
|
|
91
|
+
const FLOAT_RE = /^[-+]?(?:(?:0|[1-9]\d*)?\.\d+(?:[eE][-+]?\d+)?|(?:0|[1-9]\d*)(?:\.\d*)?[eE][-+]?\d+)$/;
|
|
92
|
+
|
|
93
|
+
function coercePlain(v) {
|
|
94
|
+
if (v === 'true' || v === 'True' || v === 'TRUE') return true;
|
|
95
|
+
if (v === 'false' || v === 'False' || v === 'FALSE') return false;
|
|
96
|
+
if (v === 'null' || v === 'Null' || v === 'NULL' || v === '~') return null;
|
|
39
97
|
// A leading zero (zip 07003, license 00123) marks a code, not a number.
|
|
40
|
-
if (
|
|
41
|
-
if (
|
|
42
|
-
if (v
|
|
43
|
-
|
|
44
|
-
if (!inner) return [];
|
|
45
|
-
return inner.split(',').map((s) => coerce(s));
|
|
46
|
-
}
|
|
98
|
+
if (INT_RE.test(v)) return parseInt(v, 10);
|
|
99
|
+
if (/^0x[0-9a-fA-F]+$/.test(v)) return parseInt(v.slice(2), 16);
|
|
100
|
+
if (/^0o[0-7]+$/.test(v)) return parseInt(v.slice(2), 8);
|
|
101
|
+
if (FLOAT_RE.test(v)) return parseFloat(v);
|
|
47
102
|
return v;
|
|
48
103
|
}
|
|
49
104
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Parse an inline `[...]` / `{...}` value (JSON and YAML flow style). Quotes,
|
|
107
|
+
* nested brackets and commas inside quoted strings are respected. Throws on
|
|
108
|
+
* malformed input so the caller can fall back to the raw text.
|
|
109
|
+
*/
|
|
110
|
+
function parseFlow(src) {
|
|
111
|
+
let pos = 0;
|
|
112
|
+
const ws = () => { while (pos < src.length && /\s/.test(src[pos])) pos++; };
|
|
113
|
+
const quoted = () => {
|
|
114
|
+
const end = findClosingQuote(src.slice(pos));
|
|
115
|
+
if (end < 0) throw new Error('unterminated quote');
|
|
116
|
+
const tok = src.slice(pos, pos + end + 1);
|
|
117
|
+
pos += end + 1;
|
|
118
|
+
return unquote(tok);
|
|
119
|
+
};
|
|
120
|
+
const plain = (inKey) => {
|
|
121
|
+
const start = pos;
|
|
122
|
+
while (pos < src.length) {
|
|
123
|
+
const ch = src[pos];
|
|
124
|
+
if (ch === ',' || ch === ']' || ch === '}') break;
|
|
125
|
+
if (inKey && ch === ':' && (pos + 1 >= src.length || /[\s,\]}]/.test(src[pos + 1]))) break;
|
|
126
|
+
pos++;
|
|
127
|
+
}
|
|
128
|
+
return src.slice(start, pos).trim();
|
|
129
|
+
};
|
|
130
|
+
const node = () => {
|
|
131
|
+
ws();
|
|
132
|
+
const ch = src[pos];
|
|
133
|
+
if (ch === '[') return seq();
|
|
134
|
+
if (ch === '{') return map();
|
|
135
|
+
if (ch === '"' || ch === "'") return quoted();
|
|
136
|
+
const p = plain(false);
|
|
137
|
+
return p === '' ? null : coercePlain(p);
|
|
138
|
+
};
|
|
139
|
+
const seq = () => {
|
|
140
|
+
pos++;
|
|
141
|
+
const out = [];
|
|
142
|
+
for (;;) {
|
|
143
|
+
ws();
|
|
144
|
+
if (pos >= src.length) throw new Error('unterminated sequence');
|
|
145
|
+
if (src[pos] === ']') { pos++; return out; }
|
|
146
|
+
out.push(node());
|
|
147
|
+
ws();
|
|
148
|
+
if (src[pos] === ',') pos++;
|
|
149
|
+
else if (src[pos] !== ']') throw new Error('bad sequence');
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const map = () => {
|
|
153
|
+
pos++;
|
|
154
|
+
const out = {};
|
|
155
|
+
for (;;) {
|
|
156
|
+
ws();
|
|
157
|
+
if (pos >= src.length) throw new Error('unterminated map');
|
|
158
|
+
if (src[pos] === '}') { pos++; return out; }
|
|
159
|
+
const key = src[pos] === '"' || src[pos] === "'" ? quoted() : plain(true);
|
|
160
|
+
ws();
|
|
161
|
+
if (src[pos] === ':') { pos++; out[key] = node(); } else out[key] = null;
|
|
162
|
+
ws();
|
|
163
|
+
if (src[pos] === ',') pos++;
|
|
164
|
+
else if (src[pos] !== '}') throw new Error('bad map');
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const value = node();
|
|
168
|
+
ws();
|
|
169
|
+
if (pos < src.length && !src.slice(pos).startsWith('#')) throw new Error('trailing text');
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Set by parseFrontmatter(fm, { strict: true }): malformed input throws instead of
|
|
174
|
+
// being skipped or kept as raw text.
|
|
175
|
+
let strictMode = false;
|
|
176
|
+
|
|
177
|
+
function coerce(raw) {
|
|
178
|
+
const v = raw.trim();
|
|
179
|
+
if (v === '') return '';
|
|
180
|
+
if (v[0] === '"' || v[0] === "'") {
|
|
181
|
+
const end = findClosingQuote(v);
|
|
182
|
+
if (end > 0 && /^\s*(#.*)?$/.test(v.slice(end + 1))) return unquote(v.slice(0, end + 1));
|
|
183
|
+
if (strictMode) throw new SyntaxError(`Malformed quoted value: ${v.slice(0, 60)}`);
|
|
184
|
+
return v;
|
|
185
|
+
}
|
|
186
|
+
if (v[0] === '[' || v[0] === '{') {
|
|
187
|
+
try { return parseFlow(v); } catch (err) {
|
|
188
|
+
if (strictMode) throw new SyntaxError(`Malformed inline value: ${v.slice(0, 60)}`);
|
|
189
|
+
return v;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return coercePlain(stripComment(v));
|
|
53
193
|
}
|
|
54
194
|
|
|
195
|
+
// A key is a bare word (no whitespace, `:` or `#`; inner single spaces allowed)
|
|
196
|
+
// or a quoted string, followed by `:` and then whitespace or end of line. The
|
|
197
|
+
// whitespace requirement keeps tags like `context:universal` and URLs as values.
|
|
198
|
+
const KEY_RE =
|
|
199
|
+
/^(?:([A-Za-z0-9_][^\s:#]*(?: [^\s:#]+)*)|("(?:[^"\\]|\\.)*")|('(?:[^']|'')*')):(?:[ \t]+(.*))?$/;
|
|
200
|
+
|
|
55
201
|
function splitKeyValue(text) {
|
|
56
|
-
const m = text.
|
|
57
|
-
|
|
202
|
+
const m = text.trimEnd().match(KEY_RE);
|
|
203
|
+
if (!m) return null;
|
|
204
|
+
const key = m[1] ?? unquote(m[2] ?? m[3]);
|
|
205
|
+
const rest = (m[4] ?? '').trim();
|
|
206
|
+
return { key, rest: rest.startsWith('#') ? '' : rest };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read a scalar or flow value that starts on `lines[i]` (already split into
|
|
211
|
+
* `rest`) and may wrap onto deeper-indented continuation lines. Plain and
|
|
212
|
+
* quoted lines fold with a space; a blank line is one newline.
|
|
213
|
+
*/
|
|
214
|
+
function readScalar(lines, i, keyIndent, rest) {
|
|
215
|
+
const inline = /^["'[{]/.test(rest);
|
|
216
|
+
let text = rest;
|
|
217
|
+
let j = i + 1;
|
|
218
|
+
let blanks = 0;
|
|
219
|
+
let last = i;
|
|
220
|
+
while (j < lines.length) {
|
|
221
|
+
const line = lines[j];
|
|
222
|
+
if (!line.trim()) { blanks++; j++; continue; }
|
|
223
|
+
if (countIndent(line) <= keyIndent) break;
|
|
224
|
+
if (!inline && line.trim().startsWith('#')) break;
|
|
225
|
+
if (strictMode && !inline && splitKeyValue(line.trim())) throw new SyntaxError(`Unexpected key in a wrapped value: ${line.trim().slice(0, 60)}`);
|
|
226
|
+
const piece = inline ? line.trim() : stripComment(line.trim());
|
|
227
|
+
text += blanks ? '\n'.repeat(blanks) + piece : ` ${piece}`;
|
|
228
|
+
blanks = 0;
|
|
229
|
+
last = j;
|
|
230
|
+
j++;
|
|
231
|
+
}
|
|
232
|
+
return { value: coerce(text), next: last + 1 };
|
|
58
233
|
}
|
|
59
234
|
|
|
60
235
|
function nextSignificant(lines, start) {
|
|
@@ -64,7 +239,7 @@ function nextSignificant(lines, start) {
|
|
|
64
239
|
return -1;
|
|
65
240
|
}
|
|
66
241
|
|
|
67
|
-
const BLOCK_SCALAR_RE = /^([|>])([+-]?)
|
|
242
|
+
const BLOCK_SCALAR_RE = /^([|>])([+-]?)(?:\s+#.*)?$/;
|
|
68
243
|
|
|
69
244
|
/** Read a `|` (literal) or `>` (folded) block scalar with clip/strip/keep chomping. */
|
|
70
245
|
function parseBlockScalar(lines, start, parentIndent, style, chomp) {
|
|
@@ -84,23 +259,26 @@ function parseBlockScalar(lines, start, parentIndent, style, chomp) {
|
|
|
84
259
|
while (end > 0 && raw[end - 1] === '') end--;
|
|
85
260
|
const content = raw.slice(0, end);
|
|
86
261
|
let text = content[0] ?? '';
|
|
262
|
+
let prevText = 0; // index of the last non-blank line already emitted
|
|
87
263
|
for (let k = 1; k < content.length; k++) {
|
|
88
264
|
const line = content[k];
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
else if (
|
|
95
|
-
else
|
|
96
|
-
|
|
265
|
+
if (line === '') continue;
|
|
266
|
+
const blanks = k - prevText - 1;
|
|
267
|
+
if (style === '|') text += `${'\n'.repeat(blanks + 1)}${line}`;
|
|
268
|
+
// Folding: a break between two ordinary lines becomes a space, and blank
|
|
269
|
+
// lines each add one newline; a more-indented line on either side keeps its break.
|
|
270
|
+
else if (/^\s/.test(line) || /^\s/.test(content[prevText])) text += `${'\n'.repeat(blanks + 1)}${line}`;
|
|
271
|
+
else text += blanks ? `${'\n'.repeat(blanks)}${line}` : ` ${line}`;
|
|
272
|
+
prevText = k;
|
|
97
273
|
}
|
|
98
|
-
if (!content.length) text = '';
|
|
274
|
+
if (!content.length) text = chomp === '+' ? '\n'.repeat(raw.length) : '';
|
|
99
275
|
else if (chomp === '+') text += '\n'.repeat(1 + raw.length - end);
|
|
100
276
|
else if (chomp !== '-') text += '\n';
|
|
101
277
|
return { value: text, next: i };
|
|
102
278
|
}
|
|
103
279
|
|
|
280
|
+
const isDash = (text) => text === '-' || text.startsWith('- ');
|
|
281
|
+
|
|
104
282
|
function parseMap(lines, start, indent) {
|
|
105
283
|
const data = {};
|
|
106
284
|
let i = start;
|
|
@@ -109,9 +287,17 @@ function parseMap(lines, start, indent) {
|
|
|
109
287
|
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
|
110
288
|
const currentIndent = countIndent(line);
|
|
111
289
|
if (currentIndent < indent) break;
|
|
112
|
-
if (currentIndent > indent) {
|
|
290
|
+
if (currentIndent > indent) {
|
|
291
|
+
if (strictMode) throw new SyntaxError(`Unexpected indentation: ${line.trim().slice(0, 60)}`);
|
|
292
|
+
i++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
113
295
|
const kv = splitKeyValue(line.slice(indent));
|
|
114
|
-
if (!kv) {
|
|
296
|
+
if (!kv) {
|
|
297
|
+
if (strictMode) throw new SyntaxError(`Not a "key: value" line: ${line.trim().slice(0, 60)}`);
|
|
298
|
+
i++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
115
301
|
const block = kv.rest.match(BLOCK_SCALAR_RE);
|
|
116
302
|
if (block) {
|
|
117
303
|
const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
|
|
@@ -120,19 +306,22 @@ function parseMap(lines, start, indent) {
|
|
|
120
306
|
continue;
|
|
121
307
|
}
|
|
122
308
|
if (kv.rest !== '') {
|
|
123
|
-
|
|
124
|
-
|
|
309
|
+
const scalar = readScalar(lines, i, indent, kv.rest);
|
|
310
|
+
data[kv.key] = scalar.value;
|
|
311
|
+
i = scalar.next;
|
|
125
312
|
continue;
|
|
126
313
|
}
|
|
127
314
|
|
|
128
315
|
const next = nextSignificant(lines, i + 1);
|
|
129
|
-
|
|
316
|
+
// YAML allows a block list at the same indent as its key.
|
|
317
|
+
const sameIndentList = next >= 0 && countIndent(lines[next]) === indent && isDash(lines[next].slice(indent));
|
|
318
|
+
if (next < 0 || (countIndent(lines[next]) <= indent && !sameIndentList)) {
|
|
130
319
|
data[kv.key] = {};
|
|
131
320
|
i++;
|
|
132
321
|
continue;
|
|
133
322
|
}
|
|
134
323
|
const childIndent = countIndent(lines[next]);
|
|
135
|
-
if (lines[next].slice(childIndent)
|
|
324
|
+
if (isDash(lines[next].slice(childIndent))) {
|
|
136
325
|
const parsed = parseList(lines, next, childIndent);
|
|
137
326
|
data[kv.key] = parsed.value;
|
|
138
327
|
i = parsed.next;
|
|
@@ -154,21 +343,25 @@ function parseList(lines, start, indent) {
|
|
|
154
343
|
const currentIndent = countIndent(line);
|
|
155
344
|
if (currentIndent < indent) break;
|
|
156
345
|
if (currentIndent > indent) { i++; continue; }
|
|
157
|
-
const item = line.slice(indent).match(
|
|
346
|
+
const item = line.slice(indent).match(/^-(?:(\s+)(.*))?$/);
|
|
158
347
|
if (!item) break;
|
|
159
|
-
const raw = item[
|
|
348
|
+
const raw = (item[2] ?? '').trim();
|
|
349
|
+
const keyIndent = indent + 1 + (item[1] ?? '').length;
|
|
160
350
|
const kv = splitKeyValue(raw);
|
|
161
351
|
if (kv) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
352
|
+
// Re-read the item as a map whose first key sits where the dash was, so
|
|
353
|
+
// nested children, block scalars and wrapped values of every key parse alike.
|
|
354
|
+
lines[i] = ' '.repeat(keyIndent) + raw;
|
|
355
|
+
const parsed = parseMap(lines, i, keyIndent);
|
|
356
|
+
items.push(parsed.value);
|
|
357
|
+
i = parsed.next;
|
|
358
|
+
} else if (isDash(raw)) {
|
|
359
|
+
// Nested sequence (`- - a`): re-read the inner dash as its own list item.
|
|
360
|
+
lines[i] = ' '.repeat(keyIndent) + raw;
|
|
361
|
+
const parsed = parseList(lines, i, keyIndent);
|
|
362
|
+
items.push(parsed.value);
|
|
363
|
+
i = parsed.next;
|
|
364
|
+
} else if (raw === '' || raw.startsWith('#')) {
|
|
172
365
|
const next = nextSignificant(lines, i + 1);
|
|
173
366
|
if (next >= 0 && countIndent(lines[next]) > indent) {
|
|
174
367
|
const parsed = parseMap(lines, next, countIndent(lines[next]));
|
|
@@ -178,21 +371,36 @@ function parseList(lines, start, indent) {
|
|
|
178
371
|
items.push('');
|
|
179
372
|
i++;
|
|
180
373
|
}
|
|
374
|
+
} else if (BLOCK_SCALAR_RE.test(raw)) {
|
|
375
|
+
const block = raw.match(BLOCK_SCALAR_RE);
|
|
376
|
+
const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
|
|
377
|
+
items.push(parsed.value);
|
|
378
|
+
i = parsed.next;
|
|
181
379
|
} else {
|
|
182
|
-
|
|
183
|
-
|
|
380
|
+
const scalar = readScalar(lines, i, indent, raw);
|
|
381
|
+
items.push(scalar.value);
|
|
382
|
+
i = scalar.next;
|
|
184
383
|
}
|
|
185
384
|
}
|
|
186
385
|
return { value: items, next: i };
|
|
187
386
|
}
|
|
188
387
|
|
|
189
388
|
/**
|
|
190
|
-
* Parse a frontmatter block (the inner YAML, no fences) into an object.
|
|
389
|
+
* Parse a frontmatter block (the inner YAML, no fences) into an object. The
|
|
390
|
+
* default is lenient: unreadable lines are skipped. `{ strict: true }` throws a
|
|
391
|
+
* SyntaxError on lines that are not `key: value`, orphaned indentation, and
|
|
392
|
+
* malformed quoted or inline values, so callers can report unreadable files.
|
|
191
393
|
*/
|
|
192
|
-
export function parseFrontmatter(fm) {
|
|
394
|
+
export function parseFrontmatter(fm, { strict = false } = {}) {
|
|
193
395
|
if (!fm) return {};
|
|
194
|
-
|
|
195
|
-
|
|
396
|
+
const previous = strictMode;
|
|
397
|
+
strictMode = strict;
|
|
398
|
+
try {
|
|
399
|
+
// Drop the newline before the closing fence so it is not read as a blank line.
|
|
400
|
+
return parseMap(fm.replace(/\n$/, '').split('\n'), 0, 0).value;
|
|
401
|
+
} finally {
|
|
402
|
+
strictMode = previous;
|
|
403
|
+
}
|
|
196
404
|
}
|
|
197
405
|
|
|
198
406
|
/** Parse a full document (with fences) into { data, body }. */
|
|
@@ -208,15 +416,22 @@ function serializeValue(v) {
|
|
|
208
416
|
if (v === null) return 'null';
|
|
209
417
|
if (typeof v === 'boolean' || typeof v === 'number') return String(v);
|
|
210
418
|
if (Array.isArray(v)) return `[${v.map((x) => serializeValue(x)).join(', ')}]`;
|
|
419
|
+
// Nested objects inside an inline array go out as JSON, which the flow parser reads back.
|
|
420
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
211
421
|
const s = String(v);
|
|
212
|
-
if (s === '' || /[
|
|
422
|
+
if (s === '' || /[:#,\[\]{}"'\\\n\r\t|>]/.test(s) || /^\s|\s$/.test(s) || /^[-?!&*%@`]/.test(s)) return JSON.stringify(s);
|
|
213
423
|
// A string that would read back as a number, boolean or null stays quoted,
|
|
214
424
|
// so "07003" and "true" survive a round trip (here and in YAML 1.1 readers).
|
|
215
425
|
if (LOOKS_TYPED.test(s)) return JSON.stringify(s);
|
|
216
426
|
return s;
|
|
217
427
|
}
|
|
218
428
|
|
|
219
|
-
function
|
|
429
|
+
function serializeKey(key) {
|
|
430
|
+
return /^[A-Za-z0-9_][A-Za-z0-9_.\-/]*$/.test(key) ? key : JSON.stringify(key);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function serializeEntry(lines, rawKey, value, indent = 0) {
|
|
434
|
+
const key = serializeKey(rawKey);
|
|
220
435
|
const pad = ' '.repeat(indent);
|
|
221
436
|
if (Array.isArray(value)) {
|
|
222
437
|
if (!value.length) {
|
|
@@ -233,10 +448,10 @@ function serializeEntry(lines, key, value, indent = 0) {
|
|
|
233
448
|
}
|
|
234
449
|
const [first, ...rest] = entries;
|
|
235
450
|
if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
|
|
236
|
-
lines.push(`${pad} - ${first[0]}:`);
|
|
451
|
+
lines.push(`${pad} - ${serializeKey(first[0])}:`);
|
|
237
452
|
serializeObject(lines, first[1], indent + 6);
|
|
238
453
|
} else {
|
|
239
|
-
lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
|
|
454
|
+
lines.push(`${pad} - ${serializeKey(first[0])}: ${serializeValue(first[1])}`);
|
|
240
455
|
}
|
|
241
456
|
for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
|
|
242
457
|
} else {
|
package/src/kernel.mjs
CHANGED
|
@@ -184,6 +184,28 @@ export function createKernel(options = {}) {
|
|
|
184
184
|
if (currentData?.type && currentData.type !== nextData.type) {
|
|
185
185
|
return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
186
186
|
}
|
|
187
|
+
// Replaying an identical full operation is a no-op, not a replace. This must
|
|
188
|
+
// be checked before the append-only guard so that re-importing a bundle that
|
|
189
|
+
// is already present reports `unchanged` instead of failing (spec §17.1).
|
|
190
|
+
if (current && current.bytes.toString('utf8') === nextContent) {
|
|
191
|
+
const noopAuth = await authorize({
|
|
192
|
+
principal, workspaceId: envelope.workspace_id, definition,
|
|
193
|
+
action: envelope.type === 'operation' ? 'replace' : actionFor(envelope, true, isAppendType(definition)),
|
|
194
|
+
context,
|
|
195
|
+
});
|
|
196
|
+
if (!noopAuth.allowed) {
|
|
197
|
+
const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
|
|
198
|
+
return failure(code, envelope, operationId, [`Access denied: ${noopAuth.reason}`], [{ field: 'principal', issue: noopAuth.reason }]);
|
|
199
|
+
}
|
|
200
|
+
const response = {
|
|
201
|
+
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
202
|
+
committed_at: current.version?.committed_at || null, dry_run: false,
|
|
203
|
+
unchanged: true,
|
|
204
|
+
validation: { valid: true, type: definition.qualified_type, errors: [], warnings: [] },
|
|
205
|
+
};
|
|
206
|
+
try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); } catch {}
|
|
207
|
+
return response;
|
|
208
|
+
}
|
|
187
209
|
// A full replace would rewrite recorded history (spec §5.3).
|
|
188
210
|
if (current && isAppendType(definition)) {
|
|
189
211
|
return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be replaced.`], [{ field: 'content', issue: 'Append with a patch carrying __body__.' }]);
|
package/src/registry.mjs
CHANGED
|
@@ -297,26 +297,68 @@ export function resolvePrimitiveDefinition(registrySet, type) {
|
|
|
297
297
|
return local ? registrySet.types.get(local) || null : null;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Names of the extensions loaded when a caller does not scope the load.
|
|
302
|
+
*
|
|
303
|
+
* A registry directory accumulates one extension per product. Two unrelated
|
|
304
|
+
* products may each legitimately define a shared primitive name (e.g. both a
|
|
305
|
+
* CRM and a ticketing product needing `integration_connection`), and §5.6 makes
|
|
306
|
+
* that a load-time collision. `extensions/defaults.json` names the set that is
|
|
307
|
+
* safe to compose together; every other extension MUST be selected explicitly by
|
|
308
|
+
* the caller that owns it. Without the manifest, every extension is loaded and
|
|
309
|
+
* any cross-product collision stays a hard error rather than a silent preference.
|
|
310
|
+
*/
|
|
311
|
+
function loadExtensionDefaults(registryDir) {
|
|
312
|
+
const manifest = path.join(registryDir, 'extensions', 'defaults.json');
|
|
313
|
+
if (!fs.existsSync(manifest)) return null;
|
|
314
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
315
|
+
const names = parsed?.default_extensions;
|
|
316
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
|
|
317
|
+
throw new Error('extensions/defaults.json: default_extensions must be an array of registry names.');
|
|
318
|
+
}
|
|
319
|
+
return names;
|
|
320
|
+
}
|
|
321
|
+
|
|
300
322
|
/**
|
|
301
323
|
* Load the core registry and merge extension registries.
|
|
324
|
+
*
|
|
325
|
+
* `only` scopes the load to a set of registry names (e.g. `['dabber']`). A
|
|
326
|
+
* registry directory may hold extensions for unrelated products that legitimately
|
|
327
|
+
* declare the same shared primitive name; loading them together is a load-time
|
|
328
|
+
* collision (§5.6), so a caller that knows which extension a vault belongs to
|
|
329
|
+
* MUST be able to load just that one. When `only` is omitted, the set named by
|
|
330
|
+
* `extensions/defaults.json` is loaded; failing that, every extension is loaded
|
|
331
|
+
* and any duplicate name remains a hard error rather than a silent preference.
|
|
332
|
+
*
|
|
302
333
|
* @param {string} [registryDir] - directory containing core.json + extensions/
|
|
334
|
+
* @param {{ only?: string[]|Set<string> }} [options]
|
|
303
335
|
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
|
|
304
336
|
*/
|
|
305
|
-
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
337
|
+
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR, options = {}) {
|
|
306
338
|
const corePath = path.join(registryDir, 'core.json');
|
|
307
339
|
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
340
|
+
const requested = options.only ? new Set(options.only) : null;
|
|
341
|
+
const defaults = requested ? null : loadExtensionDefaults(registryDir);
|
|
342
|
+
const only = requested || (defaults ? new Set(defaults) : null);
|
|
308
343
|
const extensionInputs = [];
|
|
309
344
|
const extDir = path.join(registryDir, 'extensions');
|
|
310
345
|
if (fs.existsSync(extDir)) {
|
|
311
|
-
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
|
|
346
|
+
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json') && f !== 'defaults.json').sort()) {
|
|
312
347
|
const extPath = path.join(extDir, file);
|
|
313
348
|
if (fs.lstatSync(extPath).isSymbolicLink()) {
|
|
314
349
|
throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
|
|
315
350
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
351
|
+
const registry = JSON.parse(fs.readFileSync(extPath, 'utf8'));
|
|
352
|
+
if (only && !only.has(registry.registry) && !only.has(file.replace(/\.json$/, ''))) continue;
|
|
353
|
+
extensionInputs.push({ source: `extensions/${file}`, registry });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (only) {
|
|
357
|
+
const loaded = new Set(extensionInputs.map((e) => e.registry.registry));
|
|
358
|
+
for (const name of only) {
|
|
359
|
+
if (!loaded.has(name) && !extensionInputs.some((e) => e.source === `extensions/${name}.json`)) {
|
|
360
|
+
throw new Error(`registry '${name}' is not present in ${path.join(registryDir, 'extensions')}.`);
|
|
361
|
+
}
|
|
320
362
|
}
|
|
321
363
|
}
|
|
322
364
|
return composeRegistries({ core, extensions: extensionInputs });
|