links-notation 0.24.0 → 0.25.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/README.md +83 -0
- package/README.ru.md +48 -0
- package/dist/index.js +271 -145
- package/index.d.ts +53 -1
- package/package.json +1 -1
- package/src/IndentedDocument.js +88 -0
- package/src/Link.js +8 -6
- package/src/Parser.js +26 -1
- package/src/index.js +5 -0
- package/src/references.js +63 -0
- package/tests/IndentedDocument.test.js +256 -0
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
87
|
+
static escapeReference(reference, options = {}) {
|
|
88
|
+
return escapeReference(reference, options);
|
|
89
|
+
}
|
|
90
90
|
|
|
91
|
-
|
|
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
|
|
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
|
+
});
|