toon-formatter 2.0.1 → 2.1.1

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/src/utils.js CHANGED
@@ -11,11 +11,28 @@ export function encodeXmlReservedChars(rawXmlString) {
11
11
  if (typeof rawXmlString !== 'string') {
12
12
  return '';
13
13
  }
14
+ // Replace & with & but not if it's already an entity
15
+ return rawXmlString.replace(/&(?!#|\w+;)/g, '&');
16
+ }
17
+
18
+ /**
19
+ * Sanitizes a string for use as an XML tag name.
20
+ * @param {string} name
21
+ * @returns {string} Sanitized name
22
+ */
23
+ export function sanitizeTagName(name) {
24
+ if (typeof name !== 'string' || !name) {
25
+ return '_';
26
+ }
14
27
 
15
- let encodedString = rawXmlString;
16
- const ampersandRegex = /&(?!#|\w+;)/g;
17
- encodedString = encodedString.replace(ampersandRegex, '&');
18
- return encodedString;
28
+ let sanitized = name;
29
+ // If name starts with non-letter/underscore (e.g. digit), prepend underscore
30
+ if (/^[^a-zA-Z_]/.test(sanitized)) {
31
+ sanitized = '_' + sanitized;
32
+ }
33
+
34
+ // Replace invalid chars with underscore
35
+ return sanitized.replace(/[^a-zA-Z0-9_.]/g, '_');
19
36
  }
20
37
 
21
38
  /**
@@ -56,10 +73,16 @@ export function parseValue(val) {
56
73
  if (val === 'null') return null;
57
74
  if (val === '') return ""; // Empty string
58
75
 
59
- // Number check: simplified, can be improved
60
- if (!isNaN(Number(val)) && val !== '' && !val.startsWith('0') && val !== '0') return Number(val);
76
+ // Number check
61
77
  if (val === '0') return 0;
62
- if (val.match(/^-?0\./)) return Number(val); // 0.5, -0.5
78
+
79
+ // If it starts with 0 but is not a decimal, it's a string (e.g. "0123")
80
+ if (val.startsWith('0') && !val.startsWith('0.') && val.length > 1) {
81
+ // String
82
+ } else {
83
+ const num = Number(val);
84
+ if (!isNaN(num) && val !== '') return num;
85
+ }
63
86
 
64
87
  // String unquoting
65
88
  if (val.startsWith('"') && val.endsWith('"')) {
@@ -76,8 +99,10 @@ export function parseValue(val) {
76
99
  */
77
100
  export function formatValue(v) {
78
101
  if (v === null) return "null";
102
+ if (v === true) return "true";
103
+ if (v === false) return "false";
79
104
  if (typeof v === "string") return `"${v.replace(/"/g, '\\"')}"`;
80
- return v; // number, boolean
105
+ return String(v);
81
106
  }
82
107
 
83
108
  /**
@@ -88,62 +113,84 @@ export function formatValue(v) {
88
113
  export function extractJsonFromString(text) {
89
114
  if (!text || typeof text !== 'string') return null;
90
115
 
91
- let startIndex = -1;
116
+ let searchStart = 0;
92
117
 
93
- // Find first potential start
94
- for (let i = 0; i < text.length; i++) {
95
- if (text[i] === '{' || text[i] === '[') {
96
- // Ignore if preceded by non-whitespace (e.g. key[2])
97
- if (i > 0 && /\S/.test(text[i - 1])) {
98
- continue;
118
+ while (searchStart < text.length) {
119
+ let startIndex = -1;
120
+
121
+ // Find first potential start
122
+ for (let i = searchStart; i < text.length; i++) {
123
+ if (text[i] === '{' || text[i] === '[') {
124
+ // Ignore if preceded by non-whitespace (e.g. key[2]),
125
+ // unless it's a closing bracket/brace or XML tag end
126
+ if (i > 0 && /\S/.test(text[i - 1]) && !/[\}\]>]/.test(text[i - 1])) {
127
+ continue;
128
+ }
129
+ startIndex = i;
130
+ break;
99
131
  }
100
- startIndex = i;
101
- break;
102
132
  }
103
- }
104
133
 
105
- if (startIndex === -1) return null;
134
+ if (startIndex === -1) return null;
106
135
 
107
- let balance = 0;
108
- let inQuote = false;
109
- let escape = false;
136
+ let balance = 0;
137
+ let inQuote = false;
138
+ let escape = false;
110
139
 
111
- for (let i = startIndex; i < text.length; i++) {
112
- const char = text[i];
140
+ for (let i = startIndex; i < text.length; i++) {
141
+ const char = text[i];
113
142
 
114
- if (escape) {
115
- escape = false;
116
- continue;
117
- }
118
-
119
- if (char === '\\') {
120
- escape = true;
121
- continue;
122
- }
143
+ if (escape) {
144
+ escape = false;
145
+ continue;
146
+ }
123
147
 
124
- if (char === '"') {
125
- inQuote = !inQuote;
126
- continue;
127
- }
148
+ if (char === '\\') {
149
+ escape = true;
150
+ continue;
151
+ }
128
152
 
129
- if (!inQuote) {
130
- if (char === '{' || char === '[') {
131
- balance++;
132
- } else if (char === '}' || char === ']') {
133
- balance--;
153
+ if (char === '"') {
154
+ inQuote = !inQuote;
155
+ continue;
134
156
  }
135
157
 
136
- if (balance === 0) {
137
- // Potential end
138
- const candidate = text.substring(startIndex, i + 1);
139
- try {
140
- JSON.parse(candidate);
141
- return candidate;
142
- } catch (e) {
143
- // Continue scanning if parse fails
158
+ if (!inQuote) {
159
+ if (char === '{' || char === '[') {
160
+ balance++;
161
+ } else if (char === '}' || char === ']') {
162
+ balance--;
163
+ }
164
+
165
+ if (balance === 0) {
166
+ const candidate = text.substring(startIndex, i + 1);
167
+
168
+
169
+ // Avoid matching TOON arrays (e.g. [3]: 1, 2, 3)
170
+ if (/^\s*\[\d+\]/.test(candidate)) {
171
+ searchStart = i + 1;
172
+ startIndex = -1;
173
+ break; // Break inner for loop, restart while loop from searchStart
174
+ }
175
+
176
+ try {
177
+ JSON.parse(candidate);
178
+ return candidate;
179
+ } catch (e) {
180
+ // If balanced but not valid JSON (like {id,name}),
181
+ // it's likely a false start. Abandon this startIndex and try next.
182
+ searchStart = startIndex + 1;
183
+ startIndex = -1;
184
+ break; // Break inner for loop, restart while loop from searchStart
185
+ }
144
186
  }
145
187
  }
146
188
  }
189
+
190
+ if (startIndex !== -1) {
191
+ // Reached end without balancing for this startIndex
192
+ searchStart = startIndex + 1;
193
+ }
147
194
  }
148
195
 
149
196
  return null;
@@ -157,23 +204,22 @@ export function extractJsonFromString(text) {
157
204
  export function extractXmlFromString(text) {
158
205
  if (!text || typeof text !== 'string') return null;
159
206
 
160
- // Find first start tag
161
- const startTagRegex = /<([a-zA-Z0-9_:-]+)(?:\s[^>]*)?\>/;
207
+ // Find first start tag (including self-closing)
208
+ const startTagRegex = /<([a-zA-Z0-9_:-]+)(?:\s[^>]*)?\/?>/;
162
209
  const match = text.match(startTagRegex);
163
210
 
164
211
  if (!match) return null;
165
212
 
166
213
  const startIndex = match.index;
167
214
  const rootTagName = match[1];
168
-
169
215
  const fullMatch = match[0];
216
+
170
217
  if (fullMatch.endsWith('/>')) {
171
218
  return fullMatch;
172
219
  }
173
220
 
174
221
  let balance = 0;
175
-
176
- const tagRegex = /<\/?([a-zA-Z0-9_:-]+)(?:\s[^>]*)?\/?\>/g;
222
+ const tagRegex = /<\/?([a-zA-Z0-9_:-]+)(?:\s[^>]*)?\/?>/g;
177
223
  tagRegex.lastIndex = startIndex;
178
224
 
179
225
  let matchTag;
@@ -190,7 +236,7 @@ export function extractXmlFromString(text) {
190
236
  }
191
237
 
192
238
  if (balance === 0) {
193
- return text.substring(startIndex, matchTag.index + fullTag.length);
239
+ return text.substring(startIndex, tagRegex.lastIndex);
194
240
  }
195
241
  }
196
242
 
@@ -208,25 +254,53 @@ export function extractCsvFromString(text) {
208
254
  const lines = text.split('\n');
209
255
  let startLineIndex = -1;
210
256
 
211
- for (let i = 0; i < lines.length; i++) {
257
+ const isJsonLike = (line) => {
258
+ const trimmed = line.trim();
259
+ return /^"[^"]+"\s*:/.test(trimmed) || /^[\{\[]/.test(trimmed) || /^[\}\]],?$/.test(trimmed);
260
+ };
261
+ const isYamlLike = (line) => {
262
+ const trimmed = line.trim();
263
+ return /^- /.test(trimmed) || /^[^",]+:\s/.test(trimmed);
264
+ };
265
+ const isXmlLike = (line) => {
266
+ const trimmed = line.trim();
267
+ return trimmed.startsWith('<') && trimmed.includes('>');
268
+ };
269
+ const isToonStructure = (line) => {
270
+ const trimmed = line.trim();
271
+ return /^.*?\[\d+\].*:\s*$/.test(trimmed);
272
+ };
273
+
274
+ let i = 0;
275
+ while (i < lines.length) {
212
276
  const line = lines[i];
277
+
278
+ if (isToonStructure(line)) {
279
+ const countMatch = line.match(/\[(\d+)\]/);
280
+ const count = countMatch ? parseInt(countMatch[1], 10) : 0;
281
+ i += count + 1;
282
+ continue;
283
+ }
284
+
213
285
  const commaCount = (line.match(/,/g) || []).length;
214
286
  if (commaCount > 0) {
215
- startLineIndex = i;
216
- break;
287
+ if (!isJsonLike(line) && !isYamlLike(line) && !isXmlLike(line)) {
288
+ startLineIndex = i;
289
+ break;
290
+ }
217
291
  }
292
+ i++;
218
293
  }
219
294
 
220
295
  if (startLineIndex === -1) return null;
221
296
 
222
297
  const resultLines = [];
223
-
224
- for (let i = startLineIndex; i < lines.length; i++) {
225
- const line = lines[i];
298
+ for (let j = startLineIndex; j < lines.length; j++) {
299
+ const line = lines[j];
226
300
  if (line.trim() === '') continue;
227
301
 
228
302
  const commaCount = (line.match(/,/g) || []).length;
229
- if (commaCount === 0) {
303
+ if (commaCount === 0 || isJsonLike(line) || isYamlLike(line) || isXmlLike(line)) {
230
304
  break;
231
305
  }
232
306
  resultLines.push(line);
@@ -234,10 +308,134 @@ export function extractCsvFromString(text) {
234
308
 
235
309
  const result = resultLines.join('\n').trim();
236
310
 
237
- // Avoid matching TOON arrays (e.g. users[2]{id,name}:)
311
+ // Avoid matching TOON arrays
238
312
  if (/^\s*(\w+)?\[\d+\]/.test(result)) {
239
313
  return null;
240
314
  }
241
315
 
316
+ // Final check for JSON-like start
317
+ if (/^[\{\[]/.test(result)) {
318
+ return null;
319
+ }
320
+
321
+ return result;
322
+ }
323
+
324
+ /**
325
+ * Flattens a JSON object/list recursivey.
326
+ * @param {*} obj
327
+ * @param {string} prefix
328
+ * @param {Object} result
329
+ * @returns {Object}
330
+ */
331
+ export function flattenObject(obj, prefix = '', result = {}) {
332
+ if (obj === null || typeof obj === 'undefined') {
333
+ result[prefix] = null;
334
+ return result;
335
+ }
336
+
337
+ // Try parsing string if it looks like JSON
338
+ if (typeof obj === 'string') {
339
+ const trimmed = obj.trim();
340
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
341
+ try {
342
+ const parsed = JSON.parse(obj);
343
+ return flattenObject(parsed, prefix, result);
344
+ } catch (e) { }
345
+ }
346
+ result[prefix] = obj;
347
+ return result;
348
+ }
349
+
350
+ if (Array.isArray(obj)) {
351
+ for (let i = 0; i < obj.length; i++) {
352
+ const newKey = prefix ? `${prefix}.${i}` : `${i}`;
353
+ flattenObject(obj[i], newKey, result);
354
+ }
355
+ } else if (typeof obj === 'object') {
356
+ for (const key in obj) {
357
+ const newKey = prefix ? `${prefix}.${key}` : key;
358
+ flattenObject(obj[key], newKey, result);
359
+ }
360
+ } else {
361
+ result[prefix] = obj;
362
+ }
363
+
364
+ return result;
365
+ }
366
+
367
+ /**
368
+ * Unflattens a JSON object (reverses flattening).
369
+ * @param {Object} data
370
+ * @returns {Object}
371
+ */
372
+ export function unflattenObject(data) {
373
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
374
+ return data;
375
+ }
376
+
377
+ const hasDot = Object.keys(data).some(k => k.includes('.'));
378
+ if (!hasDot) return data;
379
+
380
+ const result = {};
381
+ for (const key in data) {
382
+ const parts = key.split('.');
383
+ let current = result;
384
+ for (let i = 0; i < parts.length - 1; i++) {
385
+ const part = parts[i];
386
+ if (!(part in current)) {
387
+ current[part] = {};
388
+ }
389
+ current = current[part];
390
+ }
391
+ current[parts[parts.length - 1]] = data[key];
392
+ }
242
393
  return result;
243
394
  }
395
+
396
+ /**
397
+ * Helper to build an XML tag string from a key and value object.
398
+ * @param {string} key
399
+ * @param {*} value
400
+ * @returns {string}
401
+ */
402
+ export function buildTag(key, value) {
403
+ const sanitizedKey = sanitizeTagName(key);
404
+
405
+ if (value === null || typeof value === 'undefined') {
406
+ return `<${sanitizedKey} />`;
407
+ }
408
+
409
+ if (typeof value === 'object' && !Array.isArray(value)) {
410
+ let attrs = '';
411
+ let content = '';
412
+
413
+ if (value['@attributes']) {
414
+ for (const k in value['@attributes']) {
415
+ attrs += ` ${k}="${value['@attributes'][k]}"`;
416
+ }
417
+ }
418
+
419
+ for (const k in value) {
420
+ if (k === '@attributes') continue;
421
+ if (k === '#text') {
422
+ content += String(value[k]);
423
+ } else {
424
+ const val = value[k];
425
+ if (Array.isArray(val)) {
426
+ val.forEach(item => {
427
+ content += buildTag(k, item);
428
+ });
429
+ } else {
430
+ content += buildTag(k, val);
431
+ }
432
+ }
433
+ }
434
+ return `<${sanitizedKey}${attrs}>${content}</${sanitizedKey}>`;
435
+ } else if (Array.isArray(value)) {
436
+ // This shouldn't happen if called correctly via parent, but for safety:
437
+ return value.map(item => buildTag(key, item)).join('');
438
+ } else {
439
+ return `<${sanitizedKey}>${value}</${sanitizedKey}>`;
440
+ }
441
+ }
package/src/xml.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { jsonToToonSync, toonToJsonSync } from './json.js';
7
- import { encodeXmlReservedChars, extractXmlFromString } from './utils.js';
7
+ import { encodeXmlReservedChars, extractXmlFromString, buildTag } from './utils.js';
8
8
 
9
9
  /**
10
10
  * Converts XML DOM to JSON object
@@ -31,6 +31,10 @@ function xmlToJsonObject(xml) {
31
31
  for (let i = 0; i < xml.childNodes.length; i++) {
32
32
  const item = xml.childNodes.item(i);
33
33
  const nodeName = item.nodeName;
34
+
35
+ // Skip comment nodes
36
+ if (item.nodeType === 8) continue;
37
+
34
38
  const childJson = xmlToJsonObject(item);
35
39
 
36
40
  if (childJson === undefined) continue;
@@ -38,8 +42,7 @@ function xmlToJsonObject(xml) {
38
42
  if (obj[nodeName] === undefined) {
39
43
  obj[nodeName] = childJson;
40
44
  } else {
41
- // Handle multiple children with the same tag name (create an array)
42
- if (typeof obj[nodeName].push === "undefined") {
45
+ if (!Array.isArray(obj[nodeName])) {
43
46
  const old = obj[nodeName];
44
47
  obj[nodeName] = [];
45
48
  obj[nodeName].push(old);
@@ -49,71 +52,15 @@ function xmlToJsonObject(xml) {
49
52
  }
50
53
  }
51
54
 
52
- // Clean up: If the object only contains text and no attributes/children, return the text directly
53
- if (Object.keys(obj).length === 1 && obj['#text'] !== undefined) {
55
+ // Special case: if object only has #text and no attributes/children, return text directly
56
+ const keys = Object.keys(obj);
57
+ if (keys.length === 1 && keys[0] === '#text' && !obj['@attributes']) {
54
58
  return obj['#text'];
55
59
  }
56
60
 
57
61
  return obj;
58
62
  }
59
63
 
60
- /**
61
- * Converts JSON object to XML string
62
- * @param {Object} obj - JSON object
63
- * @returns {string} XML string
64
- */
65
- function jsonObjectToXml(obj) {
66
- let xml = '';
67
-
68
- for (const key in obj) {
69
- if (!obj.hasOwnProperty(key)) continue;
70
-
71
- const value = obj[key];
72
-
73
- if (key === "#text") {
74
- // Handle text content directly
75
- xml += value;
76
- }
77
- else if (key === '@attributes' && typeof value === 'object') {
78
- // Handle attributes: Convert { "@attributes": { "id": "1" } } to id="1"
79
- let attrString = '';
80
- for (const attrKey in value) {
81
- attrString += ` ${attrKey}="${value[attrKey]}"`;
82
- }
83
- xml += attrString;
84
- }
85
- else if (Array.isArray(value)) {
86
- // Handle arrays: Loop and create a tag for each item
87
- value.forEach(item => {
88
- if (typeof item === 'object') {
89
- const innerContent = jsonObjectToXml(item);
90
- const attrMatch = innerContent.match(/^(\s+[^\s=]+="[^"]*")*/);
91
- const attrs = attrMatch ? attrMatch[0] : "";
92
- const body = innerContent.slice(attrs.length);
93
-
94
- xml += `<${key}${attrs}>${body}</${key}>`;
95
- } else {
96
- xml += `<${key}>${item}</${key}>`;
97
- }
98
- });
99
- }
100
- else if (typeof value === 'object' && value !== null) {
101
- // Handle nested objects: Recurse and wrap in the current key's tag
102
- const innerContent = jsonObjectToXml(value);
103
- const attrMatch = innerContent.match(/^(\s+[^\s=]+="[^"]*")*/);
104
- const attrs = attrMatch ? attrMatch[0] : "";
105
- const body = innerContent.slice(attrs.length);
106
-
107
- xml += `<${key}${attrs}>${body}</${key}>`;
108
- }
109
- else if (value !== null && value !== undefined) {
110
- // Handle primitive values: Create a simple tag
111
- xml += `<${key}>${value}</${key}>`;
112
- }
113
- }
114
- return xml;
115
- }
116
-
117
64
  /**
118
65
  * Internal core function to convert pure XML string to TOON (Sync)
119
66
  * @param {string} xmlString
@@ -134,21 +81,17 @@ function parseXmlToToonSync(xmlString) {
134
81
  'application/xml'
135
82
  );
136
83
 
137
- // Check for parser errors (works in both browser and xmldom)
138
- if (xmlDoc.querySelector) {
139
- // Browser environment
140
- const parserError = xmlDoc.querySelector('parsererror');
141
- if (parserError) {
142
- throw new Error(parserError.textContent);
143
- }
144
- } else {
145
- // xmldom environment - check documentElement
146
- if (xmlDoc.documentElement && xmlDoc.documentElement.nodeName === 'parsererror') {
147
- throw new Error(xmlDoc.documentElement.textContent || 'XML parsing error');
148
- }
84
+ const parserError = xmlDoc.querySelector ? xmlDoc.querySelector('parsererror') :
85
+ (xmlDoc.documentElement && xmlDoc.documentElement.nodeName === 'parsererror' ? xmlDoc.documentElement : null);
86
+
87
+ if (parserError) {
88
+ throw new Error(parserError.textContent || 'XML parsing error');
149
89
  }
150
90
 
151
- const jsonObject = xmlToJsonObject(xmlDoc);
91
+ const rootElement = xmlDoc.documentElement;
92
+ const jsonObject = {};
93
+ jsonObject[rootElement.nodeName] = xmlToJsonObject(rootElement);
94
+
152
95
  return jsonToToonSync(jsonObject);
153
96
  }
154
97
 
@@ -194,9 +137,7 @@ export async function xmlToToon(xmlString) {
194
137
  try {
195
138
  const { DOMParser: NodeDOMParser } = await import('xmldom');
196
139
  global.DOMParser = NodeDOMParser;
197
- } catch (e) {
198
- // Ignore if import fails, xmlToToonSync will throw appropriate error
199
- }
140
+ } catch (e) { }
200
141
  }
201
142
  return xmlToToonSync(xmlString);
202
143
  }
@@ -213,7 +154,11 @@ export function toonToXmlSync(toonString) {
213
154
  }
214
155
 
215
156
  const jsonObject = toonToJsonSync(toonString);
216
- return jsonObjectToXml(jsonObject);
157
+ let xml = "";
158
+ for (const k in jsonObject) {
159
+ xml += buildTag(k, jsonObject[k]);
160
+ }
161
+ return xml;
217
162
  }
218
163
 
219
164
  /**