links-notation 0.23.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.
@@ -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
+ }
@@ -23,7 +23,9 @@ test('binary text quotes every Unicode grammar whitespace character', () => {
23
23
  '\u001f',
24
24
  '\ufeff',
25
25
  ]) {
26
- expect(formatBinaryReference(text)).toBe("'" + text + "'");
26
+ expect(
27
+ new Parser().parse(`(root: ${formatBinaryReference(text)})`)
28
+ ).toEqual([new Link('root', [new Link(text)])]);
27
29
  }
28
30
  });
29
31
 
@@ -86,6 +86,6 @@ test('TestEmptyReferenceSurvivesARoundTrip', () => {
86
86
  }
87
87
  });
88
88
 
89
- test('TestEmptyReferenceIsWrittenAsADelimiterPair', () => {
90
- expect(formatLinks(parser.parse('(a "" b)'))).toBe('(a "" b)');
89
+ test('TestEmptyReferenceIsWrittenAsAVersionedLiteral', () => {
90
+ expect(formatLinks(parser.parse('(a "" b)'))).toBe('(a ~1{} b)');
91
91
  });
@@ -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
+ });
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { expect, test } from 'bun:test';
3
+ import {
4
+ BinaryLinoCodec,
5
+ decodeReferenceLiteral,
6
+ encodeReferenceLiteral,
7
+ formatBinaryReference,
8
+ formatLinks,
9
+ FormatConfig,
10
+ Link,
11
+ Parser,
12
+ StreamParser,
13
+ } from '../src/index.js';
14
+
15
+ const references = readFileSync(
16
+ new globalThis.URL(
17
+ '../../docs/protocol/reference-literals.txt',
18
+ import.meta.url
19
+ ),
20
+ 'utf8'
21
+ )
22
+ .trim()
23
+ .split('\n')
24
+ .filter((line) => !line.startsWith('#'))
25
+ .map((hex) => (hex === '-' ? '' : Buffer.from(hex, 'hex').toString('utf8')));
26
+
27
+ test('native reference fidelity', () => {
28
+ for (const id of references) {
29
+ const original = new Link(id, [
30
+ new Link(id),
31
+ new Link('nested', [new Link(id)]),
32
+ ]);
33
+ for (const lessParentheses of [
34
+ false,
35
+ true,
36
+ new FormatConfig({ maxInlineRefs: 1, preferInline: false }),
37
+ ]) {
38
+ const text = formatLinks([original], lessParentheses);
39
+ expect(new Parser().parse(text)).toEqual([original]);
40
+ }
41
+ }
42
+ });
43
+
44
+ test('reference literals at every chunk split', () => {
45
+ for (const id of references) {
46
+ expect(decodeReferenceLiteral(encodeReferenceLiteral(id))).toBe(id);
47
+ for (const literal of [
48
+ Link.escapeReference(id),
49
+ formatBinaryReference(id),
50
+ encodeReferenceLiteral(id),
51
+ ]) {
52
+ const text = `(root: ${literal})\n(${literal}: fixture)`;
53
+ const expected = [
54
+ new Link('root', [new Link(id)]),
55
+ new Link(id, [new Link('fixture')]),
56
+ ];
57
+ expect(new Parser().parse(text)).toEqual(expected);
58
+ for (let split = 0; split <= text.length; split++) {
59
+ const stream = new StreamParser();
60
+ stream.write(text.slice(0, split));
61
+ stream.write(text.slice(split));
62
+ expect(stream.end()).toEqual(expected);
63
+ }
64
+ }
65
+ }
66
+ }, 10000);
67
+
68
+ test('binary reference fidelity', () => {
69
+ const codec = new BinaryLinoCodec();
70
+ for (const id of references) {
71
+ const original = [new Link(id, [new Link(id)])];
72
+ const decoded = codec.decode(codec.encode(original));
73
+ expect(decoded).toEqual(original);
74
+ expect(codec.parseDocument(codec.formatDocument(decoded))).toEqual(
75
+ original
76
+ );
77
+ }
78
+ });
79
+
80
+ test('malformed reference literals are rejected', () => {
81
+ const invalid = readFileSync(
82
+ new globalThis.URL(
83
+ '../../docs/protocol/invalid-reference-literals.txt',
84
+ import.meta.url
85
+ ),
86
+ 'utf8'
87
+ )
88
+ .trim()
89
+ .split('\n')
90
+ .filter((line) => !line.startsWith('#'));
91
+ for (const literal of invalid) {
92
+ expect(() => decodeReferenceLiteral(literal)).toThrow();
93
+ expect(() => new Parser().parse(literal)).toThrow();
94
+ }
95
+ expect(() => decodeReferenceLiteral('~1{61}\n')).toThrow();
96
+ expect(decodeReferenceLiteral('~1{C3A9}')).toBe('é');
97
+ expect(() => encodeReferenceLiteral('\ud800')).toThrow();
98
+ expect(() => Link.escapeReference('\ud800')).toThrow();
99
+ });