links-notation 0.24.0 → 0.25.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/README.md CHANGED
@@ -147,6 +147,85 @@ const group = new LinksGroup(parsed);
147
147
  console.log(group.format());
148
148
  ```
149
149
 
150
+ ### Indented Text Documents
151
+
152
+ Use `parseIndentedDocument` and `formatIndentedDocument` for text parents and
153
+ their answers or options:
154
+
155
+ ```javascript
156
+ import { parseIndentedDocument, formatIndentedDocument } from 'links-notation';
157
+
158
+ const input = `What is your experience?
159
+ I have worked with JavaScript
160
+ 'Question with "quotes": and colon'
161
+ Answer
162
+ Skills
163
+ JavaScript
164
+ Python
165
+ `;
166
+ const answers = parseIndentedDocument(input);
167
+ console.log(answers.get('What is your experience?'));
168
+ // I have worked with JavaScript
169
+ console.log(answers.get('Skills')); // ['JavaScript', 'Python']
170
+ console.log(formatIndentedDocument(answers));
171
+ ```
172
+
173
+ A parent with no children maps to `[]`, one child maps to a string, and multiple
174
+ children map to an array. Repeated parent lines accumulate children in order.
175
+ To read multiple child lines as multiline text, pass
176
+ `{ multipleValues: 'join' }`. These helpers accept the same parsing options as
177
+ `Parser`, including `comments`, `maxInputSize`, and `maxDepth`.
178
+
179
+ The formatter uses two spaces of indentation and adds a final newline to a
180
+ nonempty document. Ordinary single-space prose stays unquoted. Quotes are
181
+ escaped by doubling the enclosing delimiter. Structural characters, comment
182
+ starts, reserved literal prefixes, and significant whitespace are quoted.
183
+ Empty strings and control characters use the versioned reference literals
184
+ introduced in 0.24.0. A multiline string is encoded as one reference with its
185
+ exact line endings; an array is written as separate child references. Parsed
186
+ IDs are already unescaped.
187
+
188
+ The text format does not distinguish a one-element array from a single string;
189
+ both read back as a string. Use a multiline string to distinguish one
190
+ multiline answer from several options. The helpers reject grandchildren and
191
+ named links with values instead of discarding their structure.
192
+
193
+ For arbitrary indentation trees, `Parser.parseGroups(input)` returns existing
194
+ `LinksGroup` instances. Each group's `element` is the `Link` on that line, and
195
+ `children` contains its immediately indented groups. Parenthesized values retain
196
+ their usual `Link` structure:
197
+
198
+ ```javascript
199
+ import { Parser } from 'links-notation';
200
+
201
+ const [group] = new Parser().parseGroups('Question\n Answer\n Detail');
202
+ console.log(group.element.getValuesString()); // Question
203
+ console.log(group.children[0].element.getValuesString()); // Answer
204
+ console.log(group.children[0].children[0].element.getValuesString()); // Detail
205
+ ```
206
+
207
+ `Parser.parse()` continues to return the existing list of links and expanded
208
+ paths. `parseGroups()` preserves indentation even for the `name:` syntax.
209
+
210
+ `escapeReference` and `unescapeReference` are exported functions and static
211
+ methods on `Link`. Use minimal mode only for whole text lines; default escaping
212
+ still quotes spaces so references can be used inside tuples:
213
+
214
+ ```javascript
215
+ import { Link, escapeReference, unescapeReference } from 'links-notation';
216
+
217
+ Link.escapeReference('plain text'); // "'plain text'"
218
+ escapeReference('plain text', { minimal: true }); // 'plain text'
219
+ unescapeReference("'it''s ready'"); // "it's ready"
220
+ ```
221
+
222
+ `unescapeReference` decodes a complete serialized reference, including n-quote
223
+ delimiters, doubled escapes, and versioned literals. It rejects incomplete
224
+ quoted input and malformed literals, and preserves literal backslashes. Do not
225
+ call it on already decoded `Link.id` values.
226
+
227
+ See the [Q&A document example](../examples/js_indented_document.js).
228
+
150
229
  ### Streaming Parser (for Large Messages)
151
230
 
152
231
  The `StreamParser` allows you to parse Links Notation incrementally, processing data as it arrives without loading the entire message into memory. This is ideal for:
@@ -341,6 +420,7 @@ Main parser class for converting strings to links.
341
420
  recursed into until the stack runs out
342
421
  - `initialize()` - Initialize the parser (async)
343
422
  - `parse(input)` - Parse a Lino string and return links
423
+ - `parseGroups(input)` - Read an indentation tree as `LinksGroup` instances
344
424
 
345
425
  #### `Link`
346
426
 
@@ -350,6 +430,9 @@ Represents a single link with ID and values.
350
430
  - `toString()` - Convert link to string format
351
431
  - `id` - Link identifier
352
432
  - `values` - Array of child values/links
433
+ - `escapeReference(reference, options)` - Quote a reference; `minimal: true`
434
+ allows ordinary prose on a text document line
435
+ - `unescapeReference(reference)` - Decode a complete serialized reference
353
436
 
354
437
  #### `LinksGroup`
355
438
 
package/README.ru.md CHANGED
@@ -3,6 +3,54 @@
3
3
  Реализация парсера протокола Lino для JavaScript с использованием Bun и
4
4
  генератора парсеров Peggy.js.
5
5
 
6
+ ## Документы с отступами
7
+
8
+ `parseIndentedDocument()` и `formatIndentedDocument()` преобразуют текстовые
9
+ родительские строки и их дочерние строки в `Map<string, string | string[]>`:
10
+
11
+ ```javascript
12
+ import { parseIndentedDocument, formatIndentedDocument } from 'links-notation';
13
+
14
+ const answers = parseIndentedDocument(`Ваш опыт?
15
+ JavaScript
16
+ Навыки
17
+ JavaScript
18
+ Python
19
+ `);
20
+ console.log(answers.get('Ваш опыт?')); // 'JavaScript'
21
+ console.log(answers.get('Навыки')); // ['JavaScript', 'Python']
22
+ console.log(formatIndentedDocument(answers));
23
+ ```
24
+
25
+ Без дочерних строк значение равно `[]`, с одной строкой — строке, с несколькими
26
+ — массиву. Повторяющиеся родительские строки объединяют дочерние значения в
27
+ порядке документа. Опция `{ multipleValues: 'join' }` объединяет несколько
28
+ значений через перевод строки. Поддерживаются опции `Parser`: `comments`,
29
+ `maxInputSize` и `maxDepth`. Вложенность глубже одного дочернего уровня и
30
+ именованные ссылки со значениями отклоняются.
31
+
32
+ Форматирование использует два пробела для отступа и завершающий перевод строки.
33
+ Обычный текст с одиночными пробелами остаётся без кавычек; специальные символы,
34
+ значимые пробелы и зарезервированные префиксы литералов заключаются в кавычки.
35
+ Кавычки экранируются удвоением. Пустые строки и управляющие символы используют
36
+ версионированные литералы, введённые в 0.24.0. Массив из одного элемента при
37
+ чтении становится строкой. Многострочная строка сохраняется одной ссылкой с
38
+ точными переводами строк, а элементы массива записываются отдельными дочерними
39
+ строками.
40
+
41
+ `Parser.parseGroups()` возвращает дерево существующих объектов `LinksGroup`:
42
+ `element` содержит ссылку текущей строки, а `children` — группы непосредственно
43
+ вложенных строк. Поведение `Parser.parse()` сохраняется.
44
+
45
+ Функции `escapeReference()` и `unescapeReference()` экспортируются из пакета и
46
+ доступны как статические методы `Link`. Опция `{ minimal: true }` разрешает
47
+ обычные пробелы при экранировании целой текстовой строки. По умолчанию пробелы
48
+ по-прежнему заключаются в кавычки. `unescapeReference()` принимает целую
49
+ сериализованную ссылку, включая версионированные литералы; значения `Link.id`
50
+ уже декодированы.
51
+
52
+ См. [пример базы вопросов и ответов](../examples/js_indented_document.js).
53
+
6
54
  ## Установка
7
55
 
8
56
  ### Использование Bun (рекомендуется)
package/dist/index.js CHANGED
@@ -1,3 +1,159 @@
1
+ // src/quotes.js
2
+ var QUOTES = ['"', "'", "`"];
3
+
4
+ class DelimitedReferences {
5
+ constructor(document) {
6
+ this.document = document;
7
+ this.runsByQuote = new Map;
8
+ this.readings = new Map;
9
+ }
10
+ readAt(start) {
11
+ if (!this.readings.has(start)) {
12
+ this.readings.set(start, this.#read(start));
13
+ }
14
+ return this.readings.get(start);
15
+ }
16
+ endAt(start) {
17
+ const reading = this.readAt(start);
18
+ return reading === null ? null : start + reading.length;
19
+ }
20
+ #read(start) {
21
+ const document = this.document;
22
+ const quote = document[start];
23
+ if (!QUOTES.includes(quote)) {
24
+ return null;
25
+ }
26
+ const runs = this.#runsOf(quote);
27
+ const opening = runs.indexOf(start);
28
+ const count = runs.end(opening) - start;
29
+ let closing = opening + 1;
30
+ while (closing < runs.count) {
31
+ const length = runs.lengths[closing];
32
+ if (length < count) {
33
+ closing = runs.nextLonger[closing];
34
+ } else if (Math.floor(length / count) % 2 === 1) {
35
+ break;
36
+ } else {
37
+ closing++;
38
+ }
39
+ }
40
+ const end = closing < runs.count ? runs.end(closing) : null;
41
+ return reading(document, start, quote, count, end);
42
+ }
43
+ #runsOf(quote) {
44
+ if (!this.runsByQuote.has(quote)) {
45
+ this.runsByQuote.set(quote, new DelimiterRuns(this.document, quote));
46
+ }
47
+ return this.runsByQuote.get(quote);
48
+ }
49
+ }
50
+ function readReference(document, start) {
51
+ const quote = document[start];
52
+ if (!QUOTES.includes(quote)) {
53
+ return null;
54
+ }
55
+ const count = runLength(document, start, quote);
56
+ let end = null;
57
+ let position = document.indexOf(quote, start + count);
58
+ while (position !== -1) {
59
+ const length = runLength(document, position, quote);
60
+ if (length >= count && Math.floor(length / count) % 2 === 1) {
61
+ end = position + length;
62
+ break;
63
+ }
64
+ position = document.indexOf(quote, position + length);
65
+ }
66
+ return reading(document, start, quote, count, end);
67
+ }
68
+ function reading(document, start, quote, count, end) {
69
+ const emptyReference = count % 2 === 0 ? { value: "", length: count } : null;
70
+ if (end === null) {
71
+ return emptyReference;
72
+ }
73
+ const parts = [];
74
+ let position = start + count;
75
+ let run = document.indexOf(quote, position);
76
+ while (run !== -1 && run < end) {
77
+ const length = runLength(document, run, quote);
78
+ const escaped = Math.floor(length / (2 * count)) * count;
79
+ const closes = run + length === end ? count : 0;
80
+ parts.push(document.slice(position, run));
81
+ parts.push(quote.repeat(length - escaped - closes));
82
+ position = run + length;
83
+ run = document.indexOf(quote, position);
84
+ }
85
+ const value = parts.join("");
86
+ if (emptyReference !== null && !isSubstantiveBody(value)) {
87
+ return emptyReference;
88
+ }
89
+ return { value, length: end - start };
90
+ }
91
+ function runLength(text, start, quote) {
92
+ let end = start;
93
+ while (end < text.length && text[end] === quote) {
94
+ end++;
95
+ }
96
+ return end - start;
97
+ }
98
+
99
+ class DelimiterRuns {
100
+ constructor(document, quote) {
101
+ this.starts = [];
102
+ this.lengths = [];
103
+ let position = document.indexOf(quote);
104
+ while (position !== -1) {
105
+ const length = runLength(document, position, quote);
106
+ this.starts.push(position);
107
+ this.lengths.push(length);
108
+ position = document.indexOf(quote, position + length);
109
+ }
110
+ this.count = this.starts.length;
111
+ this.nextLonger = new Array(this.count);
112
+ const longer = [];
113
+ for (let run = this.count - 1;run >= 0; run--) {
114
+ while (longer.length > 0 && this.lengths[longer[longer.length - 1]] <= this.lengths[run]) {
115
+ longer.pop();
116
+ }
117
+ this.nextLonger[run] = longer.length > 0 ? longer[longer.length - 1] : this.count;
118
+ longer.push(run);
119
+ }
120
+ }
121
+ end(run) {
122
+ return this.starts[run] + this.lengths[run];
123
+ }
124
+ indexOf(position) {
125
+ let low = 0;
126
+ let high = this.count - 1;
127
+ while (low < high) {
128
+ const middle = low + high + 1 >> 1;
129
+ if (this.starts[middle] <= position) {
130
+ low = middle;
131
+ } else {
132
+ high = middle - 1;
133
+ }
134
+ }
135
+ return low;
136
+ }
137
+ }
138
+ function isSubstantiveBody(content) {
139
+ let depth = 0;
140
+ let hasVisible = false;
141
+ for (const character of content) {
142
+ if (character === "(") {
143
+ depth++;
144
+ } else if (character === ")") {
145
+ depth--;
146
+ if (depth < 0) {
147
+ return false;
148
+ }
149
+ }
150
+ if (!/[ \t\n\r]/.test(character)) {
151
+ hasVisible = true;
152
+ }
153
+ }
154
+ return hasVisible && depth === 0;
155
+ }
156
+
1
157
  // src/ReferenceLiteral.js
