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.
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. */
@@ -561,3 +613,8 @@ export class BinaryLinoCodec {
561
613
  }
562
614
  export function formatBinaryDocument(document: Link[]): string;
563
615
  export function formatBinaryReference(reference: string): string;
616
+
617
+ /** Encode exact Unicode as a version 1 UTF-8 hex literal (~1{...}). Throws on unpaired surrogates. */
618
+ export function encodeReferenceLiteral(text: string): string;
619
+ /** Decode a complete literal. Throws on malformed hex, UTF-8, or unsupported versions. */
620
+ export function decodeReferenceLiteral(literal: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
package/src/Binary.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Binary links notation v1. Packet and document layers share no transport. */
2
2
  import { Link } from './Link.js';
3
+ import { formatReference } from './ReferenceLiteral.js';
3
4
  import { Parser } from './Parser.js';
4
5
 
5
6
  const U64 = (1n << 64n) - 1n;
@@ -695,26 +696,7 @@ function canonical(node) {
695
696
  return new Link(node.id, node.values.map(canonical));
696
697
  }
697
698
  export function formatBinaryReference(text) {
698
- if (
699
- text &&
700
- !text.startsWith('#') &&
701
- !/[\p{White_Space}\u001c-\u001f\ufeff():"'`]/u.test(text)
702
- )
703
- return text;
704
- let choice = null;
705
- for (const quote of ["'", '"', '`'])
706
- if (!text.startsWith(quote)) {
707
- let longest = 0,
708
- run = 0;
709
- for (const c of text) {
710
- run = c === quote ? run + 1 : 0;
711
- longest = Math.max(longest, run);
712
- }
713
- const count = (longest + 1) | 1;
714
- if (choice === null || count < choice.count) choice = { quote, count };
715
- }
716
- const delimiter = choice.quote.repeat(choice.count);
717
- return delimiter + text + delimiter;
699
+ return formatReference(text);
718
700
  }
719
701
  export function formatBinaryDocument(document) {
720
702
  function nested(node, top = false) {
@@ -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,3 +1,5 @@
1
+ import { escapeReference, unescapeReference } from './references.js';
2
+
1
3
  export class Link {
2
4
  /**
3
5
  * Create a new Link
@@ -79,60 +81,16 @@ export class Link {
79
81
  /**
80
82
  * Escape a reference string by adding quotes if necessary
81
83
  * @param {string} reference - The reference to escape
84
+ * @param {{minimal?: boolean}} options - Allow plain-text document lines
82
85
  * @returns {string} Escaped reference
83
86
  */
84
- static escapeReference(reference) {
85
- if (reference === null || reference === undefined) {
86
- return '';
87
- }
88
-
89
- // The empty reference is written as a bare delimiter pair, so that it reads
90
- // back as itself instead of disappearing from the document.
91
- if (reference === '') {
92
- return '""';
93
- }
94
-
95
- const hasSingleQuote = reference.includes("'");
96
- const hasDoubleQuote = reference.includes('"');
97
-
98
- // A reference that begins with a `#` has to be quoted, or it would read
99
- // back as a comment. A `#` anywhere else in a reference is content
100
- // (`issue#1047`), so only the first character matters.
101
- const needsQuoting =
102
- reference.startsWith('#') ||
103
- reference.includes(':') ||
104
- reference.includes('(') ||
105
- reference.includes(')') ||
106
- reference.includes(' ') ||
107
- reference.includes('\t') ||
108
- reference.includes('\n') ||
109
- reference.includes('\r') ||
110
- hasDoubleQuote ||
111
- hasSingleQuote;
112
-
113
- // Handle edge case: reference contains both single and double quotes
114
- if (hasSingleQuote && hasDoubleQuote) {
115
- // Escape single quotes and wrap in single quotes
116
- return `'${reference.replace(/'/g, "\\'")}'`;
117
- }
118
-
119
- // Prefer single quotes if double quotes are present
120
- if (hasDoubleQuote) {
121
- return `'${reference}'`;
122
- }
123
-
124
- // Use double quotes if single quotes are present
125
- if (hasSingleQuote) {
126
- return `"${reference}"`;
127
- }
128
-
129
- // Use single quotes for special characters
130
- if (needsQuoting) {
131
- return `'${reference}'`;
132
- }
87
+ static escapeReference(reference, options = {}) {
88
+ return escapeReference(reference, options);
89
+ }
133
90
 
134
- // No quoting needed
135
- return reference;
91
+ /** Decode an escaped reference, rather than an already parsed Link ID. */
92
+ static unescapeReference(reference) {
93
+ return unescapeReference(reference);
136
94
  }
137
95
 
138
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.
@@ -0,0 +1,66 @@
1
+ /* global TextEncoder, TextDecoder */
2
+
3
+ /** Detect the reserved versioned-literal prefix at a reference boundary. */
4
+ export function hasReferenceLiteralPrefix(text, start = 0) {
5
+ return /^~[0-9]+\{/.test(text.slice(start));
6
+ }
7
+
8
+ /** Encode exact Unicode text as a version 1 UTF-8 hexadecimal reference. */
9
+ export function encodeReferenceLiteral(text) {
10
+ if (typeof text !== 'string' || !text.isWellFormed()) {
11
+ throw new TypeError('Reference must be well-formed Unicode text');
12
+ }
13
+ const hex = Array.from(new TextEncoder().encode(text), (byte) =>
14
+ byte.toString(16).padStart(2, '0')
15
+ ).join('');
16
+ return `~1{${hex}}`;
17
+ }
18
+
19
+ /** Decode a complete versioned literal, rejecting malformed bytes and versions. */
20
+ export function decodeReferenceLiteral(literal) {
21
+ const match = /^~([0-9]+)\{([0-9a-fA-F]*)\}$/.exec(literal);
22
+ if (
23
+ !match ||
24
+ match[0] !== literal ||
25
+ match[1] !== '1' ||
26
+ match[2].length % 2 !== 0
27
+ ) {
28
+ throw new TypeError(
29
+ 'Invalid or unsupported reference literal (expected ~1{UTF-8 hex})'
30
+ );
31
+ }
32
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
33
+ Uint8Array.from(match[2].match(/../g) ?? [], (hex) => parseInt(hex, 16))
34
+ );
35
+ }
36
+
37
+ /** Use readable legacy quoting where possible and a literal for empty/control text. */
38
+ export function formatReference(text) {
39
+ if (typeof text !== 'string' || !text.isWellFormed()) {
40
+ throw new TypeError('Reference must be well-formed Unicode text');
41
+ }
42
+ if (!text || /[\u0000-\u001f\u007f]/u.test(text)) {
43
+ return encodeReferenceLiteral(text);
44
+ }
45
+ if (
46
+ !text.startsWith('#') &&
47
+ !/^~[0-9]+\{/.test(text) &&
48
+ !/[\p{White_Space}\ufeff():"'`]/u.test(text)
49
+ ) {
50
+ return text;
51
+ }
52
+ let choice = null;
53
+ for (const quote of ["'", '"', '`']) {
54
+ if (text.startsWith(quote)) continue;
55
+ let longest = 0;
56
+ let run = 0;
57
+ for (const character of text) {
58
+ run = character === quote ? run + 1 : 0;
59
+ longest = Math.max(longest, run);
60
+ }
61
+ const count = (longest + 1) | 1;
62
+ if (choice === null || count < choice.count) choice = { quote, count };
63
+ }
64
+ const delimiter = choice.quote.repeat(choice.count);
65
+ return delimiter + text + delimiter;
66
+ }
package/src/grammar.pegjs CHANGED
@@ -1,5 +1,6 @@
1
1
  {{
2
2
  import { DelimitedReferences } from './quotes.js';
3
+ import { decodeReferenceLiteral, hasReferenceLiteralPrefix } from './ReferenceLiteral.js';
3
4
  }}
4
5
 
5
6
  {
@@ -217,9 +218,18 @@ indentedIdLink = id:reference __ ":" eol { return { id: id, values: [] }; }
217
218
 
218
219
  // Reference can be quoted (with any number of quotes N >= 1) or simple unquoted
219
220
  // Universal approach: use procedural parsing for all quote types and counts
220
- reference = quotedReference / simpleReference
221
+ reference = referenceLiteral / quotedReference / simpleReference
222
+
223
+ // Versioned literals reserve ~<version>{<UTF-8 hex>} at a reference boundary.
224
+ // A malformed reserved token must fail rather than fall back to a bare name.
225
+ referenceLiteral = &'~' &{
226
+ return hasReferenceLiteralPrefix(input, offset());
227
+ } literal:$('~' [0-9]+ '{' [^} \t\r\n()]* '}') {
228
+ return decodeReferenceLiteral(literal);
229
+ }
221
230
 
222
- simpleReference = chars:referenceSymbol+ { return chars.join(''); }
231
+ simpleReference = &{ return !hasReferenceLiteralPrefix(input, offset()); }
232
+ chars:referenceSymbol+ { return chars.join(''); }
223
233
 
224
234
  // Universal quoted reference - handles any N quotes for all quote types
225
235
  // Uses procedural parsing with input/offset() for clean, simple logic
package/src/index.js CHANGED
@@ -1,4 +1,13 @@
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';
7
+ export {
8
+ encodeReferenceLiteral,
9
+ decodeReferenceLiteral,
10
+ } from './ReferenceLiteral.js';
2
11
  export { LinksGroup } from './LinksGroup.js';
3
12
  export { Parser, DEFAULT_MAX_DEPTH } from './Parser.js';
4
13
  export { StreamParseError, StreamParser } from './StreamParser.js';