links-notation 0.23.0 → 0.24.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 +3 -0
- package/dist/index.js +327 -125
- package/index.d.ts +5 -0
- package/package.json +1 -1
- package/src/Binary.js +2 -20
- package/src/Link.js +3 -47
- package/src/ReferenceLiteral.js +66 -0
- package/src/grammar.pegjs +12 -2
- package/src/index.js +4 -0
- package/src/parser-generated.js +262 -89
- package/tests/Binary.test.js +3 -1
- package/tests/EmptyReference.test.js +2 -2
- package/tests/ReferenceFidelity.test.js +99 -0
package/index.d.ts
CHANGED
|
@@ -561,3 +561,8 @@ export class BinaryLinoCodec {
|
|
|
561
561
|
}
|
|
562
562
|
export function formatBinaryDocument(document: Link[]): string;
|
|
563
563
|
export function formatBinaryReference(reference: string): string;
|
|
564
|
+
|
|
565
|
+
/** Encode exact Unicode as a version 1 UTF-8 hex literal (~1{...}). Throws on unpaired surrogates. */
|
|
566
|
+
export function encodeReferenceLiteral(text: string): string;
|
|
567
|
+
/** Decode a complete literal. Throws on malformed hex, UTF-8, or unsupported versions. */
|
|
568
|
+
export function decodeReferenceLiteral(literal: string): string;
|
package/package.json
CHANGED
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
|
-
|
|
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) {
|
package/src/Link.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formatReference } from './ReferenceLiteral.js';
|
|
2
|
+
|
|
1
3
|
export class Link {
|
|
2
4
|
/**
|
|
3
5
|
* Create a new Link
|
|
@@ -86,53 +88,7 @@ export class Link {
|
|
|
86
88
|
return '';
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
|
|
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
|
-
}
|
|
133
|
-
|
|
134
|
-
// No quoting needed
|
|
135
|
-
return reference;
|
|
91
|
+
return formatReference(reference);
|
|
136
92
|
}
|
|
137
93
|
|
|
138
94
|
/**
|
|
@@ -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 =
|
|
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,8 @@
|
|
|
1
1
|
export { Link, formatLinks } from './Link.js';
|
|
2
|
+
export {
|
|
3
|
+
encodeReferenceLiteral,
|
|
4
|
+
decodeReferenceLiteral,
|
|
5
|
+
} from './ReferenceLiteral.js';
|
|
2
6
|
export { LinksGroup } from './LinksGroup.js';
|
|
3
7
|
export { Parser, DEFAULT_MAX_DEPTH } from './Parser.js';
|
|
4
8
|
export { StreamParseError, StreamParser } from './StreamParser.js';
|