@notrealstudio/nr-md 0.1.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/dist/schema.js ADDED
@@ -0,0 +1,853 @@
1
+ // Schema layer — object ⇄ Document via JSON Schema + x-storage (serialize-spec §3).
2
+ //
3
+ // The schema is consumed as PLAIN JSON (no TypeBox, no codegen in core, §3.1).
4
+ // Three portable operations:
5
+ // - serializeWithSchema: object + schema → Document (x-storage layout) → text
6
+ // - parseWithSchema: text → Document → object (x-storage + schema coercion)
7
+ // - validate: delegate to Ajv2020 (optional peer) or an injected validator
8
+ //
9
+ // Field order on serialize = order of schema `properties` (§3.3, deterministic).
10
+ import { parse } from './parser.js';
11
+ import { serialize, serializeTable, parseTableRows } from './serialize.js';
12
+ import { coerceTyped } from './typed-header.js';
13
+ import { coerce } from './coerce.js';
14
+ import { unescape } from './value.js';
15
+ /**
16
+ * Key of the catch-all bag holding fields NOT described by the schema.
17
+ *
18
+ * Enabled per object schema with `x-mdd: { unknown: 'block' }`; `parseWithSchema`
19
+ * collects every unclaimed attribute/block here and `serializeWithSchema` emits
20
+ * the bag back — round-trip of foreign extensions is lossless.
21
+ */
22
+ export const UNKNOWN_KEY = '$unknown';
23
+ /**
24
+ * Block name of the overflow bag: unknown keys that are NOT valid mdd names
25
+ * (spaces, leading digits…) cannot be an attribute or a block header, so they
26
+ * travel together in one fenced-JSON block instead of being dropped.
27
+ */
28
+ export const UNKNOWN_OVERFLOW_BLOCK = '_unknown';
29
+ function ctxOf(opts) {
30
+ // eslint-disable-next-line no-console
31
+ return { warn: opts.warn ?? ((msg) => console.warn(msg)) };
32
+ }
33
+ // ---------- Schema access helpers ----------
34
+ function props(schema) {
35
+ const p = schema.properties;
36
+ return p && typeof p === 'object' ? p : {};
37
+ }
38
+ function xmdd(schema) {
39
+ const m = schema['x-mdd'];
40
+ return m && typeof m === 'object' ? m : {};
41
+ }
42
+ function storageOf(prop) {
43
+ const s = prop['x-storage'];
44
+ return s === 'body' || s === 'name' || s === 'id' || s === 'block' ? s : 'attr';
45
+ }
46
+ /**
47
+ * Block name of a field (§3.9): `x-mdd.block` overrides the property key, so the
48
+ * wire name and the document name can differ (`data` → `# @char`, `entries` →
49
+ * `### @entry`). Default — the key as-is.
50
+ */
51
+ function blockNameOf(prop, key) {
52
+ const b = xmdd(prop).block;
53
+ return typeof b === 'string' && b.length > 0 ? b : key;
54
+ }
55
+ /** Opaque JSON representation (`x-mdd: { as: 'json' }`, §3.9)? */
56
+ function asJson(prop) {
57
+ return xmdd(prop).as === 'json';
58
+ }
59
+ /** Does the items schema describe a scalar (a `blocks` element = block with a string body, §3.5)? */
60
+ function isScalarSchema(schema) {
61
+ const t = schema.type;
62
+ return t === 'string' || t === 'number' || t === 'integer' || t === 'boolean';
63
+ }
64
+ function itemsOf(prop) {
65
+ const items = prop.items;
66
+ return items && typeof items === 'object' ? items : {};
67
+ }
68
+ /** Does the items schema describe an object (→ tbl by default, §3.2)? */
69
+ function itemsAreObjects(prop) {
70
+ const items = itemsOf(prop);
71
+ if (items.type === 'object')
72
+ return true;
73
+ const ip = items.properties;
74
+ return Boolean(ip && typeof ip === 'object' && Object.keys(ip).length > 0);
75
+ }
76
+ /**
77
+ * List layout of an array field. Explicit `x-mdd.list` always wins; otherwise an
78
+ * array of objects → `tbl` and an array of scalars → `flow` (§3.2 defaults —
79
+ * `blocks` is opt-in only, existing schemas keep their layout).
80
+ */
81
+ function listModeOf(prop, value) {
82
+ const explicit = xmdd(prop).list;
83
+ if (explicit === 'flow' || explicit === 'tbl' || explicit === 'lines' || explicit === 'blocks') {
84
+ return explicit;
85
+ }
86
+ if (itemsAreObjects(prop))
87
+ return 'tbl';
88
+ if (Array.isArray(value) && value.some((v) => v !== null && typeof v === 'object'))
89
+ return 'tbl';
90
+ return 'flow';
91
+ }
92
+ /**
93
+ * Map field (§3): `type: object` + `patternProperties` (or a schema-valued
94
+ * `additionalProperties`) and NO `properties` — a `Record<string, V>` laid out as a
95
+ * block of per-key sub-blocks.
96
+ */
97
+ function isMapSchema(prop) {
98
+ if (prop.type !== 'object')
99
+ return false;
100
+ const p = prop.properties;
101
+ if (p && typeof p === 'object' && Object.keys(p).length > 0)
102
+ return false;
103
+ const pp = prop.patternProperties;
104
+ if (pp && typeof pp === 'object' && Object.keys(pp).length > 0)
105
+ return true;
106
+ const ap = prop.additionalProperties;
107
+ return Boolean(ap && typeof ap === 'object');
108
+ }
109
+ /** Value schema of a map field — first `patternProperties` entry, else `additionalProperties`. */
110
+ function mapValueSchema(prop) {
111
+ const pp = prop.patternProperties;
112
+ if (pp && typeof pp === 'object') {
113
+ const vals = Object.values(pp);
114
+ if (vals.length > 0 && vals[0] && typeof vals[0] === 'object')
115
+ return vals[0];
116
+ }
117
+ const ap = prop.additionalProperties;
118
+ if (ap && typeof ap === 'object')
119
+ return ap;
120
+ return { type: 'string' };
121
+ }
122
+ function isObjectSchema(prop, value) {
123
+ if (prop.type === 'object')
124
+ return true;
125
+ return prop.type === undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
126
+ }
127
+ function hasNameStorage(schema) {
128
+ for (const prop of Object.values(props(schema))) {
129
+ if (prop && typeof prop === 'object' && prop['x-storage'] === 'name')
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+ function hasIdStorage(schema) {
135
+ for (const prop of Object.values(props(schema))) {
136
+ if (prop && typeof prop === 'object' && prop['x-storage'] === 'id')
137
+ return true;
138
+ }
139
+ return false;
140
+ }
141
+ /** Catch-all enabled for this object schema (`x-mdd: { unknown: 'block' | 'inline' }`)? */
142
+ function unknownEnabled(schema) {
143
+ const u = xmdd(schema).unknown;
144
+ return u === 'block' || u === 'inline';
145
+ }
146
+ /**
147
+ * `unknown: 'inline'` (§3.6) — unknown fields come back as the object's OWN keys
148
+ * rather than in an `$unknown` bag. Serialization is identical either way (bare
149
+ * keys outside the schema are always picked up); the modes differ only in the
150
+ * shape of the parse result. `inline` round-trips shape 1:1 — which is what you
151
+ * need when the canonical form is somebody else's JSON.
152
+ */
153
+ function unknownInline(schema) {
154
+ return xmdd(schema).unknown === 'inline';
155
+ }
156
+ /**
157
+ * `envelope: true` (§3.3) — the root object has no header of its own (the envelope
158
+ * is `@spec`, `@spec_version` plus one data block), so its child blocks sit at the
159
+ * top level (h1) rather than h2. Applies only to a flat root layout.
160
+ */
161
+ function envelopeEnabled(schema) {
162
+ return xmdd(schema).envelope === true && !hasNameStorage(schema) && !hasIdStorage(schema);
163
+ }
164
+ // ---------- mdd name validity (mirrors parser.ts §2.1/§2.2) ----------
165
+ function isNameStart(c) {
166
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95; /* _ */
167
+ }
168
+ function isNameCont(c) {
169
+ return isNameStart(c) || (c >= 48 && c <= 57) || c === 45 /* - */ || c === 46; /* . */
170
+ }
171
+ /** Can this string be an attribute key / block name as-is (no escaping exists for it)? */
172
+ function isMddName(s) {
173
+ if (s.length === 0)
174
+ return false;
175
+ if (!isNameStart(s.charCodeAt(0)))
176
+ return false;
177
+ for (let i = 1; i < s.length; i++)
178
+ if (!isNameCont(s.charCodeAt(i)))
179
+ return false;
180
+ return true;
181
+ }
182
+ // ---------- Fenced JSON (opaque, lossless carrier for arbitrary values) ----------
183
+ function jsonFence(value) {
184
+ return '```json\n' + JSON.stringify(value, null, 2) + '\n```';
185
+ }
186
+ /** Decode a body that is entirely one ```json fence, else `{ ok: false }`. */
187
+ function readJsonFence(text) {
188
+ const t = text.trim();
189
+ if (!t.startsWith('```') || !t.endsWith('```') || t.length < 7)
190
+ return { ok: false };
191
+ const nl = t.indexOf('\n');
192
+ if (nl === -1)
193
+ return { ok: false };
194
+ const info = t.slice(3, nl).trim();
195
+ if (info !== 'json' && info !== '')
196
+ return { ok: false };
197
+ try {
198
+ return { ok: true, value: JSON.parse(t.slice(nl + 1, t.length - 3)) };
199
+ }
200
+ catch {
201
+ return { ok: false };
202
+ }
203
+ }
204
+ // ---------- Value shape helpers ----------
205
+ function isInterpolated(v) {
206
+ return v !== null && typeof v === 'object' && !Array.isArray(v) && 'raw' in v;
207
+ }
208
+ function isPlainScalar(v) {
209
+ return v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
210
+ }
211
+ /** Text of a value that may be an InterpolatedValue (`${…}` in an attribute/cell). */
212
+ function valueText(v) {
213
+ if (isInterpolated(v))
214
+ return v.raw;
215
+ return v === null || v === undefined ? '' : String(v);
216
+ }
217
+ function bodyText(body) {
218
+ if (body === undefined)
219
+ return '';
220
+ return typeof body === 'string' ? body : body.raw;
221
+ }
222
+ /**
223
+ * Body text with escapes unfolded — the parser leaves an InterpolatedValue raw
224
+ * (escapes intact, §2.5), so a JSON fence carrying `${` would come back with its
225
+ * backslashes still doubled. Only used where the body is opaque data, not a template.
226
+ */
227
+ function bodyData(body) {
228
+ if (body === undefined)
229
+ return '';
230
+ return typeof body === 'string' ? body : unescape(body.raw, { flow: false });
231
+ }
232
+ /** Parsed attribute value → plain JSON (drop InterpolatedValue wrappers). */
233
+ function attrToPlain(v) {
234
+ if (Array.isArray(v))
235
+ return v.map((it) => (isInterpolated(it) ? it.raw : it));
236
+ if (isInterpolated(v))
237
+ return v.raw;
238
+ return v;
239
+ }
240
+ // ---------- serializeWithSchema (§3.3) ----------
241
+ function toBodyString(value) {
242
+ if (typeof value === 'string')
243
+ return value.length > 0 ? value : undefined;
244
+ if (value === null || value === undefined)
245
+ return undefined;
246
+ if (isInterpolated(value))
247
+ return value.raw.length > 0 ? value.raw : undefined;
248
+ return String(value);
249
+ }
250
+ /** Column order for a tbl field: keys of `items.properties`, else record keys. */
251
+ function tblColumns(prop, records) {
252
+ const ip = itemsOf(prop).properties;
253
+ if (ip && typeof ip === 'object')
254
+ return Object.keys(ip);
255
+ return records.length > 0 ? Object.keys(records[0]) : [];
256
+ }
257
+ /** Separator for a list-in-cell column (x-mdd.separator), default `,`. */
258
+ function cellSeparator(prop) {
259
+ const sep = prop ? xmdd(prop).separator : undefined;
260
+ return typeof sep === 'string' && sep.length === 1 ? sep : ',';
261
+ }
262
+ /** Flatten a tbl cell to a scalar: a list-in-cell array joins by its separator. */
263
+ function cellToScalar(value, prop) {
264
+ if (Array.isArray(value)) {
265
+ return value.map((v) => (v === null ? '' : valueText(v))).join(cellSeparator(prop));
266
+ }
267
+ if (isInterpolated(value))
268
+ return value.raw;
269
+ return (value ?? null);
270
+ }
271
+ /** Emit one catch-all entry: scalar → attribute, anything else → fenced-JSON block. */
272
+ function emitUnknownEntry(parent, key, value, level) {
273
+ if (isPlainScalar(value)) {
274
+ parent.attrs.push({ key: [key], value });
275
+ return;
276
+ }
277
+ if (isInterpolated(value)) {
278
+ parent.attrs.push({ key: [key], value: value.raw });
279
+ return;
280
+ }
281
+ parent.children.push({ name: key, level, attrs: [], children: [], body: jsonFence(value) });
282
+ }
283
+ /** Catch-all: `obj[$unknown]` plus any own keys the schema does not describe. */
284
+ function unknownBagOf(obj, schema) {
285
+ const known = new Set(Object.keys(props(schema)));
286
+ const bag = {};
287
+ for (const [k, v] of Object.entries(obj)) {
288
+ if (k === UNKNOWN_KEY || known.has(k) || v === undefined)
289
+ continue;
290
+ bag[k] = v;
291
+ }
292
+ const explicit = obj[UNKNOWN_KEY];
293
+ if (explicit !== null && typeof explicit === 'object' && !Array.isArray(explicit)) {
294
+ for (const [k, v] of Object.entries(explicit)) {
295
+ if (v !== undefined)
296
+ bag[k] = v;
297
+ }
298
+ }
299
+ return bag;
300
+ }
301
+ /** Build the block of a map field: one sub-block per key (§3). */
302
+ function buildMapBlock(map, prop, key, level, ctx) {
303
+ const bad = Object.keys(map).filter((k) => !isMddName(k));
304
+ if (bad.length > 0) {
305
+ ctx.warn(`serializeWithSchema: map "${key}" has keys that are not valid mdd names ` +
306
+ `(${bad.join(', ')}) — emitting the whole map as fenced JSON.`);
307
+ return { name: key, level, attrs: [], children: [], body: jsonFence(map) };
308
+ }
309
+ const valueSchema = mapValueSchema(prop);
310
+ const block = { name: key, level, attrs: [], children: [] };
311
+ for (const [k, v] of Object.entries(map)) {
312
+ if (v === undefined)
313
+ continue;
314
+ if (isObjectSchema(valueSchema, v) && v !== null && typeof v === 'object' && !Array.isArray(v)) {
315
+ block.children.push(buildBlock(v, valueSchema, k, level + 1, ctx));
316
+ continue;
317
+ }
318
+ const child = { name: k, level: level + 1, attrs: [], children: [] };
319
+ const body = toBodyString(v);
320
+ if (body !== undefined)
321
+ child.body = body;
322
+ block.children.push(child);
323
+ }
324
+ return block;
325
+ }
326
+ /** Build a Block from (obj, schema). `defaultName` names a nested child block. */
327
+ function buildBlock(obj, schema, defaultName, level, ctx) {
328
+ const block = { name: defaultName, level, attrs: [], children: [] };
329
+ for (const [key, prop] of Object.entries(props(schema))) {
330
+ if (!(key in obj))
331
+ continue;
332
+ const value = obj[key];
333
+ if (value === undefined)
334
+ continue;
335
+ const storage = storageOf(prop);
336
+ const bname = blockNameOf(prop, key);
337
+ if (storage === 'name') {
338
+ block.name = String(value);
339
+ continue;
340
+ }
341
+ if (storage === 'id') {
342
+ block.id = String(value);
343
+ continue;
344
+ }
345
+ if (storage === 'body') {
346
+ const b = toBodyString(value);
347
+ if (b !== undefined)
348
+ block.body = b;
349
+ continue;
350
+ }
351
+ // as: 'json' — a declared opaque field (platform extensions): a fenced JSON block.
352
+ if (asJson(prop)) {
353
+ block.children.push({ name: bname, level: level + 1, attrs: [], children: [], body: jsonFence(value) });
354
+ continue;
355
+ }
356
+ // storage: 'block' — a body block of its own (prose: description, scenario, …).
357
+ if (storage === 'block') {
358
+ const child = { name: bname, level: level + 1, attrs: [], children: [] };
359
+ const b = toBodyString(value);
360
+ if (b !== undefined)
361
+ child.body = b;
362
+ block.children.push(child);
363
+ continue;
364
+ }
365
+ // storage === 'attr' (default), representation refined by shape / x-mdd.
366
+ if (Array.isArray(value)) {
367
+ const mode = listModeOf(prop, value);
368
+ if (mode === 'flow') {
369
+ block.attrs.push({ key: [key], value: value });
370
+ }
371
+ else if (mode === 'lines') {
372
+ block.children.push({
373
+ name: bname,
374
+ level: level + 1,
375
+ attrs: [],
376
+ children: [],
377
+ body: value.map((v) => valueText(v)).join('\n'),
378
+ });
379
+ }
380
+ else if (mode === 'blocks') {
381
+ // blocks — one child block per element, all named after the property key
382
+ // (or x-mdd.block). A scalar element (array of strings) → a block with a string body.
383
+ const items = itemsOf(prop);
384
+ if (value.length === 0) {
385
+ // An empty array is inexpressible as repeated blocks (zero blocks reads as
386
+ // 'no field'), so it degenerates to an empty flow attribute, which parses
387
+ // back as [] (§3.5).
388
+ block.attrs.push({ key: [key], value: [] });
389
+ }
390
+ for (const el of value) {
391
+ if (isScalarSchema(items) || isPlainScalar(el) || isInterpolated(el)) {
392
+ const child = { name: bname, level: level + 1, attrs: [], children: [] };
393
+ const b = toBodyString(el);
394
+ if (b !== undefined)
395
+ child.body = b;
396
+ block.children.push(child);
397
+ continue;
398
+ }
399
+ block.children.push(buildBlock((el ?? {}), items, bname, level + 1, ctx));
400
+ }
401
+ }
402
+ else {
403
+ // tbl — one child block whose body is the table.
404
+ const records = value;
405
+ const columns = tblColumns(prop, records);
406
+ const itemProps = props(itemsOf(prop));
407
+ const flat = records.map((r) => {
408
+ const rec = {};
409
+ for (const c of columns)
410
+ rec[c] = cellToScalar(r[c], itemProps[c]);
411
+ return rec;
412
+ });
413
+ block.children.push({
414
+ name: bname,
415
+ level: level + 1,
416
+ attrs: [],
417
+ children: [],
418
+ body: serializeTable(flat, { columns }),
419
+ });
420
+ }
421
+ continue;
422
+ }
423
+ if (isMapSchema(prop)) {
424
+ if (value !== null && typeof value === 'object') {
425
+ block.children.push(buildMapBlock(value, prop, bname, level + 1, ctx));
426
+ }
427
+ continue;
428
+ }
429
+ if (isObjectSchema(prop, value)) {
430
+ block.children.push(buildBlock(value, prop, bname, level + 1, ctx));
431
+ continue;
432
+ }
433
+ // Scalar attribute.
434
+ block.attrs.push({ key: [key], value: cellToScalar(value, prop) });
435
+ }
436
+ if (unknownEnabled(schema)) {
437
+ const overflow = {};
438
+ for (const [k, v] of Object.entries(unknownBagOf(obj, schema))) {
439
+ if (isMddName(k))
440
+ emitUnknownEntry(block, k, v, level + 1);
441
+ else
442
+ overflow[k] = v;
443
+ }
444
+ if (Object.keys(overflow).length > 0) {
445
+ ctx.warn(`serializeWithSchema: unknown keys that are not valid mdd names ` +
446
+ `(${Object.keys(overflow).join(', ')}) — carried in the "${UNKNOWN_OVERFLOW_BLOCK}" JSON block.`);
447
+ block.children.push({
448
+ name: UNKNOWN_OVERFLOW_BLOCK,
449
+ level: level + 1,
450
+ attrs: [],
451
+ children: [],
452
+ body: jsonFence(overflow),
453
+ });
454
+ }
455
+ }
456
+ return block;
457
+ }
458
+ /**
459
+ * Serialize an object to mdd/mdz text through a JSON Schema (serialize-spec §3.3).
460
+ *
461
+ * Layout by `x-storage` (attr | body | name | id) and `x-mdd` (list: flow | tbl |
462
+ * lines | blocks, unknown: block). Field order = order of schema `properties` —
463
+ * deterministic, so the same object always serializes byte-for-byte identically.
464
+ */
465
+ export function serializeWithSchema(obj, schema, opts = {}) {
466
+ const sigil = opts.sigil ?? '$';
467
+ const record = (obj ?? {});
468
+ // envelope — the root has no header of its own and its blocks are top-level, so
469
+ // build the record at level 0 and children land at 1 (h1). Otherwise children go to h2.
470
+ const built = buildBlock(record, schema, '', envelopeEnabled(schema) ? 0 : 1, ctxOf(opts));
471
+ const root = { name: '', level: 0, attrs: [], children: [] };
472
+ if (built.name !== '' || built.id !== undefined) {
473
+ // A name/id-storage field gives the record a header — emit a named block.
474
+ root.children.push(built);
475
+ }
476
+ else {
477
+ // No header identity — the record IS the document (flat layout).
478
+ root.attrs = built.attrs;
479
+ if (built.body !== undefined)
480
+ root.body = built.body;
481
+ root.children = built.children;
482
+ }
483
+ return serialize({ sigil, root }, { sigil, baseLevel: opts.baseLevel, eol: opts.eol });
484
+ }
485
+ // ---------- parseWithSchema (§3.3) ----------
486
+ function getAttr(block, key) {
487
+ for (const a of block.attrs)
488
+ if (a.key.join('.') === key)
489
+ return a;
490
+ return undefined;
491
+ }
492
+ function childByName(block, name) {
493
+ for (const c of block.children)
494
+ if (c.name === name)
495
+ return c;
496
+ return undefined;
497
+ }
498
+ /** ALL children with this name — a `blocks` array is a repeated block (§3.2). */
499
+ function childrenByName(block, name) {
500
+ return block.children.filter((c) => c.name === name);
501
+ }
502
+ /** Schema type wins over YAML coercion (§3.3 / format-spec §5). */
503
+ function coerceToSchema(value, prop) {
504
+ // An InterpolatedValue is a `${…}`-bearing raw text — its data form is `.raw`.
505
+ const v = isInterpolated(value) ? value.raw : value;
506
+ const t = prop.type;
507
+ if (t === 'number' || t === 'integer') {
508
+ if (typeof v === 'number')
509
+ return v;
510
+ if (typeof v === 'string' && v.trim() !== '') {
511
+ const n = Number(v);
512
+ if (Number.isFinite(n))
513
+ return n;
514
+ }
515
+ return v;
516
+ }
517
+ if (t === 'boolean') {
518
+ if (typeof v === 'boolean')
519
+ return v;
520
+ if (v === 'true')
521
+ return true;
522
+ if (v === 'false')
523
+ return false;
524
+ return v;
525
+ }
526
+ if (t === 'string')
527
+ return typeof v === 'string' ? v : String(v);
528
+ return v;
529
+ }
530
+ /** JSON-schema type name that matches an inline column type (for conflict check). */
531
+ function inlineTypeName(type) {
532
+ if (!type)
533
+ return undefined;
534
+ return type.kind === 'list' ? 'array' : type.kind;
535
+ }
536
+ /** Coerce a raw tbl cell by an external schema property (schema wins, §3.3). */
537
+ function coerceRawBySchema(raw, prop) {
538
+ const t = prop.type;
539
+ const isEmpty = raw === '';
540
+ if (t === 'string') {
541
+ if (isEmpty)
542
+ return '';
543
+ return raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"'
544
+ ? coerce(raw)
545
+ : raw;
546
+ }
547
+ if (t === 'number' || t === 'integer') {
548
+ const c = coerce(raw);
549
+ return typeof c === 'number' ? c : raw;
550
+ }
551
+ if (t === 'boolean') {
552
+ const c = coerce(raw);
553
+ return typeof c === 'boolean' ? c : raw;
554
+ }
555
+ if (t === 'array') {
556
+ if (isEmpty)
557
+ return [];
558
+ const sep = xmdd(prop).separator;
559
+ return raw.split(typeof sep === 'string' && sep.length === 1 ? sep : ',').map((e) => coerce(e.trim()));
560
+ }
561
+ return coerce(raw);
562
+ }
563
+ /** `integer` and the inline `number` annotation describe the same column. */
564
+ function typesConflict(inline, schemaType) {
565
+ if (inline === schemaType)
566
+ return false;
567
+ return !(inline === 'number' && schemaType === 'integer');
568
+ }
569
+ /**
570
+ * Coerce a tbl cell honouring the type-source priority (serialize-spec §3.3):
571
+ * external schema > inline header annotation > per-cell coercion (§3).
572
+ */
573
+ function coerceTblCell(raw, col, schemaProp, ctx) {
574
+ if (schemaProp && schemaProp.type !== undefined) {
575
+ const inline = inlineTypeName(col.type);
576
+ if (inline !== undefined && typesConflict(inline, schemaProp.type)) {
577
+ // Conflict: schema wins, but warn (§3.3 / acceptance §7.5).
578
+ ctx.warn(`parseWithSchema: column "${col.name}" inline type "${inline}" conflicts with ` +
579
+ `schema type "${String(schemaProp.type)}" — schema wins.`);
580
+ }
581
+ return coerceRawBySchema(raw, schemaProp);
582
+ }
583
+ // No external type — inline annotation, else per-cell §3.
584
+ return coerceTyped(raw, col);
585
+ }
586
+ function scalarOfListItem(item) {
587
+ if (isInterpolated(item))
588
+ return item.raw;
589
+ return item;
590
+ }
591
+ /** Read an unclaimed block with NO schema: fenced JSON, else a generic structure. */
592
+ function readUnknownBlock(block) {
593
+ const fence = readJsonFence(bodyData(block.body));
594
+ if (fence.ok)
595
+ return fence.value;
596
+ const out = {};
597
+ for (const a of block.attrs)
598
+ out[a.key.join('.')] = attrToPlain(a.value);
599
+ for (const c of block.children)
600
+ out[c.name] = readUnknownBlock(c);
601
+ const body = bodyText(block.body);
602
+ if (Object.keys(out).length === 0)
603
+ return body;
604
+ if (body !== '')
605
+ out[UNKNOWN_KEY] = body;
606
+ return out;
607
+ }
608
+ function readMap(child, prop, ctx) {
609
+ const fence = readJsonFence(bodyData(child.body));
610
+ if (fence.ok && fence.value !== null && typeof fence.value === 'object') {
611
+ return fence.value;
612
+ }
613
+ const valueSchema = mapValueSchema(prop);
614
+ const map = {};
615
+ for (const c of child.children) {
616
+ map[c.name] =
617
+ valueSchema.type === 'object'
618
+ ? readObject(c, valueSchema, ctx)
619
+ : coerceToSchema(bodyText(c.body), valueSchema);
620
+ }
621
+ return map;
622
+ }
623
+ /** Block names the schema claims — key or `x-mdd.block` (§3.9). */
624
+ function claimedBlockNames(schema) {
625
+ const names = new Set();
626
+ for (const [key, prop] of Object.entries(props(schema)))
627
+ names.add(blockNameOf(prop, key));
628
+ return names;
629
+ }
630
+ function readObject(block, schema, ctx) {
631
+ const out = {};
632
+ const usedAttrs = new Set();
633
+ const usedChildren = new Set();
634
+ const takeAttr = (key) => {
635
+ const a = getAttr(block, key);
636
+ if (a)
637
+ usedAttrs.add(a);
638
+ return a;
639
+ };
640
+ const takeChild = (name) => {
641
+ const c = childByName(block, name);
642
+ if (c)
643
+ usedChildren.add(c);
644
+ return c;
645
+ };
646
+ for (const [key, prop] of Object.entries(props(schema))) {
647
+ const storage = storageOf(prop);
648
+ const bname = blockNameOf(prop, key);
649
+ if (storage === 'name') {
650
+ if (block.name !== '')
651
+ out[key] = coerceToSchema(block.name, prop);
652
+ continue;
653
+ }
654
+ if (storage === 'id') {
655
+ if (block.id !== undefined)
656
+ out[key] = coerceToSchema(block.id, prop);
657
+ continue;
658
+ }
659
+ if (storage === 'body') {
660
+ if (block.body !== undefined)
661
+ out[key] = coerceToSchema(bodyText(block.body), prop);
662
+ continue;
663
+ }
664
+ // as: 'json' — a fenced JSON block. A body not recognised as a fence is read
665
+ // generically (like an unknown block): approximate beats lost.
666
+ if (asJson(prop)) {
667
+ const child = takeChild(bname);
668
+ if (child) {
669
+ const fence = readJsonFence(bodyData(child.body));
670
+ out[key] = fence.ok ? fence.value : readUnknownBlock(child);
671
+ }
672
+ continue;
673
+ }
674
+ // storage: 'block' — a body block of its own. An empty body is an empty string
675
+ // (the block exists).
676
+ if (storage === 'block') {
677
+ const child = takeChild(bname);
678
+ if (child)
679
+ out[key] = coerceToSchema(bodyText(child.body), prop);
680
+ continue;
681
+ }
682
+ if (prop.type === 'array') {
683
+ const mode = listModeOf(prop, undefined);
684
+ if (mode === 'flow') {
685
+ const a = takeAttr(key);
686
+ if (a && Array.isArray(a.value)) {
687
+ const items = itemsOf(prop);
688
+ out[key] = a.value.map((it) => coerceToSchema(scalarOfListItem(it), items));
689
+ }
690
+ }
691
+ else if (mode === 'lines') {
692
+ const child = takeChild(bname);
693
+ if (child) {
694
+ const t = bodyText(child.body);
695
+ out[key] = t === '' ? [] : t.split('\n');
696
+ }
697
+ }
698
+ else if (mode === 'blocks') {
699
+ const items = itemsOf(prop);
700
+ // Elements are repeated blocks named after the property key (or x-mdd.block).
701
+ // When the items schema carries an `x-storage: name` field the element's own
702
+ // name replaces that header, so the elements are instead every block the schema
703
+ // does not otherwise claim (documented limitation: at most one such array per
704
+ // object, and it swallows what a catch-all would have collected).
705
+ const claimed = claimedBlockNames(schema);
706
+ const kids = hasNameStorage(items)
707
+ ? block.children.filter((c) => !claimed.has(c.name))
708
+ : childrenByName(block, bname);
709
+ for (const c of kids)
710
+ usedChildren.add(c);
711
+ if (kids.length > 0) {
712
+ out[key] = kids.map((c) => isScalarSchema(items) ? coerceToSchema(bodyText(c.body), items) : readObject(c, items, ctx));
713
+ }
714
+ else {
715
+ // Zero blocks means either no field at all, or an empty list — and an empty
716
+ // list is carried by the degenerate empty flow attribute (§3.5).
717
+ const a = takeAttr(key);
718
+ if (a && Array.isArray(a.value))
719
+ out[key] = [];
720
+ }
721
+ }
722
+ else {
723
+ // tbl — external schema wins over inline header types (§3.3 priority).
724
+ const child = takeChild(bname);
725
+ if (child) {
726
+ const { columns, rows } = parseTableRows(bodyText(child.body));
727
+ const itemProps = props(itemsOf(prop));
728
+ out[key] = rows.map((row) => {
729
+ const obj = {};
730
+ columns.forEach((col, idx) => {
731
+ obj[col.name] = coerceTblCell(row[idx] ?? '', col, itemProps[col.name], ctx);
732
+ });
733
+ return obj;
734
+ });
735
+ }
736
+ }
737
+ continue;
738
+ }
739
+ if (isMapSchema(prop)) {
740
+ const child = takeChild(bname);
741
+ if (child)
742
+ out[key] = readMap(child, prop, ctx);
743
+ continue;
744
+ }
745
+ if (prop.type === 'object') {
746
+ const child = takeChild(bname);
747
+ if (child)
748
+ out[key] = readObject(child, prop, ctx);
749
+ continue;
750
+ }
751
+ // Scalar attribute.
752
+ const a = takeAttr(key);
753
+ if (a !== undefined)
754
+ out[key] = coerceToSchema(a.value, prop);
755
+ }
756
+ if (unknownEnabled(schema)) {
757
+ const bag = {};
758
+ for (const a of block.attrs) {
759
+ if (usedAttrs.has(a))
760
+ continue;
761
+ bag[a.key.join('.')] = attrToPlain(a.value);
762
+ }
763
+ for (const c of block.children) {
764
+ if (usedChildren.has(c))
765
+ continue;
766
+ if (c.name === UNKNOWN_OVERFLOW_BLOCK) {
767
+ const fence = readJsonFence(bodyData(c.body));
768
+ if (fence.ok && fence.value !== null && typeof fence.value === 'object' && !Array.isArray(fence.value)) {
769
+ Object.assign(bag, fence.value);
770
+ continue;
771
+ }
772
+ }
773
+ bag[c.name] = readUnknownBlock(c);
774
+ }
775
+ if (Object.keys(bag).length > 0) {
776
+ if (unknownInline(schema))
777
+ Object.assign(out, bag);
778
+ else
779
+ out[UNKNOWN_KEY] = bag;
780
+ }
781
+ }
782
+ return out;
783
+ }
784
+ /**
785
+ * Parse mdd/mdz text back into an object through a JSON Schema (serialize-spec §3.3).
786
+ *
787
+ * Inverse of {@link serializeWithSchema}: reassembles the object from x-storage
788
+ * placement and coerces each field by its schema type (schema type wins over
789
+ * YAML coercion). Generic `<T>` is a TS-only convenience — no runtime effect.
790
+ */
791
+ export function parseWithSchema(text, schema, opts = {}) {
792
+ const doc = parse(text, { sigil: opts.sigil, baseLevel: opts.baseLevel });
793
+ const recordBlock = hasNameStorage(schema) && doc.root.children.length > 0 ? doc.root.children[0] : doc.root;
794
+ return readObject(recordBlock, schema, ctxOf(opts));
795
+ }
796
+ // ---------- validate (§3.4) ----------
797
+ let ajvCtor = undefined; // undefined = not yet tried, null = unavailable
798
+ function loadAjv2020() {
799
+ if (ajvCtor !== undefined)
800
+ return ajvCtor;
801
+ ajvCtor = null;
802
+ try {
803
+ // Ajv is an OPTIONAL peer (§3.4) — resolve it synchronously if present.
804
+ // This Node-only delegate is the TS binding; ports use their native validator.
805
+ // eval('require') broke under ESM (there is no require, so validate always threw
806
+ // 'not installed'). process.getBuiltinModule gives synchronous access to a builtin
807
+ // from ESM (Node 22.3+); in a browser the typeof guard yields null and the caller
808
+ // injects a validator instead.
809
+ const builtin = (typeof process !== 'undefined' &&
810
+ typeof process.getBuiltinModule === 'function')
811
+ ? process.getBuiltinModule('module')
812
+ : undefined;
813
+ const req = builtin?.createRequire(import.meta.url);
814
+ if (typeof req === 'function') {
815
+ const mod = req('ajv/dist/2020.js');
816
+ ajvCtor = mod.default ?? mod;
817
+ }
818
+ }
819
+ catch {
820
+ ajvCtor = null;
821
+ }
822
+ return ajvCtor;
823
+ }
824
+ /**
825
+ * Validate an object against a schema (serialize-spec §3.4).
826
+ *
827
+ * Delegates to Ajv2020 with `addVocabulary(['x-storage','x-mdd','x-ui'])` so the
828
+ * strict mode does not choke on our x-extensions (nr-schema §5). Ajv is an
829
+ * OPTIONAL peer — when it is not installed and no `opts.validator` is provided,
830
+ * a clear error is thrown ("install ajv or provide a validator").
831
+ */
832
+ export function validate(obj, schema, opts = {}) {
833
+ if (opts.validator)
834
+ return opts.validator(obj, schema);
835
+ const Ctor = loadAjv2020();
836
+ if (!Ctor) {
837
+ throw new Error('validate: Ajv is not installed. Install the optional peer `ajv` (Ajv2020), ' +
838
+ 'or pass a validator via opts.validator.');
839
+ }
840
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
841
+ const AjvCtor = Ctor;
842
+ const ajv = new AjvCtor({ strict: true, allErrors: true });
843
+ ajv.addVocabulary(['x-storage', 'x-mdd', 'x-ui']);
844
+ const validateFn = ajv.compile(schema);
845
+ const valid = Boolean(validateFn(obj));
846
+ const errors = valid
847
+ ? []
848
+ : (validateFn.errors ?? []).map((e) => ({
849
+ path: e.instancePath ?? '',
850
+ message: e.message ?? 'invalid',
851
+ }));
852
+ return { valid, errors };
853
+ }