2
158
  function hasReferenceLiteralPrefix(text, start = 0) {
3
159
  return /^~[0-9]+\{/.test(text.slice(start));
@@ -44,6 +200,42 @@ function formatReference(text) {
44
200
  return delimiter + text + delimiter;
45
201
  }
46
202
 
203
+ // src/references.js
204
+ function escapeReference(reference, { minimal = false } = {}) {
205
+ if (reference === null || reference === undefined)
206
+ return "";
207
+ if (typeof reference !== "string" || !reference.isWellFormed()) {
208
+ throw new TypeError("Reference must be well-formed Unicode text");
209
+ }
210
+ if (!minimal || !reference || /[\u0000-\u001f\u007f]/u.test(reference)) {
211
+ return formatReference(reference);
212
+ }
213
+ const needsQuoting = /(^| )~[0-9]+\{/.test(reference) || /[:()'"`]|(?! )[\p{White_Space}\ufeff]/u.test(reference) || /(^ | $| {2,}|(^| )#)/.test(reference);
214
+ if (!needsQuoting)
215
+ return reference;
216
+ const singleQuotes = reference.split("'").length - 1;
217
+ const doubleQuotes = reference.split('"').length - 1;
218
+ let quote = singleQuotes <= doubleQuotes ? "'" : '"';
219
+ if (reference.startsWith(quote))
220
+ quote = quote === "'" ? '"' : "'";
221
+ return quote + reference.replaceAll(quote, quote + quote) + quote;
222
+ }
223
+ function unescapeReference(reference) {
224
+ if (typeof reference !== "string") {
225
+ throw new TypeError("Reference must be a string");
226
+ }
227
+ if (hasReferenceLiteralPrefix(reference)) {
228
+ return decodeReferenceLiteral(reference);
229
+ }
230
+ if (!QUOTES.includes(reference[0]))
231
+ return reference;
232
+ const reading = readReference(reference, 0);
233
+ if (reading === null || reading.length !== reference.length) {
234
+ throw new SyntaxError("Expected one complete quoted reference");
235
+ }
236
+ return reading.value;
237
+ }
238
+
47
239
  // src/Link.js
48
240
  class Link {
49
241
  constructor(id = null, values = null) {
@@ -81,11 +273,11 @@ class Link {
81
273
  static getValueString(value) {
82
274
  return value && typeof value.toLinkOrIdString === "function" ? value.toLinkOrIdString() : String(value);
83
275
  }
84
- static escapeReference(reference) {
85
- if (reference === null || reference === undefined) {
86
- return "";
87
- }
88
- return formatReference(reference);
276
+ static escapeReference(reference, options = {}) {
277
+ return escapeReference(reference, options);
278
+ }
279
+ static unescapeReference(reference) {
280
+ return unescapeReference(reference);
89
281
  }
90
282
  toLinkOrIdString() {
91
283
  if (!this.values || this.values.length === 0) {
@@ -296,6 +488,7 @@ class LinksGroup {
296
488
  return list.map((item) => `(${item.id || item})`).join(" ");
297
489
  }
298
490
  }
491
+
299
492
  // src/ParseError.js
300
493
  var QUOTED_LINE_WIDTH = 80;
301
494
  var ELLIPSIS = "...";
@@ -371,144 +564,6 @@ function windowAround(lineText, column) {
371
564
  return [quoted, target - start + shift + 1];
372
565
  }
373
566
 
374
- // src/quotes.js
375
- var QUOTES = ['"', "'", "`"];
376
-
377
- class DelimitedReferences {
378
- constructor(document) {
379
- this.document = document;
380
- this.runsByQuote = new Map;
381
- this.readings = new Map;
382
- }
383
- readAt(start) {
384
- if (!this.readings.has(start)) {
385
- this.readings.set(start, this.#read(start));
386
- }
387
- return this.readings.get(start);
388
- }
389
- endAt(start) {
390
- const reading = this.readAt(start);
391
- return reading === null ? null : start + reading.length;
392
- }
393
- #read(start) {
394
- const document = this.document;
395
- const quote = document[start];
396
- if (!QUOTES.includes(quote)) {
397
- return null;
398
- }
399
- const runs = this.#runsOf(quote);
400
- const opening = runs.indexOf(start);
401
- const count = runs.end(opening) - start;
402
- let closing = opening + 1;
403
- while (closing < runs.count) {
404
- const length = runs.lengths[closing];
405
- if (length < count) {
406
- closing = runs.nextLonger[closing];
407
- } else if (Math.floor(length / count) % 2 === 1) {
408
- break;
409
- } else {
410
- closing++;
411
- }
412
- }
413
- const end = closing < runs.count ? runs.end(closing) : null;
414
- return reading(document, start, quote, count, end);
415
- }
416
- #runsOf(quote) {
417
- if (!this.runsByQuote.has(quote)) {
418
- this.runsByQuote.set(quote, new DelimiterRuns(this.document, quote));
419
- }
420
- return this.runsByQuote.get(quote);
421
- }
422
- }
423
- function reading(document, start, quote, count, end) {
424
- const emptyReference = count % 2 === 0 ? { value: "", length: count } : null;
425
- if (end === null) {
426
- return emptyReference;
427
- }
428
- const parts = [];
429
- let position = start + count;
430
- let run = document.indexOf(quote, position);
431
- while (run !== -1 && run < end) {
432
- const length = runLength(document, run, quote);
433
- const escaped = Math.floor(length / (2 * count)) * count;
434
- const closes = run + length === end ? count : 0;
435
- parts.push(document.slice(position, run));
436
- parts.push(quote.repeat(length - escaped - closes));
437
- position = run + length;
438
- run = document.indexOf(quote, position);
439
- }
440
- const value = parts.join("");
441
- if (emptyReference !== null && !isSubstantiveBody(value)) {
442
- return emptyReference;
443
- }
444
- return { value, length: end - start };
445
- }
446
- function runLength(text, start, quote) {
447
- let end = start;
448
- while (end < text.length && text[end] === quote) {
449
- end++;
450
- }
451
- return end - start;
452
- }
453
-
454
- class DelimiterRuns {
455
- constructor(document, quote) {
456
- this.starts = [];
457
- this.lengths = [];
458
- let position = document.indexOf(quote);
459
- while (position !== -1) {
460
- const length = runLength(document, position, quote);
461
- this.starts.push(position);
462
- this.lengths.push(length);
463
- position = document.indexOf(quote, position + length);
464
- }
465
- this.count = this.starts.length;
466
- this.nextLonger = new Array(this.count);
467
- const longer = [];
468
- for (let run = this.count - 1;run >= 0; run--) {
469
- while (longer.length > 0 && this.lengths[longer[longer.length - 1]] <= this.lengths[run]) {
470
- longer.pop();
471
- }
472
- this.nextLonger[run] = longer.length > 0 ? longer[longer.length - 1] : this.count;
473
- longer.push(run);
474
- }
475
- }
476
- end(run) {
477
- return this.starts[run] + this.lengths[run];
478
- }
479
- indexOf(position) {
480
- let low = 0;
481
- let high = this.count - 1;
482
- while (low < high) {
483
- const middle = low + high + 1 >> 1;
484
- if (this.starts[middle] <= position) {
485
- low = middle;
486
- } else {
487
- high = middle - 1;
488
- }
489
- }
490
- return low;
491
- }
492
- }
493
- function isSubstantiveBody(content) {
494
- let depth = 0;
495
- let hasVisible = false;
496
- for (const character of content) {
497
- if (character === "(") {
498
- depth++;
499
- } else if (character === ")") {
500
- depth--;
501
- if (depth < 0) {
502
- return false;
503
- }
504
- }
505
- if (!/[ \t\n\r]/.test(character)) {
506
- hasVisible = true;
507
- }
508
- }
509
- return hasVisible && depth === 0;
510
- }
511
-
512
567
  // src/comments.js
513
568
  var COMMENT = "#";
514
569
  var BEFORE_REFERENCE = [" ", "\t", `
@@ -2817,6 +2872,15 @@ class Parser {
2817
2872
  this.comments = options.comments ?? true;
2818
2873
  }
2819
2874
  parse(input) {
2875
+ return this._parse(input, (raw) => this.transformResult(raw));
2876
+ }
2877
+ parseGroups(input) {
2878
+ return this._parse(input, (raw) => raw.map((item) => this._transformGroup(item)));
2879
+ }
2880
+ _transformGroup(item) {
2881
+ return new LinksGroup(this.transformLink(item), (item.children || []).map((child) => this._transformGroup(child)));
2882
+ }
2883
+ _parse(input, transform) {
2820
2884
  if (typeof input !== "string") {
2821
2885
  throw new TypeError("Input must be a string");
2822
2886
  }
@@ -2828,7 +2892,7 @@ class Parser {
2828
2892
  const rawResult = peg$parse(prepared, {
2829
2893
  maxDepth: this.maxDepth
2830
2894
  });
2831
- return this.transformResult(rawResult);
2895
+ return transform(rawResult);
2832
2896
  } catch (error) {
2833
2897
  if (error && error.location) {
2834
2898
  throw new ParseError(input, error);
@@ -2950,6 +3014,64 @@ class Parser {
2950
3014
  return new Link(item.id ?? null, []);
2951
3015
  }
2952
3016
  }
3017
+
3018
+ // src/IndentedDocument.js
3019
+ function lineText(link) {
3020
+ if (link.id !== null && link.values.length === 0)
3021
+ return link.id;
3022
+ if (link.id === null && link.values.length > 0 && link.values.every((value) => value.id !== null && value.values.length === 0)) {
3023
+ return link.values.map((value) => value.id).join(" ");
3024
+ }
3025
+ throw new TypeError("Indented document lines must contain only text references");
3026
+ }
3027
+ function parseIndentedDocument(input, options = {}) {
3028
+ const { multipleValues = "array" } = options;
3029
+ if (multipleValues !== "array" && multipleValues !== "join") {
3030
+ throw new TypeError('multipleValues must be "array" or "join"');
3031
+ }
3032
+ const groups = new Parser(options).parseGroups(input);
3033
+ const collected = new Map;
3034
+ for (const group of groups) {
3035
+ const parent = lineText(group.element);
3036
+ const values = collected.get(parent) ?? [];
3037
+ for (const child of group.children) {
3038
+ if (child.children.length > 0) {
3039
+ throw new TypeError("Indented documents support only immediate children");
3040
+ }
3041
+ values.push(lineText(child.element));
3042
+ }
3043
+ collected.set(parent, values);
3044
+ }
3045
+ return new Map([...collected].map(([parent, values]) => [
3046
+ parent,
3047
+ values.length === 1 ? values[0] : values.length > 1 && multipleValues === "join" ? values.join(`
3048
+ `) : values
3049
+ ]));
3050
+ }
3051
+ function formatIndentedDocument(entries) {
3052
+ if (!(entries instanceof Map)) {
3053
+ throw new TypeError("Indented document entries must be a Map");
3054
+ }
3055
+ const lines = [];
3056
+ for (const [parent, value] of entries) {
3057
+ if (typeof parent !== "string") {
3058
+ throw new TypeError("Indented document parents must be strings");
3059
+ }
3060
+ const values = Array.isArray(value) ? value : [value];
3061
+ for (const child of values) {
3062
+ if (typeof child !== "string") {
3063
+ throw new TypeError("Indented document children must be strings");
3064
+ }
3065
+ }
3066
+ lines.push(escapeReference(parent, { minimal: true }));
3067
+ for (const child of values) {
3068
+ lines.push(" " + escapeReference(child, { minimal: true }));
3069
+ }
3070
+ }
3071
+ return lines.length > 0 ? lines.join(`
3072
+ `) + `
3073
+ ` : "";
3074
+ }
2953
3075
  // src/StreamParser.js
2954
3076
  import { EventEmitter } from "events";
2955
3077
  var DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
@@ -3783,8 +3905,12 @@ export {
3783
3905
  StreamParser,
3784
3906
  decodeReferenceLiteral,
3785
3907
  encodeReferenceLiteral,
3908
+ escapeReference,
3786
3909
  formatBinaryDocument,
3787
3910
  formatBinaryReference,
3911
+ formatIndentedDocument,
3788
3912
  formatLinks,
3789
- stripComments
3913
+ parseIndentedDocument,
3914
+ stripComments,
3915
+ unescapeReference
3790
3916
  };
package/index.d.ts CHANGED
@@ -100,9 +100,29 @@ export class Link {
100
100
  * @param reference - The reference to escape
101
101
  * @returns Escaped reference
102
102
  */
103
- static escapeReference(reference: string): string;
103
+ static escapeReference(
104
+ reference: string,
105
+ options?: EscapeReferenceOptions
106
+ ): string;
107
+
108
+ /** Decode one escaped reference. Link IDs returned by a parser are already decoded. */
109
+ static unescapeReference(reference: string): string;
104
110
  }
105
111
 
112
+ export interface EscapeReferenceOptions {
113
+ /** Leave ordinary single-space prose unquoted for use on document lines. */
114
+ minimal?: boolean;
115
+ }
116
+
117
+ /** Escape a reference. Minimal mode doubles enclosing quotes; default formatting uses n-quotes. */
118
+ export function escapeReference(
119
+ reference: string,
120
+ options?: EscapeReferenceOptions
121
+ ): string;
122
+
123
+ /** Decode a complete escaped reference, including n-quotes and versioned literals. */
124
+ export function unescapeReference(reference: string): string;
125
+
106
126
  /**
107
127
  * Parser options for configuring the parser behavior
108
128
  */
@@ -166,8 +186,40 @@ export class Parser {
166
186
  * @throws {ParseError} If the text does not parse
167
187
  */
168
188
  parse(input: string): Link[];
189
+
190
+ /** Read the indentation tree without expanding it into path combinations. */
191
+ parseGroups(input: string): IndentedGroup[];
192
+ }
193
+
194
+ /** A parsed line and the immediately indented lines below it. */
195
+ export interface IndentedGroup extends LinksGroup {
196
+ element: Link;
197
+ children: IndentedGroup[];
169
198
  }
170
199
 
200
+ export interface IndentedDocumentOptions extends ParserOptions {
201
+ /** Multiple children become an array (default) or text joined with newlines. */
202
+ multipleValues?: 'array' | 'join';
203
+ }
204
+
205
+ /**
206
+ * Read parent/children text. Repeated parents accumulate children in order.
207
+ * No children yields [], one yields a string, multiple yield an array by default.
208
+ * Named links with values and grandchildren are rejected.
209
+ */
210
+ export function parseIndentedDocument(
211
+ input: string,
212
+ options?: IndentedDocumentOptions
213
+ ): Map<string, string | string[]>;
214
+
215
+ /**
216
+ * Write minimally quoted text with two-space indentation and a final newline.
217
+ * Multiline strings remain one encoded reference; array entries become children.
218
+ */
219
+ export function formatIndentedDocument(
220
+ entries: ReadonlyMap<string, string | string[]>
221
+ ): string;
222
+
171
223
  /** Options for incremental parsing. */
172
224
  export interface StreamParserOptions extends ParserOptions {
173
225
  /** Canonical parser instance to use for completed records. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,88 @@
1
+ import { Parser } from './Parser.js';
2
+ import { escapeReference } from './references.js';
3
+
4
+ // A text line can be a single quoted reference or a flat list of words. Named
5
+ // links and nested values have more structure than this document format holds.
6
+ function lineText(link) {
7
+ if (link.id !== null && link.values.length === 0) return link.id;
8
+ if (
9
+ link.id === null &&
10
+ link.values.length > 0 &&
11
+ link.values.every((value) => value.id !== null && value.values.length === 0)
12
+ ) {
13
+ return link.values.map((value) => value.id).join(' ');
14
+ }
15
+ throw new TypeError(
16
+ 'Indented document lines must contain only text references'
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Read text parents and their immediate text children into a Map. Repeated
22
+ * parents accumulate children in document order. Zero children yields [], one
23
+ * yields a string, and multiple yield an array (or joined text when requested).
24
+ * @param {string} input
25
+ * @param {Object} options - Parser options plus multipleValues: 'array' | 'join'
26
+ * @returns {Map<string, string|string[]>}
27
+ */
28
+ export function parseIndentedDocument(input, options = {}) {
29
+ const { multipleValues = 'array' } = options;
30
+ if (multipleValues !== 'array' && multipleValues !== 'join') {
31
+ throw new TypeError('multipleValues must be "array" or "join"');
32
+ }
33
+ const groups = new Parser(options).parseGroups(input);
34
+ const collected = new Map();
35
+ for (const group of groups) {
36
+ const parent = lineText(group.element);
37
+ const values = collected.get(parent) ?? [];
38
+ for (const child of group.children) {
39
+ if (child.children.length > 0) {
40
+ throw new TypeError(
41
+ 'Indented documents support only immediate children'
42
+ );
43
+ }
44
+ values.push(lineText(child.element));
45
+ }
46
+ collected.set(parent, values);
47
+ }
48
+ return new Map(
49
+ [...collected].map(([parent, values]) => [
50
+ parent,
51
+ values.length === 1
52
+ ? values[0]
53
+ : values.length > 1 && multipleValues === 'join'
54
+ ? values.join('\n')
55
+ : values,
56
+ ])
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Write a text Map using minimal quoting and two-space child indentation.
62
+ * Multiline strings are encoded as one reference; array entries are separate
63
+ * children. Nonempty output ends with a newline.
64
+ * @param {Map<string, string|string[]>} entries
65
+ * @returns {string}
66
+ */
67
+ export function formatIndentedDocument(entries) {
68
+ if (!(entries instanceof Map)) {
69
+ throw new TypeError('Indented document entries must be a Map');
70
+ }
71
+ const lines = [];
72
+ for (const [parent, value] of entries) {
73
+ if (typeof parent !== 'string') {
74
+ throw new TypeError('Indented document parents must be strings');
75
+ }
76
+ const values = Array.isArray(value) ? value : [value];
77
+ for (const child of values) {
78
+ if (typeof child !== 'string') {
79
+ throw new TypeError('Indented document children must be strings');
80
+ }
81
+ }
82
+ lines.push(escapeReference(parent, { minimal: true }));
83
+ for (const child of values) {
84
+ lines.push(' ' + escapeReference(child, { minimal: true }));
85
+ }
86
+ }
87
+ return lines.length > 0 ? lines.join('\n') + '\n' : '';
88
+ }
package/src/Link.js CHANGED
@@ -1,4 +1,4 @@
1
- import { formatReference } from './ReferenceLiteral.js';
1
+ import { escapeReference, unescapeReference } from './references.js';
2
2
 
3
3
  export class Link {
4
4
  /**
@@ -81,14 +81,16 @@ export class Link {
81
81
  /**
82
82
  * Escape a reference string by adding quotes if necessary
83
83
  * @param {string} reference - The reference to escape
84
+ * @param {{minimal?: boolean}} options - Allow plain-text document lines
84
85
  * @returns {string} Escaped reference
85
86
  */
86
- static escapeReference(reference) {
87
- if (reference === null || reference === undefined) {
88
- return '';
89
- }
87
+ static escapeReference(reference, options = {}) {
88
+ return escapeReference(reference, options);
89
+ }
90
90
 
91
- return formatReference(reference);
91
+ /** Decode an escaped reference, rather than an already parsed Link ID. */
92
+ static unescapeReference(reference) {
93
+ return unescapeReference(reference);
92
94
  }
93
95
 
94
96
  /**
package/src/Parser.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Link } from './Link.js';
2
+ import { LinksGroup } from './LinksGroup.js';
2
3
  import { ParseError } from './ParseError.js';
3
4
  import { stripComments } from './comments.js';
4
5
  import * as parserModule from './parser-generated.js';
@@ -31,6 +32,30 @@ export class Parser {
31
32
  * @throws {Error} If parsing fails
32
33
  */
33
34
  parse(input) {
35
+ return this._parse(input, (raw) => this.transformResult(raw));
36
+ }
37
+
38
+ /**
39
+ * Read the indentation tree before it is expanded into path combinations.
40
+ * Each group's element is the Link on that line; its children are groups
41
+ * for the immediately indented lines. Parenthesized values remain Links.
42
+ * @param {string} input - The Lino document
43
+ * @returns {LinksGroup[]}
44
+ */
45
+ parseGroups(input) {
46
+ return this._parse(input, (raw) =>
47
+ raw.map((item) => this._transformGroup(item))
48
+ );
49
+ }
50
+
51
+ _transformGroup(item) {
52
+ return new LinksGroup(
53
+ this.transformLink(item),
54
+ (item.children || []).map((child) => this._transformGroup(child))
55
+ );
56
+ }
57
+
58
+ _parse(input, transform) {
34
59
  // Validate input
35
60
  if (typeof input !== 'string') {
36
61
  throw new TypeError('Input must be a string');
@@ -50,7 +75,7 @@ export class Parser {
50
75
  const rawResult = parserModule.parse(prepared, {
51
76
  maxDepth: this.maxDepth,
52
77
  });
53
- return this.transformResult(rawResult);
78
+ return transform(rawResult);
54
79
  } catch (error) {
55
80
  // A syntax error knows where it stopped; anything else is passed on with
56
81
  // the original error kept as the cause.
package/src/index.js CHANGED
@@ -1,4 +1,9 @@
1
1
  export { Link, formatLinks } from './Link.js';
2
+ export { escapeReference, unescapeReference } from './references.js';
3
+ export {
4
+ parseIndentedDocument,
5
+ formatIndentedDocument,
6
+ } from './IndentedDocument.js';
2
7
  export {
3
8
  encodeReferenceLiteral,
4
9
  decodeReferenceLiteral,
@@ -0,0 +1,63 @@
1
+ import { QUOTES, readReference } from './quotes.js';
2
+ import {
3
+ decodeReferenceLiteral,
4
+ formatReference,
5
+ hasReferenceLiteralPrefix,
6
+ } from './ReferenceLiteral.js';
7
+
8
+ /**
9
+ * Quote one reference. Minimal mode allows ordinary single-space prose on a
10
+ * document line; token formatting still quotes spaces by default.
11
+ * @param {string|null|undefined} reference
12
+ * @param {{minimal?: boolean}} options
13
+ * @returns {string}
14
+ */
15
+ export function escapeReference(reference, { minimal = false } = {}) {
16
+ if (reference === null || reference === undefined) return '';
17
+ if (typeof reference !== 'string' || !reference.isWellFormed()) {
18
+ throw new TypeError('Reference must be well-formed Unicode text');
19
+ }
20
+ // Keep ordinary Link formatting identical to binary text formatting. The
21
+ // versioned literal also preserves empty strings and control characters in
22
+ // minimal document lines, including exact line endings.
23
+ if (!minimal || !reference || /[\u0000-\u001f\u007f]/u.test(reference)) {
24
+ return formatReference(reference);
25
+ }
26
+
27
+ const needsQuoting =
28
+ /(^| )~[0-9]+\{/.test(reference) ||
29
+ /[:()'"`]|(?! )[\p{White_Space}\ufeff]/u.test(reference) ||
30
+ /(^ | $| {2,}|(^| )#)/.test(reference);
31
+ if (!needsQuoting) return reference;
32
+
33
+ const singleQuotes = reference.split("'").length - 1;
34
+ const doubleQuotes = reference.split('"').length - 1;
35
+ let quote = singleQuotes <= doubleQuotes ? "'" : '"';
36
+
37
+ // The opening delimiter must be a run of exactly one quote. Choosing the
38
+ // other wrapper keeps a leading content quote from extending that run.
39
+ if (reference.startsWith(quote)) quote = quote === "'" ? '"' : "'";
40
+ return quote + reference.replaceAll(quote, quote + quote) + quote;
41
+ }
42
+
43
+ /**
44
+ * Decode a complete, escaped reference using the parser's delimiter rules.
45
+ * Plain text is returned unchanged. Parsed Link IDs are already decoded and
46
+ * should not be passed through this function a second time.
47
+ * @param {string} reference
48
+ * @returns {string}
49
+ */
50
+ export function unescapeReference(reference) {
51
+ if (typeof reference !== 'string') {
52
+ throw new TypeError('Reference must be a string');
53
+ }
54
+ if (hasReferenceLiteralPrefix(reference)) {
55
+ return decodeReferenceLiteral(reference);
56
+ }
57
+ if (!QUOTES.includes(reference[0])) return reference;
58
+ const reading = readReference(reference, 0);
59
+ if (reading === null || reading.length !== reference.length) {
60
+ throw new SyntaxError('Expected one complete quoted reference');
61
+ }
62
+ return reading.value;
63
+ }
@@ -0,0 +1,256 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { test, expect } from 'bun:test';
3
+ import * as lino from '../src/index.js';
4
+
5
+ const { Link, LinksGroup, Parser, ParseError } = lino;
6
+
7
+ test('mixed quotes round-trip through the existing Link formatter', () => {
8
+ const text = 'He said "it\'s ready"';
9
+ const formatted = new Link(text).toString();
10
+ expect(new Parser().parse(formatted)[0].values[0].id).toBe(text);
11
+ expect(formatted).not.toContain("\\'");
12
+ expect(lino.escapeReference(text, { minimal: true })).toBe(
13
+ "'He said \"it''s ready\"'"
14
+ );
15
+ });
16
+
17
+ test('minimal escaping leaves ordinary prose unquoted', () => {
18
+ expect(
19
+ Link.escapeReference('What is your experience?', { minimal: true })
20
+ ).toBe('What is your experience?');
21
+ expect(Link.escapeReference('What is your experience?')).toBe(
22
+ "'What is your experience?'"
23
+ );
24
+ });
25
+
26
+ test('public groups preserve immediate children and deeper indentation', () => {
27
+ const groups = new Parser().parseGroups(
28
+ 'What is your experience?\n JavaScript\n Python\n Django\nOther question\n Answer'
29
+ );
30
+ expect(groups).toHaveLength(2);
31
+ expect(groups[0]).toBeInstanceOf(LinksGroup);
32
+ expect(groups[0].element.values.map((value) => value.id)).toEqual([
33
+ 'What',
34
+ 'is',
35
+ 'your',
36
+ 'experience?',
37
+ ]);
38
+ expect(groups[0].children).toHaveLength(2);
39
+ expect(groups[0].children[1].children[0].element.values[0].id).toBe('Django');
40
+ expect(groups[1].children[0].element.values[0].id).toBe('Answer');
41
+ });
42
+
43
+ test('groups retain named and nested links and share parser diagnostics', () => {
44
+ const parser = new Parser();
45
+ const [root] = parser.parseGroups(
46
+ 'root:\n child: (one two)\n branch\n leaf'
47
+ );
48
+ expect(root.element.id).toBe('root');
49
+ expect(root.element.values).toEqual([]);
50
+ expect(root.children[0].element.toString()).toBe('(child: (one two))');
51
+ expect(root.children[1].children).toHaveLength(1);
52
+ expect(() => parser.parseGroups('root: child: value')).toThrow(ParseError);
53
+ expect(() =>
54
+ new Parser({ maxDepth: 0 }).parseGroups('root\n child')
55
+ ).toThrow(ParseError);
56
+ expect(() => new Parser({ maxInputSize: 2 }).parseGroups('root')).toThrow(
57
+ 'Input size exceeds'
58
+ );
59
+ expect(() => parser.parseGroups(null)).toThrow(TypeError);
60
+ });
61
+
62
+ test('parse the issue document without inspecting private path flags', () => {
63
+ const input = `What is your experience?
64
+ I have worked with JavaScript
65
+ 'Question with "quotes": and colon'
66
+ Answer
67
+ `;
68
+ expect(lino.parseIndentedDocument(input)).toEqual(
69
+ new Map([
70
+ ['What is your experience?', 'I have worked with JavaScript'],
71
+ ['Question with "quotes": and colon', 'Answer'],
72
+ ])
73
+ );
74
+ });
75
+
76
+ test('multiple child lines have explicit array or joined-text behavior', () => {
77
+ const input = 'Skills\n JavaScript\n Python\nUnanswered\nSkills\n Go';
78
+ expect(lino.parseIndentedDocument(input)).toEqual(
79
+ new Map([
80
+ ['Skills', ['JavaScript', 'Python', 'Go']],
81
+ ['Unanswered', []],
82
+ ])
83
+ );
84
+ expect(lino.parseIndentedDocument(input, { multipleValues: 'join' })).toEqual(
85
+ new Map([
86
+ ['Skills', 'JavaScript\nPython\nGo'],
87
+ ['Unanswered', []],
88
+ ])
89
+ );
90
+ expect(() =>
91
+ lino.parseIndentedDocument(input, { multipleValues: 'guess' })
92
+ ).toThrow(TypeError);
93
+ });
94
+
95
+ test('format the issue document with two-space indentation and a final newline', () => {
96
+ const entries = new Map([
97
+ ['What is your experience?', 'I have worked with JavaScript'],
98
+ ['Skills', ['JavaScript', 'Python']],
99
+ ]);
100
+ expect(lino.formatIndentedDocument(entries)).toBe(
101
+ 'What is your experience?\n I have worked with JavaScript\nSkills\n JavaScript\n Python\n'
102
+ );
103
+ expect(
104
+ lino.parseIndentedDocument(lino.formatIndentedDocument(entries))
105
+ ).toEqual(entries);
106
+ expect(lino.formatIndentedDocument(new Map())).toBe('');
107
+ expect(lino.parseIndentedDocument('')).toEqual(new Map());
108
+ });
109
+
110
+ test('empty text, exact whitespace, comments, quotes, and multiline text survive', () => {
111
+ const references = [
112
+ '',
113
+ ' ',
114
+ ' leading',
115
+ 'trailing ',
116
+ 'two spaces',
117
+ 'a\tb',
118
+ '# question',
119
+ 'text # literal',
120
+ 'issue#333',
121
+ 'Question: (details)',
122
+ '`backticks`',
123
+ '"double" and \'single\'',
124
+ '\'starts with both "quotes"',
125
+ "\"starts with both 'quotes'",
126
+ 'ends with both \'"',
127
+ 'repeated \'\'\' and """',
128
+ "literal \\n and \\'",
129
+ 'line one\n line two',
130
+ 'carriage\rreturn',
131
+ 'Windows\r\nline',
132
+ 'Привет 🌍',
133
+ ];
134
+ for (const text of references) {
135
+ const escaped = lino.escapeReference(text, { minimal: true });
136
+ expect(lino.unescapeReference(escaped)).toBe(text);
137
+ expect(Link.unescapeReference(escaped)).toBe(text);
138
+ const entries = new Map([[text, text]]);
139
+ expect(
140
+ lino.parseIndentedDocument(lino.formatIndentedDocument(entries))
141
+ ).toEqual(entries);
142
+ }
143
+ });
144
+
145
+ test('unescaping uses the grammar and never decodes already parsed IDs again', () => {
146
+ expect(lino.unescapeReference('"He said ""hello"""')).toBe('He said "hello"');
147
+ expect(lino.unescapeReference("'it''s ready'")).toBe("it's ready");
148
+ expect(lino.unescapeReference('``text with ` inside``')).toBe(
149
+ 'text with ` inside'
150
+ );
151
+ expect(lino.unescapeReference('plain text')).toBe('plain text');
152
+ expect(lino.unescapeReference('literal \\n')).toBe('literal \\n');
153
+ expect(() => lino.unescapeReference('"unfinished')).toThrow(SyntaxError);
154
+ expect(() => lino.unescapeReference('"value" extra')).toThrow(SyntaxError);
155
+ expect(lino.parseIndentedDocument('Question\n "two \'\' quotes"')).toEqual(
156
+ new Map([['Question', "two '' quotes"]])
157
+ );
158
+ });
159
+
160
+ test('document helpers reject deeper or named value structures instead of losing data', () => {
161
+ expect(() =>
162
+ lino.parseIndentedDocument('Question\n Answer\n Detail')
163
+ ).toThrow(TypeError);
164
+ expect(() => lino.parseIndentedDocument('Question\n answer: value')).toThrow(
165
+ TypeError
166
+ );
167
+ expect(() =>
168
+ lino.formatIndentedDocument(new Map([['Question', 42]]))
169
+ ).toThrow(TypeError);
170
+ expect(() => lino.formatIndentedDocument(new Map([[42, 'Answer']]))).toThrow(
171
+ TypeError
172
+ );
173
+ expect(() =>
174
+ lino.formatIndentedDocument(new Map([['Question', ['ok', null]]]))
175
+ ).toThrow(TypeError);
176
+ });
177
+
178
+ test('document parsing respects comments and existing parser options', () => {
179
+ expect(
180
+ lino.parseIndentedDocument('# comment\nQuestion\n Answer # comment')
181
+ ).toEqual(new Map([['Question', 'Answer']]));
182
+ expect(
183
+ lino.parseIndentedDocument('# question\n Answer', { comments: false })
184
+ ).toEqual(new Map([['# question', 'Answer']]));
185
+ });
186
+
187
+ test('one-element arrays normalize to strings and unanswered parents remain distinct', () => {
188
+ const entries = new Map([
189
+ ['One option', ['only']],
190
+ ['No options', []],
191
+ ['Empty answer', ''],
192
+ ]);
193
+ expect(
194
+ lino.parseIndentedDocument(lino.formatIndentedDocument(entries))
195
+ ).toEqual(
196
+ new Map([
197
+ ['One option', 'only'],
198
+ ['No options', []],
199
+ ['Empty answer', ''],
200
+ ])
201
+ );
202
+ expect(() =>
203
+ lino.formatIndentedDocument(new Map([['Question', Array(1)]]))
204
+ ).toThrow(TypeError);
205
+ });
206
+
207
+ test('document helpers preserve the shared Unicode reference corpus and reserved prefixes', () => {
208
+ const references = readFileSync(
209
+ new globalThis.URL(
210
+ '../../docs/protocol/reference-literals.txt',
211
+ import.meta.url
212
+ ),
213
+ 'utf8'
214
+ )
215
+ .trim()
216
+ .split('\n')
217
+ .filter((line) => !line.startsWith('#'))
218
+ .map((hex) =>
219
+ hex === '-' ? '' : Buffer.from(hex, 'hex').toString('utf8')
220
+ );
221
+ for (const text of [
222
+ ...references,
223
+ '~1{61}',
224
+ '~2{00}',
225
+ '~1{invalid}',
226
+ 'text ~1{61} after',
227
+ 'text ~2{00}',
228
+ 'text ~1{invalid}',
229
+ ]) {
230
+ expect(lino.escapeReference(text)).toBe(lino.formatBinaryReference(text));
231
+ expect(lino.unescapeReference(lino.escapeReference(text))).toBe(text);
232
+ expect(
233
+ lino.unescapeReference(lino.escapeReference(text, { minimal: true }))
234
+ ).toBe(text);
235
+ const entries = new Map([[text, text]]);
236
+ expect(
237
+ lino.parseIndentedDocument(lino.formatIndentedDocument(entries))
238
+ ).toEqual(entries);
239
+ }
240
+ });
241
+
242
+ test('unescaping decodes versioned literals without decoding parsed document IDs twice', () => {
243
+ expect(lino.unescapeReference('~1{}')).toBe('');
244
+ expect(Link.unescapeReference('~1{00}')).toBe('\0');
245
+ expect(lino.unescapeReference('~1{C3A9}')).toBe('é');
246
+ for (const literal of ['~2{61}', '~1{0}', '~1{gg}', '~1{ff}']) {
247
+ expect(() => lino.unescapeReference(literal)).toThrow();
248
+ }
249
+ const entries = new Map([['~1{61}', '~1{62}']]);
250
+ expect(
251
+ lino.parseIndentedDocument(lino.formatIndentedDocument(entries))
252
+ ).toEqual(entries);
253
+ expect(() => lino.escapeReference('\ud800', { minimal: true })).toThrow(
254
+ TypeError
255
+ );
256
+ });