@sneat/structured-data 0.0.0-stage → 0.27.29

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,692 @@
1
+ import { parseTree } from 'jsonc-parser';
2
+ import { parseDocument, isAlias, isScalar, isSeq, isMap } from 'yaml';
3
+
4
+ const DEFAULT_LIMITS = {
5
+ maxSourceBytes: 2_000_000,
6
+ maxNodes: 30_000,
7
+ maxDepth: 64,
8
+ maxRows: 2_000,
9
+ maxConfigBytes: 64_000,
10
+ maxRules: 100,
11
+ maxSections: 300,
12
+ maxSelectorLength: 256,
13
+ maxSelectorDepth: 16,
14
+ maxYamlAliases: 0,
15
+ timeoutMs: 2_000,
16
+ };
17
+ function effectiveLimits(overrides) {
18
+ const result = { ...DEFAULT_LIMITS };
19
+ if (overrides) {
20
+ for (const key of Object.keys(DEFAULT_LIMITS)) {
21
+ const value = overrides[key];
22
+ if (value !== undefined) {
23
+ if (!Number.isSafeInteger(value) || value < 0 || value > DEFAULT_LIMITS[key]) {
24
+ throw new Error(`Invalid ${key} limit`);
25
+ }
26
+ result[key] = value;
27
+ }
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ function sourceByteLength(text) {
33
+ return new TextEncoder().encode(text).length;
34
+ }
35
+ function exceedsSourceLimit(text, maxBytes) {
36
+ return text.length > maxBytes || sourceByteLength(text) > maxBytes;
37
+ }
38
+ function diagnostic(code, message, range, source) {
39
+ return { code, message, severity: 'error', ...(range ? { range } : {}), ...(source ? { source } : {}) };
40
+ }
41
+ /** Tree-sitter and JS parser offsets are UTF-16 code-unit offsets. */
42
+ class SourceLocator {
43
+ constructor(source) {
44
+ this.source = source;
45
+ this.lineStarts = [0];
46
+ for (let index = 0; index < source.length; index++) {
47
+ if (source[index] === '\r') {
48
+ if (source[index + 1] === '\n')
49
+ index++;
50
+ this.lineStarts.push(index + 1);
51
+ }
52
+ else if (source[index] === '\n') {
53
+ this.lineStarts.push(index + 1);
54
+ }
55
+ }
56
+ }
57
+ range(startOffset, endOffset) {
58
+ return {
59
+ startOffset,
60
+ endOffset,
61
+ start: this.position(startOffset),
62
+ end: this.position(endOffset),
63
+ };
64
+ }
65
+ position(offset) {
66
+ let low = 0;
67
+ let high = this.lineStarts.length;
68
+ while (low + 1 < high) {
69
+ const middle = (low + high) >>> 1;
70
+ if (this.lineStarts[middle] <= offset)
71
+ low = middle;
72
+ else
73
+ high = middle;
74
+ }
75
+ return { line: low, column: offset - this.lineStarts[low] };
76
+ }
77
+ }
78
+
79
+ function parseJsonText(sourceText, limits) {
80
+ let bounded;
81
+ try {
82
+ bounded = effectiveLimits(limits);
83
+ }
84
+ catch (error) {
85
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', String(error))] };
86
+ }
87
+ if (exceedsSourceLimit(sourceText, bounded.maxSourceBytes)) {
88
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', `JSON source exceeds ${bounded.maxSourceBytes} bytes`)] };
89
+ }
90
+ const errors = [];
91
+ let root;
92
+ try {
93
+ root = parseTree(sourceText, errors, { disallowComments: true, allowTrailingComma: false });
94
+ }
95
+ catch {
96
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', 'JSON parser could not process this source safely')] };
97
+ }
98
+ const locator = new SourceLocator(sourceText);
99
+ if (!root || errors.length) {
100
+ return { ok: false, diagnostics: errors.length
101
+ ? errors.map(error => diagnostic('invalid_syntax', `Invalid JSON (${error.error})`, locator.range(error.offset, error.offset + error.length)))
102
+ : [diagnostic('invalid_syntax', 'JSON document is empty')] };
103
+ }
104
+ let count = 0;
105
+ try {
106
+ const convert = (node, depth) => {
107
+ if (++count > bounded.maxNodes || depth > bounded.maxDepth)
108
+ throw new Error('JSON node or depth limit exceeded');
109
+ const range = locator.range(node.offset, node.offset + node.length);
110
+ switch (node.type) {
111
+ case 'null': return { kind: 'null', range };
112
+ case 'boolean': return { kind: 'boolean', value: node.value, range };
113
+ case 'string': return { kind: 'string', value: node.value, range };
114
+ case 'number': {
115
+ const sourceNumber = sourceText.slice(node.offset, node.offset + node.length);
116
+ const parsedNumber = Number(sourceNumber);
117
+ const losslessInteger = !Number.isInteger(parsedNumber) || Number.isSafeInteger(parsedNumber);
118
+ return { kind: 'number', sourceText: sourceNumber,
119
+ ...(Number.isFinite(parsedNumber) && losslessInteger ? { value: parsedNumber } : {}), range };
120
+ }
121
+ case 'array': return { kind: 'array', items: (node.children ?? []).map(child => convert(child, depth + 1)), range };
122
+ case 'object': {
123
+ const entries = [];
124
+ const seen = new Set();
125
+ for (const property of node.children ?? []) {
126
+ const keyNode = property.children?.[0];
127
+ const valueNode = property.children?.[1];
128
+ if (!keyNode || !valueNode || typeof keyNode.value !== 'string')
129
+ throw new Error('Invalid JSON property');
130
+ const key = keyNode.value;
131
+ if (seen.has(key))
132
+ throw new Error(`Duplicate JSON key: ${key}`);
133
+ seen.add(key);
134
+ entries.push({ key, value: convert(valueNode, depth + 1), range: locator.range(property.offset, property.offset + property.length) });
135
+ }
136
+ return { kind: 'object', entries, range };
137
+ }
138
+ default: throw new Error(`Unsupported JSON node: ${node.type}`);
139
+ }
140
+ };
141
+ return { ok: true, document: { format: 'json', root: convert(root, 0), sourceText }, diagnostics: [] };
142
+ }
143
+ catch (error) {
144
+ const message = String(error);
145
+ return { ok: false, diagnostics: [diagnostic(message.includes('limit') ? 'limit_exceeded' : 'invalid_syntax', message)] };
146
+ }
147
+ }
148
+
149
+ function parseYamlText(sourceText, limits) {
150
+ let bounded;
151
+ try {
152
+ bounded = effectiveLimits(limits);
153
+ }
154
+ catch (error) {
155
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', String(error))] };
156
+ }
157
+ if (exceedsSourceLimit(sourceText, bounded.maxSourceBytes)) {
158
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', `YAML source exceeds ${bounded.maxSourceBytes} bytes`)] };
159
+ }
160
+ const locator = new SourceLocator(sourceText);
161
+ let yaml;
162
+ try {
163
+ yaml = parseDocument(sourceText, { uniqueKeys: true, strict: true });
164
+ }
165
+ catch {
166
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', 'YAML parser could not process this source safely')] };
167
+ }
168
+ if (yaml.errors.length) {
169
+ return { ok: false, diagnostics: yaml.errors.map(error => diagnostic('invalid_syntax', error.message, error.pos ? locator.range(error.pos[0], error.pos[1]) : undefined)) };
170
+ }
171
+ let count = 0;
172
+ const rangeOf = (node) => node?.range
173
+ ? locator.range(node.range[0], node.range[1]) : undefined;
174
+ try {
175
+ const convert = (node, depth) => {
176
+ if (++count > bounded.maxNodes || depth > bounded.maxDepth)
177
+ throw new Error('YAML node or depth limit exceeded');
178
+ if (node === null)
179
+ return { kind: 'null' };
180
+ if (isAlias(node))
181
+ throw new Error('YAML aliases are unsupported in structured view');
182
+ if (node.tag && !node.tag.startsWith('tag:yaml.org,2002:'))
183
+ throw new Error(`Unsupported YAML tag: ${node.tag}`);
184
+ const range = rangeOf(node);
185
+ if (isScalar(node)) {
186
+ const value = node.value;
187
+ if (value === null || value === undefined)
188
+ return { kind: 'null', range };
189
+ if (typeof value === 'boolean')
190
+ return { kind: 'boolean', value, range };
191
+ if (typeof value === 'number') {
192
+ const sourceNumber = range ? sourceText.slice(range.startOffset, range.endOffset) : String(value);
193
+ return { kind: 'number', sourceText: sourceNumber,
194
+ ...(Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value)) ? { value } : {}), range };
195
+ }
196
+ if (typeof value === 'string')
197
+ return { kind: 'string', value, range };
198
+ throw new Error('Unsupported YAML scalar');
199
+ }
200
+ if (isSeq(node))
201
+ return { kind: 'array', items: node.items.map(item => convert(item, depth + 1)), range };
202
+ if (isMap(node)) {
203
+ const entries = [];
204
+ const seen = new Set();
205
+ for (const pair of node.items) {
206
+ if (!isScalar(pair.key) || typeof pair.key.value !== 'string')
207
+ throw new Error('YAML mapping keys must be strings');
208
+ const key = pair.key.value;
209
+ if (seen.has(key))
210
+ throw new Error(`Duplicate YAML key: ${key}`);
211
+ seen.add(key);
212
+ entries.push({ key, value: convert(pair.value, depth + 1), range: rangeOf(pair.key) });
213
+ }
214
+ return { kind: 'object', entries, range };
215
+ }
216
+ throw new Error('Unsupported YAML node');
217
+ };
218
+ return { ok: true, document: { format: 'yaml', root: convert(yaml.contents, 0), sourceText }, diagnostics: [] };
219
+ }
220
+ catch (error) {
221
+ const message = String(error);
222
+ const code = message.includes('limit') ? 'limit_exceeded' : 'unsupported_construct';
223
+ return { ok: false, diagnostics: [diagnostic(code, message)] };
224
+ }
225
+ }
226
+
227
+ /** Loads only same-origin packaged assets, and only when invoked in a browser. */
228
+ async function parseHclText(sourceText, limits) {
229
+ let bounded;
230
+ try {
231
+ bounded = effectiveLimits(limits);
232
+ }
233
+ catch (error) {
234
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', String(error))] };
235
+ }
236
+ if (exceedsSourceLimit(sourceText, bounded.maxSourceBytes)) {
237
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', `HCL source exceeds ${bounded.maxSourceBytes} bytes`)] };
238
+ }
239
+ if (typeof Worker === 'undefined') {
240
+ return { ok: false, diagnostics: [diagnostic('parser_unavailable', 'HCL parsing requires a browser Worker')] };
241
+ }
242
+ let worker;
243
+ try {
244
+ worker = new Worker(new URL('../assets/hcl-worker.mjs', import.meta.url), { type: 'module' });
245
+ }
246
+ catch (error) {
247
+ return { ok: false, diagnostics: [diagnostic('parser_unavailable', `HCL Worker could not start: ${String(error)}`)] };
248
+ }
249
+ return new Promise(resolve => {
250
+ let settled = false;
251
+ const finish = (result) => {
252
+ if (settled)
253
+ return;
254
+ settled = true;
255
+ clearTimeout(timer);
256
+ worker.terminate();
257
+ resolve(result);
258
+ };
259
+ const timer = setTimeout(() => finish({ ok: false,
260
+ diagnostics: [diagnostic('limit_exceeded', 'HCL parsing exceeded its hard deadline')] }), bounded.timeoutMs + 1_000);
261
+ worker.onmessage = (event) => {
262
+ const reply = event.data;
263
+ if (!reply || reply.id !== 1)
264
+ return;
265
+ if (reply.ok && reply.root)
266
+ finish({ ok: true,
267
+ document: { format: 'hcl', root: reply.root, sourceText }, diagnostics: [] });
268
+ else
269
+ finish({ ok: false, diagnostics: reply.diagnostics?.length
270
+ ? reply.diagnostics : [diagnostic('invalid_syntax', 'HCL parser returned no document')] });
271
+ };
272
+ worker.onerror = () => finish({ ok: false,
273
+ diagnostics: [diagnostic('parser_unavailable', 'HCL Worker failed to load its packaged assets')] });
274
+ worker.postMessage({ id: 1, sourceText, limits: bounded });
275
+ });
276
+ }
277
+
278
+ /** RFC 6901 without wildcards, evaluation, or prototype traversal. */
279
+ function parseRelativePointer(pointer, maxLength = DEFAULT_LIMITS.maxSelectorLength, maxDepth = DEFAULT_LIMITS.maxSelectorDepth) {
280
+ if (pointer === '')
281
+ return [];
282
+ if (!pointer.startsWith('/') || pointer.length > maxLength)
283
+ return null;
284
+ const parts = pointer.slice(1).split('/');
285
+ if (parts.length > maxDepth)
286
+ return null;
287
+ const decoded = [];
288
+ for (const part of parts) {
289
+ if (/~(?![01])/.test(part))
290
+ return null;
291
+ const value = part.replace(/~1/g, '/').replace(/~0/g, '~');
292
+ if (value === '__proto__' || value === 'prototype' || value === 'constructor')
293
+ return null;
294
+ decoded.push(value);
295
+ }
296
+ return decoded;
297
+ }
298
+ const missing = { found: false };
299
+ function selectRelative(node, pointer) {
300
+ const parts = parseRelativePointer(pointer);
301
+ if (parts === null)
302
+ return missing;
303
+ let current = node;
304
+ for (let index = 0; index < parts.length; index++) {
305
+ const part = parts[index];
306
+ if (current.kind === 'hcl-block')
307
+ current = current.body;
308
+ if (current.kind === 'hcl-attribute')
309
+ current = current.value;
310
+ if (current.kind === 'hcl-body') {
311
+ const body = current;
312
+ if (part === 'attributes' && index + 1 < parts.length) {
313
+ const name = parts[++index];
314
+ const found = body.members.find(member => member.kind === 'hcl-attribute' && member.name === name);
315
+ if (!found || found.kind !== 'hcl-attribute')
316
+ return missing;
317
+ current = found.value;
318
+ continue;
319
+ }
320
+ if (part === 'blocks' && index + 1 < parts.length) {
321
+ const position = Number(parts[++index]);
322
+ if (!Number.isSafeInteger(position) || position < 0 || String(position) !== parts[index])
323
+ return missing;
324
+ const found = body.members.filter(member => member.kind === 'hcl-block')[position];
325
+ if (!found)
326
+ return missing;
327
+ current = found;
328
+ continue;
329
+ }
330
+ return missing;
331
+ }
332
+ if (current.kind === 'object') {
333
+ const found = current.entries.find(entry => entry.key === part);
334
+ if (!found)
335
+ return missing;
336
+ current = found.value;
337
+ continue;
338
+ }
339
+ if (current.kind === 'array') {
340
+ const position = Number(part);
341
+ if (!Number.isSafeInteger(position) || position < 0 || String(position) !== part)
342
+ return missing;
343
+ const found = current.items[position];
344
+ if (!found)
345
+ return missing;
346
+ current = found;
347
+ continue;
348
+ }
349
+ return missing;
350
+ }
351
+ return { found: true, value: current };
352
+ }
353
+ function selectViewPath(context, path) {
354
+ if (path === '$key')
355
+ return context.key === undefined ? missing
356
+ : { found: true, value: { kind: 'string', value: context.key } };
357
+ if (path.startsWith('$label/')) {
358
+ const position = Number(path.slice('$label/'.length));
359
+ if (!Number.isSafeInteger(position) || position < 0 || String(position) !== path.slice('$label/'.length))
360
+ return missing;
361
+ const value = context.labels?.[position];
362
+ return value === undefined ? missing : { found: true, value: { kind: 'string', value } };
363
+ }
364
+ return selectRelative(context.node, path);
365
+ }
366
+
367
+ function record(value) {
368
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
369
+ return null;
370
+ const prototype = Object.getPrototypeOf(value);
371
+ if (prototype !== Object.prototype && prototype !== null)
372
+ return null;
373
+ for (const property of Object.values(Object.getOwnPropertyDescriptors(value))) {
374
+ if (!('value' in property))
375
+ return null;
376
+ }
377
+ return value;
378
+ }
379
+ function exactKeys(value, allowed) {
380
+ return Object.keys(value).every(key => allowed.includes(key));
381
+ }
382
+ function text(value, max = 256) {
383
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
384
+ }
385
+ function pointer(value, limits, allowSpecial = false) {
386
+ if (typeof value !== 'string')
387
+ return false;
388
+ if (allowSpecial && (value === '$key' || /^\$label\/(0|[1-9]\d{0,2})$/.test(value)))
389
+ return true;
390
+ return parseRelativePointer(value, limits.maxSelectorLength, limits.maxSelectorDepth) !== null;
391
+ }
392
+ function safeGlob(value, limits) {
393
+ return text(value, limits.maxSelectorLength) && !value.startsWith('/') && !value.includes('..')
394
+ && /^[A-Za-z0-9_./*?@-]+$/.test(value);
395
+ }
396
+ function parseTitle(value, limits) {
397
+ const source = record(value);
398
+ if (!source || !exactKeys(source, ['label', 'path']) || !text(source['label']) || !pointer(source['path'], limits))
399
+ return null;
400
+ return { label: source['label'], path: source['path'] };
401
+ }
402
+ function parseFields(value, limits) {
403
+ if (!Array.isArray(value) || value.length > 64)
404
+ return null;
405
+ const result = [];
406
+ for (const item of value) {
407
+ const field = record(item);
408
+ if (!field || !exactKeys(field, ['label', 'path', 'scalarListLayout']) || !text(field['label'])
409
+ || !pointer(field['path'], limits) || (field['scalarListLayout'] !== undefined
410
+ && field['scalarListLayout'] !== 'inline' && field['scalarListLayout'] !== 'list'))
411
+ return null;
412
+ result.push({ label: field['label'], path: field['path'],
413
+ ...(field['scalarListLayout'] ? { scalarListLayout: field['scalarListLayout'] } : {}) });
414
+ }
415
+ return result;
416
+ }
417
+ function parseColumns(value, limits) {
418
+ if (!Array.isArray(value) || value.length === 0 || value.length > 64)
419
+ return null;
420
+ const result = [];
421
+ for (const item of value) {
422
+ const column = record(item);
423
+ if (!column || !exactKeys(column, ['label', 'path']) || !text(column['label'])
424
+ || !pointer(column['path'], limits, true))
425
+ return null;
426
+ result.push({ label: column['label'], path: column['path'] });
427
+ }
428
+ return result;
429
+ }
430
+ function parseSections(value, limits, counter, depth = 0) {
431
+ if (!Array.isArray(value) || depth > limits.maxDepth || value.length > limits.maxSections)
432
+ return null;
433
+ const result = [];
434
+ const ids = new Set();
435
+ for (const item of value) {
436
+ if (++counter.count > limits.maxSections)
437
+ return null;
438
+ const section = record(item);
439
+ if (!section || !text(section['id'], 100) || !/^[A-Za-z0-9_-]+$/.test(section['id'])
440
+ || ids.has(section['id']) || !text(section['title']) || !pointer(section['path'], limits))
441
+ return null;
442
+ ids.add(section['id']);
443
+ const base = { id: section['id'], title: section['title'], path: section['path'] };
444
+ if (section['kind'] === 'fields') {
445
+ if (!exactKeys(section, ['id', 'title', 'kind', 'path', 'fields']))
446
+ return null;
447
+ const fields = parseFields(section['fields'], limits);
448
+ if (!fields)
449
+ return null;
450
+ result.push({ ...base, kind: 'fields', fields });
451
+ continue;
452
+ }
453
+ if (section['kind'] === 'value') {
454
+ if (!exactKeys(section, ['id', 'title', 'kind', 'path', 'scalarListLayout'])
455
+ || (section['scalarListLayout'] !== undefined && section['scalarListLayout'] !== 'inline'
456
+ && section['scalarListLayout'] !== 'list'))
457
+ return null;
458
+ result.push({ ...base, kind: 'value',
459
+ ...(section['scalarListLayout'] ? { scalarListLayout: section['scalarListLayout'] } : {}) });
460
+ continue;
461
+ }
462
+ if (section['kind'] === 'table') {
463
+ if (!exactKeys(section, ['id', 'title', 'kind', 'path', 'rows', 'blockType', 'columns'])
464
+ || !['dictionary', 'list', 'blocks'].includes(String(section['rows']))
465
+ || (section['blockType'] !== undefined && !text(section['blockType'], 100)))
466
+ return null;
467
+ const columns = parseColumns(section['columns'], limits);
468
+ if (!columns)
469
+ return null;
470
+ result.push({ ...base, kind: 'table', rows: section['rows'], columns,
471
+ ...(section['blockType'] ? { blockType: section['blockType'] } : {}) });
472
+ continue;
473
+ }
474
+ if (section['kind'] === 'entries' || section['kind'] === 'blocks') {
475
+ const blocks = section['kind'] === 'blocks';
476
+ if (!exactKeys(section, blocks
477
+ ? ['id', 'title', 'kind', 'path', 'blockType', 'presentation', 'allowLayoutSwitch', 'initiallyExpanded', 'entry']
478
+ : ['id', 'title', 'kind', 'path', 'presentation', 'allowLayoutSwitch', 'initiallyExpanded', 'entry']))
479
+ return null;
480
+ if (blocks && !text(section['blockType'], 100))
481
+ return null;
482
+ if (section['presentation'] !== undefined && section['presentation'] !== 'list' && section['presentation'] !== 'accordion')
483
+ return null;
484
+ if (section['allowLayoutSwitch'] !== undefined && typeof section['allowLayoutSwitch'] !== 'boolean')
485
+ return null;
486
+ if (section['initiallyExpanded'] !== undefined && !['first', 'all', 'none'].includes(String(section['initiallyExpanded'])))
487
+ return null;
488
+ let entry;
489
+ if (section['entry'] !== undefined) {
490
+ const rawEntry = record(section['entry']);
491
+ if (!rawEntry || !exactKeys(rawEntry, ['heading', 'sections']) || !text(rawEntry['heading'])
492
+ || !pointer(rawEntry['heading'], limits, true))
493
+ return null;
494
+ const nested = rawEntry['sections'] === undefined ? undefined
495
+ : parseSections(rawEntry['sections'], limits, counter, depth + 1);
496
+ if (rawEntry['sections'] !== undefined && !nested)
497
+ return null;
498
+ entry = { heading: rawEntry['heading'], ...(nested ? { sections: nested } : {}) };
499
+ }
500
+ const common = { ...base,
501
+ ...(section['presentation'] ? { presentation: section['presentation'] } : {}),
502
+ ...(section['allowLayoutSwitch'] !== undefined ? { allowLayoutSwitch: section['allowLayoutSwitch'] } : {}),
503
+ ...(section['initiallyExpanded'] ? { initiallyExpanded: section['initiallyExpanded'] } : {}),
504
+ ...(entry ? { entry } : {}) };
505
+ result.push(blocks ? { ...common, kind: 'blocks', blockType: section['blockType'] }
506
+ : { ...common, kind: 'entries' });
507
+ continue;
508
+ }
509
+ return null;
510
+ }
511
+ return result;
512
+ }
513
+ function validateViewConfig(input, limits) {
514
+ let bounded;
515
+ try {
516
+ bounded = effectiveLimits(limits);
517
+ }
518
+ catch (error) {
519
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', String(error))] };
520
+ }
521
+ const source = record(input);
522
+ if (!source || !exactKeys(source, ['version', 'rules']) || source['version'] !== 1
523
+ || !Array.isArray(source['rules']) || source['rules'].length > bounded.maxRules) {
524
+ return { ok: false, diagnostics: [diagnostic('invalid_config', 'Expected version 1 and a bounded rules array')] };
525
+ }
526
+ const rules = [];
527
+ const ids = new Set();
528
+ const counter = { count: 0 };
529
+ for (const item of source['rules']) {
530
+ const rule = record(item);
531
+ if (!rule || !exactKeys(rule, ['id', 'match', 'parser', 'title', 'sections']) || !text(rule['id'], 100)
532
+ || !/^[A-Za-z0-9_-]+$/.test(rule['id']) || ids.has(rule['id'])
533
+ || !safeGlob(rule['match'], bounded) || !['json', 'yaml', 'hcl'].includes(String(rule['parser']))) {
534
+ return { ok: false, diagnostics: [diagnostic('invalid_config', 'Invalid or duplicate view rule')] };
535
+ }
536
+ ids.add(rule['id']);
537
+ const title = rule['title'] === undefined ? undefined : parseTitle(rule['title'], bounded);
538
+ if (rule['title'] !== undefined && !title)
539
+ return { ok: false, diagnostics: [diagnostic('invalid_config', `Invalid title in ${rule['id']}`)] };
540
+ const sections = rule['sections'] === undefined ? undefined : parseSections(rule['sections'], bounded, counter);
541
+ if (rule['sections'] !== undefined && !sections)
542
+ return { ok: false, diagnostics: [diagnostic('invalid_config', `Invalid sections in ${rule['id']}`)] };
543
+ rules.push({ id: rule['id'], match: rule['match'], parser: rule['parser'],
544
+ ...(title ? { title } : {}), ...(sections ? { sections } : {}) });
545
+ }
546
+ return { ok: true, config: { version: 1, rules } };
547
+ }
548
+ function plain(node) {
549
+ switch (node.kind) {
550
+ case 'null': return null;
551
+ case 'boolean':
552
+ case 'string': return node.value;
553
+ case 'number': return node.value ?? Number(node.sourceText);
554
+ case 'array': return node.items.map(plain);
555
+ case 'object': return Object.fromEntries(node.entries.map(entry => [entry.key, plain(entry.value)]));
556
+ default: throw new Error('Configuration cannot contain HCL or expressions');
557
+ }
558
+ }
559
+ function parseViewConfigText(textSource, format, sourcePath, limits) {
560
+ let bounded;
561
+ try {
562
+ bounded = effectiveLimits(limits);
563
+ }
564
+ catch (error) {
565
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', String(error))] };
566
+ }
567
+ if (exceedsSourceLimit(textSource, bounded.maxConfigBytes)) {
568
+ return { ok: false, diagnostics: [diagnostic('limit_exceeded', `View config exceeds ${bounded.maxConfigBytes} bytes`, undefined, sourcePath)] };
569
+ }
570
+ const parsed = format === 'json' ? parseJsonText(textSource, { ...bounded, maxSourceBytes: bounded.maxConfigBytes })
571
+ : parseYamlText(textSource, { ...bounded, maxSourceBytes: bounded.maxConfigBytes });
572
+ if (!parsed.ok)
573
+ return { ok: false, diagnostics: parsed.diagnostics.map(item => ({ ...item, source: sourcePath })) };
574
+ try {
575
+ return validateViewConfig(plain(parsed.document.root), bounded);
576
+ }
577
+ catch (error) {
578
+ return { ok: false, diagnostics: [diagnostic('invalid_config', String(error), undefined, sourcePath)] };
579
+ }
580
+ }
581
+ function matchGlob(glob, path) {
582
+ if (path.length > 4_096)
583
+ return false;
584
+ const width = path.length + 1;
585
+ const table = new Uint8Array((glob.length + 1) * width);
586
+ const at = (pattern, position) => table[pattern * width + position];
587
+ table[glob.length * width + path.length] = 1;
588
+ for (let pattern = glob.length - 1; pattern >= 0; pattern--) {
589
+ let consumedDirectory = false;
590
+ for (let position = path.length; position >= 0; position--) {
591
+ const character = glob[pattern];
592
+ const remaining = position < path.length;
593
+ let matches = false;
594
+ if (character === '*' && glob[pattern + 1] === '*' && glob[pattern + 2] === '/') {
595
+ consumedDirectory = remaining && ((path[position] === '/' && !!at(pattern + 3, position + 1)) || consumedDirectory);
596
+ matches = !!at(pattern + 3, position) || consumedDirectory;
597
+ }
598
+ else if (character === '*' && glob[pattern + 1] === '*') {
599
+ matches = !!at(pattern + 2, position) || (remaining && !!at(pattern, position + 1));
600
+ }
601
+ else if (character === '*') {
602
+ matches = !!at(pattern + 1, position) || (remaining && path[position] !== '/' && !!at(pattern, position + 1));
603
+ }
604
+ else if (character === '?') {
605
+ matches = remaining && path[position] !== '/' && !!at(pattern + 1, position + 1);
606
+ }
607
+ else {
608
+ matches = remaining && character === path[position] && !!at(pattern + 1, position + 1);
609
+ }
610
+ table[pattern * width + position] = matches ? 1 : 0;
611
+ }
612
+ }
613
+ return !!at(0, 0);
614
+ }
615
+ function resolveViewRule(options) {
616
+ let bounded;
617
+ try {
618
+ bounded = effectiveLimits(options.limits);
619
+ }
620
+ catch (error) {
621
+ return { status: 'error', diagnostics: [diagnostic('limit_exceeded', String(error))] };
622
+ }
623
+ const pathSegments = options.repositoryPath.split('/');
624
+ if (options.repositoryPath.length > 4_096 || options.repositoryPath.startsWith('/')
625
+ || options.repositoryPath.includes('\\')
626
+ || [...options.repositoryPath].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)
627
+ || pathSegments.some(segment => segment === '.' || segment === '..' || segment === '')) {
628
+ return { status: 'error', diagnostics: [diagnostic('invalid_config', 'Invalid repository path for view resolution')] };
629
+ }
630
+ const limits = options.limits;
631
+ const merged = [];
632
+ const append = (rule) => {
633
+ const previous = merged.findIndex(candidate => candidate.id === rule.id);
634
+ if (previous >= 0)
635
+ merged.splice(previous, 1);
636
+ merged.push(rule);
637
+ };
638
+ if (options.builtInRules?.length) {
639
+ const checked = validateViewConfig({ version: 1, rules: options.builtInRules }, limits);
640
+ if (!checked.ok)
641
+ return { status: 'error', diagnostics: checked.diagnostics };
642
+ checked.config.rules.forEach(append);
643
+ if (merged.length > bounded.maxRules) {
644
+ return { status: 'error', diagnostics: [diagnostic('limit_exceeded', 'Too many combined view rules')] };
645
+ }
646
+ }
647
+ const levels = new Set();
648
+ for (const document of options.orderedDocuments) {
649
+ const level = document.sourcePath.replace(/\/[^/]+$/, '');
650
+ if (levels.has(level))
651
+ return { status: 'error', diagnostics: [diagnostic('invalid_config', `Multiple view config files at ${level}`)] };
652
+ levels.add(level);
653
+ const checked = validateViewConfig(document.config, limits);
654
+ if (!checked.ok)
655
+ return { status: 'error', diagnostics: checked.diagnostics.map(item => ({ ...item, source: document.sourcePath })) };
656
+ checked.config.rules.forEach(append);
657
+ if (merged.length > bounded.maxRules) {
658
+ return { status: 'error', diagnostics: [diagnostic('limit_exceeded', 'Too many combined view rules')] };
659
+ }
660
+ }
661
+ const path = options.repositoryPath.replace(/^\/+/, '');
662
+ let matchingWork = 0;
663
+ for (let index = merged.length - 1; index >= 0; index--) {
664
+ matchingWork += (merged[index].match.length + 1) * (path.length + 1);
665
+ if (matchingWork > 8_000_000) {
666
+ return { status: 'error', diagnostics: [diagnostic('limit_exceeded', 'View rule matching work limit exceeded')] };
667
+ }
668
+ if (matchGlob(merged[index].match, path))
669
+ return { status: 'matched', parser: merged[index].parser, view: merged[index] };
670
+ }
671
+ const suffix = path.toLowerCase().split('.').pop();
672
+ if (suffix === 'json' || suffix === 'yaml' || suffix === 'hcl')
673
+ return { status: 'generic', parser: suffix };
674
+ if (suffix === 'yml')
675
+ return { status: 'generic', parser: 'yaml' };
676
+ return { status: 'unsupported', diagnostics: [diagnostic('unsupported_format', 'No JSON, YAML, or HCL view applies')] };
677
+ }
678
+
679
+ function parseStructuredText(sourceText, format, limits) {
680
+ if (format === 'json')
681
+ return Promise.resolve(parseJsonText(sourceText, limits));
682
+ if (format === 'yaml')
683
+ return Promise.resolve(parseYamlText(sourceText, limits));
684
+ return parseHclText(sourceText, limits);
685
+ }
686
+
687
+ /**
688
+ * Generated bundle index. Do not edit.
689
+ */
690
+
691
+ export { DEFAULT_LIMITS, parseHclText, parseJsonText, parseRelativePointer, parseStructuredText, parseViewConfigText, parseYamlText, resolveViewRule, selectRelative, selectViewPath, validateViewConfig };
692
+ //# sourceMappingURL=sneat-structured-data-core.mjs.map