scon-notation 1.0.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/LICENSE +21 -0
- package/README.md +91 -0
- package/package.json +45 -0
- package/src/decoder.js +761 -0
- package/src/encoder.js +506 -0
- package/src/minifier.js +143 -0
- package/src/schema-registry.js +204 -0
- package/src/scon.js +121 -0
- package/src/tree-hash.js +227 -0
- package/src/validator.js +131 -0
package/src/encoder.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// src/encoder.js
|
|
2
|
+
// Port of bX\Scon\Encoder — JS data → SCON string
|
|
3
|
+
|
|
4
|
+
import { SchemaRegistry } from './schema-registry.js';
|
|
5
|
+
|
|
6
|
+
let _TreeHash = null;
|
|
7
|
+
async function getTreeHash() {
|
|
8
|
+
if (!_TreeHash) {
|
|
9
|
+
const mod = await import('./tree-hash.js');
|
|
10
|
+
_TreeHash = mod.TreeHash;
|
|
11
|
+
}
|
|
12
|
+
return _TreeHash;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const COMMA = ',';
|
|
16
|
+
const TAB = '\t';
|
|
17
|
+
const PIPE = '|';
|
|
18
|
+
const DQUOTE = '"';
|
|
19
|
+
const COLON = ':';
|
|
20
|
+
const OBRACK = '[';
|
|
21
|
+
const CBRACK = ']';
|
|
22
|
+
const OBRACE = '{';
|
|
23
|
+
const CBRACE = '}';
|
|
24
|
+
const LIST_PREFIX = '- ';
|
|
25
|
+
const HEADER = '#!scon/1.0';
|
|
26
|
+
|
|
27
|
+
export class Encoder {
|
|
28
|
+
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.indentSize = options.indent ?? 1;
|
|
31
|
+
this.delimiter = options.delimiter ?? COMMA;
|
|
32
|
+
this.mode = options.mode ?? 'warn';
|
|
33
|
+
this.enforce = options.enforce ?? null;
|
|
34
|
+
this.autoExtract = options.autoExtract ?? false;
|
|
35
|
+
this.header = options.header ?? false; // header off by default
|
|
36
|
+
this.registry = new SchemaRegistry();
|
|
37
|
+
this.warnings = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getRegistry() { return this.registry; }
|
|
41
|
+
getWarnings() { return this.warnings; }
|
|
42
|
+
|
|
43
|
+
// Encode JS data to SCON string
|
|
44
|
+
// Returns string if sync (no autoExtract), Promise<string> if autoExtract
|
|
45
|
+
encode(data, schemas = {}, responses = {}, security = {}) {
|
|
46
|
+
// Register explicit schemas
|
|
47
|
+
for (const [name, def] of Object.entries(schemas)) {
|
|
48
|
+
this.registry.register('s', name, def);
|
|
49
|
+
}
|
|
50
|
+
for (const [name, def] of Object.entries(responses)) {
|
|
51
|
+
this.registry.register('r', name, def);
|
|
52
|
+
}
|
|
53
|
+
for (const [name, def] of Object.entries(security)) {
|
|
54
|
+
this.registry.register('sec', name, def);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (this.autoExtract && typeof data === 'object' && data !== null) {
|
|
58
|
+
// async path — needs TreeHash
|
|
59
|
+
return this._encodeAsync(data);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return this._encodeFinal(data);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async _encodeAsync(data) {
|
|
66
|
+
await this._detectRepeatedSchemas(data);
|
|
67
|
+
return this._encodeFinal(data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_encodeFinal(data) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
|
|
73
|
+
// Header (optional, off by default)
|
|
74
|
+
if (this.header) {
|
|
75
|
+
lines.push(HEADER);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Directives
|
|
79
|
+
if (this.mode !== 'warn') {
|
|
80
|
+
lines.push(`@@${this.mode}`);
|
|
81
|
+
}
|
|
82
|
+
if (this.enforce !== null) {
|
|
83
|
+
lines.push(`@@enforce(${this.enforce})`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Schema definitions
|
|
87
|
+
const allSchemas = this.registry.getAll('s');
|
|
88
|
+
const schemaKeys = Object.keys(allSchemas);
|
|
89
|
+
if (schemaKeys.length > 0) {
|
|
90
|
+
if (lines.length > 0) lines.push('');
|
|
91
|
+
for (const name of schemaKeys) {
|
|
92
|
+
lines.push(`s:${name} ${this._encodeInline(allSchemas[name])}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Response group definitions
|
|
97
|
+
const allResponses = this.registry.getAll('r');
|
|
98
|
+
const responseKeys = Object.keys(allResponses);
|
|
99
|
+
if (responseKeys.length > 0) {
|
|
100
|
+
if (lines.length > 0) lines.push('');
|
|
101
|
+
for (const name of responseKeys) {
|
|
102
|
+
lines.push(`r:${name} ${this._encodeResponseGroup(allResponses[name])}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Security group definitions
|
|
107
|
+
const allSecurity = this.registry.getAll('sec');
|
|
108
|
+
const securityKeys = Object.keys(allSecurity);
|
|
109
|
+
if (securityKeys.length > 0) {
|
|
110
|
+
if (lines.length > 0) lines.push('');
|
|
111
|
+
for (const name of securityKeys) {
|
|
112
|
+
lines.push(`sec:${name} ${this._encodeInline(allSecurity[name])}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Separator before body
|
|
117
|
+
if (schemaKeys.length > 0 || responseKeys.length > 0 || securityKeys.length > 0) {
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Body — explicit {} for empty object (preserves type distinction vs [])
|
|
122
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data) && Object.keys(data).length === 0) {
|
|
123
|
+
lines.push('{}');
|
|
124
|
+
} else {
|
|
125
|
+
for (const line of this._encodeValue(data, 0)) {
|
|
126
|
+
lines.push(line);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Prune orphan schemas
|
|
131
|
+
if (this.autoExtract && schemaKeys.length > 0) {
|
|
132
|
+
const body = lines.join('\n');
|
|
133
|
+
const pruned = lines.filter(line => {
|
|
134
|
+
const m = line.match(/^s:(\S+)\s/);
|
|
135
|
+
if (m && !body.includes('@s:' + m[1])) return false;
|
|
136
|
+
return true;
|
|
137
|
+
});
|
|
138
|
+
return pruned.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Response group encoding ---
|
|
145
|
+
|
|
146
|
+
_encodeResponseGroup(group) {
|
|
147
|
+
const parts = [];
|
|
148
|
+
for (const [code, def] of Object.entries(group)) {
|
|
149
|
+
const desc = def.description || '';
|
|
150
|
+
const schemaRef = def.schemaRef || null;
|
|
151
|
+
let part = `${code}:${this._encodeString(desc)}`;
|
|
152
|
+
if (schemaRef !== null) {
|
|
153
|
+
part += ` @s:${schemaRef}`;
|
|
154
|
+
if (def.overrides && Object.keys(def.overrides).length > 0) {
|
|
155
|
+
part += ' ' + this._encodeInline(def.overrides);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
parts.push(part);
|
|
159
|
+
}
|
|
160
|
+
return OBRACE + parts.join(', ') + CBRACE;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Inline encoding ---
|
|
164
|
+
|
|
165
|
+
_encodeInline(data) {
|
|
166
|
+
if (this._isPrimitive(data)) {
|
|
167
|
+
return this._encodePrimitive(data);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (Array.isArray(data)) {
|
|
171
|
+
const items = data.map(v => this._encodeInline(v));
|
|
172
|
+
return OBRACK + items.join(', ') + CBRACK;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (typeof data === 'object' && data !== null) {
|
|
176
|
+
const parts = [];
|
|
177
|
+
for (const [key, val] of Object.entries(data)) {
|
|
178
|
+
parts.push(`${this._encodeKey(key)}:${this._encodeInline(val)}`);
|
|
179
|
+
}
|
|
180
|
+
return OBRACE + parts.join(', ') + CBRACE;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Value encoding with indentation ---
|
|
187
|
+
|
|
188
|
+
*_encodeValue(value, depth) {
|
|
189
|
+
if (this._isPrimitive(value)) {
|
|
190
|
+
const encoded = this._encodePrimitive(value);
|
|
191
|
+
if (encoded !== '') yield encoded;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
yield* this._encodeArray(null, value, depth);
|
|
197
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
198
|
+
yield* this._encodeObject(value, depth);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
*_encodeObject(obj, depth) {
|
|
203
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
204
|
+
if (this._isPrimitive(val)) {
|
|
205
|
+
yield this._indented(depth, `${this._encodeKey(key)}: ${this._encodePrimitive(val)}`);
|
|
206
|
+
} else if (Array.isArray(val)) {
|
|
207
|
+
yield* this._encodeArray(key, val, depth);
|
|
208
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
209
|
+
const schemaRef = this._findMatchingSchema(val);
|
|
210
|
+
if (schemaRef !== null) {
|
|
211
|
+
yield this._indented(depth, `${this._encodeKey(key)}: @s:${schemaRef}`);
|
|
212
|
+
} else {
|
|
213
|
+
yield this._indented(depth, `${this._encodeKey(key)}:`);
|
|
214
|
+
if (Object.keys(val).length > 0) {
|
|
215
|
+
yield* this._encodeObject(val, depth + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
*_encodeArray(key, array, depth) {
|
|
223
|
+
const length = array.length;
|
|
224
|
+
|
|
225
|
+
if (length === 0) {
|
|
226
|
+
if (key !== null) {
|
|
227
|
+
yield this._indented(depth, `${this._encodeKey(key)}: []`);
|
|
228
|
+
} else {
|
|
229
|
+
yield this._indented(depth, '[]');
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Array of primitives
|
|
235
|
+
if (this._isArrayOfPrimitives(array)) {
|
|
236
|
+
const header = this._formatHeader(length, key);
|
|
237
|
+
const values = this._joinPrimitives(array);
|
|
238
|
+
yield this._indented(depth, `${header} ${values}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Array of objects (tabular)
|
|
243
|
+
if (this._isArrayOfObjects(array)) {
|
|
244
|
+
const fields = this._extractTabularHeader(array);
|
|
245
|
+
if (fields !== null) {
|
|
246
|
+
yield* this._encodeTabularArray(key, array, fields, depth);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Mixed / expanded array
|
|
252
|
+
yield* this._encodeMixedArray(key, array, depth);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
*_encodeTabularArray(key, rows, fields, depth) {
|
|
256
|
+
const header = this._formatHeader(rows.length, key, fields);
|
|
257
|
+
yield this._indented(depth, header);
|
|
258
|
+
|
|
259
|
+
for (const row of rows) {
|
|
260
|
+
const values = fields.map(f => row[f] ?? null);
|
|
261
|
+
yield this._indented(depth + 1, this._joinPrimitives(values));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
*_encodeMixedArray(key, items, depth) {
|
|
266
|
+
const header = this._formatHeader(items.length, key);
|
|
267
|
+
yield this._indented(depth, header);
|
|
268
|
+
|
|
269
|
+
for (const item of items) {
|
|
270
|
+
if (this._isPrimitive(item)) {
|
|
271
|
+
yield this._listItem(depth + 1, this._encodePrimitive(item));
|
|
272
|
+
} else if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
|
273
|
+
const schemaRef = this._findMatchingSchema(item);
|
|
274
|
+
if (schemaRef !== null) {
|
|
275
|
+
yield this._listItem(depth + 1, `@s:${schemaRef}`);
|
|
276
|
+
} else {
|
|
277
|
+
yield* this._encodeObjectAsListItem(item, depth + 1);
|
|
278
|
+
}
|
|
279
|
+
} else if (Array.isArray(item)) {
|
|
280
|
+
if (item.length === 0) {
|
|
281
|
+
yield this._listItem(depth + 1, '[]');
|
|
282
|
+
} else if (this._isArrayOfPrimitives(item)) {
|
|
283
|
+
const subHeader = this._formatHeader(item.length, null);
|
|
284
|
+
const values = this._joinPrimitives(item);
|
|
285
|
+
yield this._listItem(depth + 1, `${subHeader} ${values}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
*_encodeObjectAsListItem(obj, depth) {
|
|
292
|
+
const keys = Object.keys(obj);
|
|
293
|
+
if (keys.length === 0) {
|
|
294
|
+
yield this._indented(depth, LIST_PREFIX);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const firstKey = keys[0];
|
|
299
|
+
const firstVal = obj[firstKey];
|
|
300
|
+
const rest = {};
|
|
301
|
+
for (let i = 1; i < keys.length; i++) {
|
|
302
|
+
rest[keys[i]] = obj[keys[i]];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const encodedKey = this._encodeKey(firstKey);
|
|
306
|
+
|
|
307
|
+
if (this._isPrimitive(firstVal)) {
|
|
308
|
+
yield this._listItem(depth, `${encodedKey}: ${this._encodePrimitive(firstVal)}`);
|
|
309
|
+
} else if (Array.isArray(firstVal) && firstVal.length === 0) {
|
|
310
|
+
yield this._listItem(depth, `${encodedKey}: []`);
|
|
311
|
+
} else if (Array.isArray(firstVal) && this._isArrayOfPrimitives(firstVal)) {
|
|
312
|
+
const hdr = this._formatHeader(firstVal.length, null);
|
|
313
|
+
const vals = this._joinPrimitives(firstVal);
|
|
314
|
+
yield this._listItem(depth, `${encodedKey}${hdr} ${vals}`);
|
|
315
|
+
} else if (typeof firstVal === 'object' && firstVal !== null) {
|
|
316
|
+
yield this._listItem(depth, `${encodedKey}:`);
|
|
317
|
+
yield* this._encodeObject(firstVal, depth + 2);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (Object.keys(rest).length > 0) {
|
|
321
|
+
yield* this._encodeObject(rest, depth + 1);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- Schema matching ---
|
|
326
|
+
|
|
327
|
+
_findMatchingSchema(data) {
|
|
328
|
+
const allSchemas = this.registry.getAll('s');
|
|
329
|
+
for (const [name, def] of Object.entries(allSchemas)) {
|
|
330
|
+
if (this._deepEqual(data, def)) return name;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Order-sensitive deep equality (matches PHP === behavior)
|
|
336
|
+
_deepEqual(a, b) {
|
|
337
|
+
if (a === b) return true;
|
|
338
|
+
if (typeof a !== typeof b) return false;
|
|
339
|
+
if (a === null || b === null) return a === b;
|
|
340
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
341
|
+
if (Array.isArray(a)) {
|
|
342
|
+
if (a.length !== b.length) return false;
|
|
343
|
+
return a.every((v, i) => this._deepEqual(v, b[i]));
|
|
344
|
+
}
|
|
345
|
+
if (typeof a === 'object') {
|
|
346
|
+
const keysA = Object.keys(a);
|
|
347
|
+
const keysB = Object.keys(b);
|
|
348
|
+
if (keysA.length !== keysB.length) return false;
|
|
349
|
+
// Key order must match (PHP === is order-sensitive for assoc arrays)
|
|
350
|
+
for (let i = 0; i < keysA.length; i++) {
|
|
351
|
+
if (keysA[i] !== keysB[i]) return false;
|
|
352
|
+
}
|
|
353
|
+
return keysA.every(k => this._deepEqual(a[k], b[k]));
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// --- Auto-extract repeated schemas via TreeHash ---
|
|
359
|
+
|
|
360
|
+
async _detectRepeatedSchemas(data) {
|
|
361
|
+
const TreeHash = await getTreeHash();
|
|
362
|
+
const result = await TreeHash.hashTree(data, '', 2, false);
|
|
363
|
+
|
|
364
|
+
for (const entry of Object.values(result.index)) {
|
|
365
|
+
if (entry.count >= 2) {
|
|
366
|
+
const name = await this._generateSchemaName(entry.path);
|
|
367
|
+
this.registry.register('s', name, entry.data);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async _generateSchemaName(path) {
|
|
373
|
+
let parts = path.replace(/^\./, '').split('.');
|
|
374
|
+
// Strip list indices from the end
|
|
375
|
+
while (parts.length > 0 && /^\[\d+\]$/.test(parts[parts.length - 1])) {
|
|
376
|
+
parts.pop();
|
|
377
|
+
}
|
|
378
|
+
let last = parts[parts.length - 1] || '';
|
|
379
|
+
last = last.replace(/properties|content|application\/json|schema/g, '').trim().replace(/^\.+|\.+$/g, '');
|
|
380
|
+
if (!last) {
|
|
381
|
+
// Use xxh128 matching PHP substr(hash('xxh128', $path), 0, 6)
|
|
382
|
+
const TreeHash = await getTreeHash();
|
|
383
|
+
const hash = await TreeHash.hash(path);
|
|
384
|
+
last = 'auto_' + hash.slice(0, 6);
|
|
385
|
+
}
|
|
386
|
+
return last;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- Formatting helpers ---
|
|
390
|
+
|
|
391
|
+
_formatHeader(length, key = null, fields = null) {
|
|
392
|
+
let header = '';
|
|
393
|
+
if (key !== null) {
|
|
394
|
+
header += this._encodeKey(key);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const delimSuffix = this.delimiter !== COMMA ? this.delimiter : '';
|
|
398
|
+
header += OBRACK + length + delimSuffix + CBRACK;
|
|
399
|
+
|
|
400
|
+
if (fields !== null) {
|
|
401
|
+
const qFields = fields.map(f => this._encodeKey(f));
|
|
402
|
+
header += OBRACE + qFields.join(this.delimiter) + CBRACE;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
header += COLON;
|
|
406
|
+
return header;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_extractTabularHeader(array) {
|
|
410
|
+
if (array.length === 0) return null;
|
|
411
|
+
const first = array[0];
|
|
412
|
+
if (typeof first !== 'object' || first === null || Array.isArray(first)) return null;
|
|
413
|
+
|
|
414
|
+
const firstKeys = Object.keys(first);
|
|
415
|
+
if (firstKeys.length === 0) return null;
|
|
416
|
+
|
|
417
|
+
for (const row of array) {
|
|
418
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row)) return null;
|
|
419
|
+
const rowKeys = Object.keys(row);
|
|
420
|
+
if (rowKeys.length !== firstKeys.length) return null;
|
|
421
|
+
for (const fk of firstKeys) {
|
|
422
|
+
if (!(fk in row)) return null;
|
|
423
|
+
if (!this._isPrimitive(row[fk])) return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return firstKeys;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- Primitive encoding ---
|
|
431
|
+
|
|
432
|
+
_encodePrimitive(value) {
|
|
433
|
+
if (value === null) return 'null';
|
|
434
|
+
if (value === true) return 'true';
|
|
435
|
+
if (value === false) return 'false';
|
|
436
|
+
if (typeof value === 'number') return String(value);
|
|
437
|
+
if (typeof value === 'string') return this._encodeString(value);
|
|
438
|
+
return '';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_encodeString(value) {
|
|
442
|
+
if (this._isSafeUnquoted(value)) return value;
|
|
443
|
+
if (value.length > 0 && (value[0] === '{' || value[0] === '[')) {
|
|
444
|
+
this.warnings.push(`String starts with '${value[0]}', possible incomplete structure: ${value.slice(0, 60)}`);
|
|
445
|
+
}
|
|
446
|
+
return DQUOTE + this._escapeString(value) + DQUOTE;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_encodeKey(key) {
|
|
450
|
+
if (this._isValidUnquotedKey(key)) return key;
|
|
451
|
+
return DQUOTE + this._escapeString(key) + DQUOTE;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_escapeString(str) {
|
|
455
|
+
let escaped = str.replace(/\\/g, '\\\\');
|
|
456
|
+
escaped = escaped.replace(/"/g, '\\"');
|
|
457
|
+
escaped = escaped.replace(/\n/g, '\\n');
|
|
458
|
+
escaped = escaped.replace(/\r/g, '\\r');
|
|
459
|
+
escaped = escaped.replace(/\t/g, '\\t');
|
|
460
|
+
escaped = escaped.replace(/;/g, '\\;');
|
|
461
|
+
return escaped;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_isSafeUnquoted(value) {
|
|
465
|
+
if (value === '') return false;
|
|
466
|
+
if (value === 'true' || value === 'false' || value === 'null') return false;
|
|
467
|
+
// Strict decimal check matching PHP is_numeric
|
|
468
|
+
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return false;
|
|
469
|
+
if (value.includes(this.delimiter)) return false;
|
|
470
|
+
if (/[\s:"\\;@#\{\[\]\}]/.test(value)) return false;
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
_isValidUnquotedKey(key) {
|
|
475
|
+
if (key === '') return false;
|
|
476
|
+
if (key[0] === '#') return false; // starts-with-# looks like comment
|
|
477
|
+
if (/[:\[\]{}"\\\s;@#,]/.test(key)) return false;
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
_joinPrimitives(values) {
|
|
482
|
+
return values.map(v => this._encodePrimitive(v)).join(this.delimiter + ' ');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
_isPrimitive(value) {
|
|
486
|
+
return value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_isArrayOfPrimitives(arr) {
|
|
490
|
+
if (!Array.isArray(arr)) return false;
|
|
491
|
+
return arr.every(item => this._isPrimitive(item));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
_isArrayOfObjects(arr) {
|
|
495
|
+
if (!Array.isArray(arr)) return false;
|
|
496
|
+
return arr.every(item => typeof item === 'object' && item !== null && !Array.isArray(item));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
_indented(depth, content) {
|
|
500
|
+
return ' '.repeat(this.indentSize * depth) + content;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_listItem(depth, content) {
|
|
504
|
+
return this._indented(depth, LIST_PREFIX + content);
|
|
505
|
+
}
|
|
506
|
+
}
|
package/src/minifier.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// src/minifier.js
|
|
2
|
+
// Port of bX\Scon\Minifier — SCON minification and expansion
|
|
3
|
+
|
|
4
|
+
export class Minifier {
|
|
5
|
+
|
|
6
|
+
// Minify SCON to single line
|
|
7
|
+
// Rules:
|
|
8
|
+
// ; = newline (same depth)
|
|
9
|
+
// N semicolons (N>=2) = dedent (N-1) levels
|
|
10
|
+
// ;; = dedent 1, ;;; = dedent 2, ;;;; = dedent 3, etc.
|
|
11
|
+
// Strings with ; must be quoted (\; escape)
|
|
12
|
+
static minify(scon) {
|
|
13
|
+
const lines = scon.split('\n');
|
|
14
|
+
let result = '';
|
|
15
|
+
let prevDepth = 0;
|
|
16
|
+
let isFirst = true;
|
|
17
|
+
|
|
18
|
+
// Auto-detect indent from first indented line
|
|
19
|
+
let indent = 1;
|
|
20
|
+
const indentMatch = scon.match(/\n( +)\S/);
|
|
21
|
+
if (indentMatch) indent = indentMatch[1].length;
|
|
22
|
+
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
|
|
26
|
+
// Skip empty lines and comments
|
|
27
|
+
if (trimmed === '' || trimmed[0] === '#') {
|
|
28
|
+
// Preserve header
|
|
29
|
+
if (trimmed.startsWith('#!scon/')) {
|
|
30
|
+
result += trimmed + ';';
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const depth = Minifier._calculateDepth(line, indent);
|
|
36
|
+
|
|
37
|
+
if (!isFirst) {
|
|
38
|
+
const diff = prevDepth - depth;
|
|
39
|
+
if (diff >= 2) {
|
|
40
|
+
result += ';'.repeat(diff + 1);
|
|
41
|
+
} else if (diff === 1) {
|
|
42
|
+
result += ';;';
|
|
43
|
+
} else {
|
|
44
|
+
result += ';';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
result += trimmed;
|
|
49
|
+
prevDepth = depth;
|
|
50
|
+
|
|
51
|
+
// Scope openers: increase expected depth for children
|
|
52
|
+
if (/:$/.test(trimmed)) {
|
|
53
|
+
prevDepth = depth + 1;
|
|
54
|
+
}
|
|
55
|
+
// List item (- key: val) children are indented 1 level deeper
|
|
56
|
+
if (/^- /.test(trimmed)) {
|
|
57
|
+
prevDepth = depth + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isFirst = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Expand minified SCON to indented format
|
|
67
|
+
static expand(minified, indent = 1) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
let depth = 0;
|
|
70
|
+
let buffer = '';
|
|
71
|
+
let inQuotes = false;
|
|
72
|
+
const len = minified.length;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < len; i++) {
|
|
75
|
+
const char = minified[i];
|
|
76
|
+
|
|
77
|
+
// Handle escape in quotes
|
|
78
|
+
if (char === '\\' && inQuotes && i + 1 < len) {
|
|
79
|
+
buffer += char + minified[i + 1];
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (char === '"') {
|
|
85
|
+
inQuotes = !inQuotes;
|
|
86
|
+
buffer += char;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (char === ';' && !inQuotes) {
|
|
91
|
+
// Count consecutive semicolons
|
|
92
|
+
let semiCount = 1;
|
|
93
|
+
while (i + 1 < len && minified[i + 1] === ';') {
|
|
94
|
+
semiCount++;
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Emit current buffer
|
|
99
|
+
const trimmed = buffer.trim();
|
|
100
|
+
if (trimmed !== '') {
|
|
101
|
+
lines.push(' '.repeat(indent * depth) + trimmed);
|
|
102
|
+
|
|
103
|
+
// Scope openers: increase depth for children
|
|
104
|
+
if (/:$/.test(trimmed) && !/:\s*\S/.test(trimmed)) {
|
|
105
|
+
depth++;
|
|
106
|
+
}
|
|
107
|
+
// List item children are indented 1 level deeper
|
|
108
|
+
if (/^- /.test(trimmed)) {
|
|
109
|
+
depth++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
buffer = '';
|
|
114
|
+
|
|
115
|
+
// Apply dedent: N semicolons = dedent (N-1) levels
|
|
116
|
+
if (semiCount >= 2) {
|
|
117
|
+
depth = Math.max(0, depth - (semiCount - 1));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
buffer += char;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Last buffer
|
|
127
|
+
const trimmed = buffer.trim();
|
|
128
|
+
if (trimmed !== '') {
|
|
129
|
+
lines.push(' '.repeat(indent * depth) + trimmed);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static _calculateDepth(line, indent = 1) {
|
|
136
|
+
let spaces = 0;
|
|
137
|
+
for (let i = 0; i < line.length; i++) {
|
|
138
|
+
if (line[i] === ' ') spaces++;
|
|
139
|
+
else break;
|
|
140
|
+
}
|
|
141
|
+
return indent > 0 ? Math.floor(spaces / indent) : 0;
|
|
142
|
+
}
|
|
143
|
+
}
|