@sdxc/structured-fields 0.0.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ import { failure, success } from "@sdxc/result";
2
+ import { decodeBase64 } from "./base64.js";
3
+ import { StructuredFieldParseError } from "./errors.js";
4
+ import { Decimal, DisplayString, Token } from "./values.js";
5
+ /** Returned by a step that failed; the reason sits on the cursor. */
6
+ const FAILED = Symbol("failed");
7
+ /** Characters a Token may continue with: `tchar`, `:` and `/`. */
8
+ const TOKEN_CHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z:/]$/;
9
+ /** Characters a key may continue with. */
10
+ const KEY_CHAR = /^[a-z0-9_\-.*]$/;
11
+ /** Characters a Byte Sequence's base64 content may use. */
12
+ const BASE64_CONTENT = /^[A-Za-z0-9+/=]*$/;
13
+ /** Anything outside visible ASCII and space, which a field value may not carry. */
14
+ const NON_ASCII = /[^\x20-\x7e\t]/;
15
+ /** Lowercase hex digits, the only form a Display String's percent-escapes may take. */
16
+ const LOWER_HEX = /^[0-9a-f]{2}$/;
17
+ /** Decodes a Display String's octets, refusing invalid UTF-8 and keeping a leading BOM. */
18
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
19
+ /**
20
+ * The parser's position in the field value, and the first failure it met.
21
+ */
22
+ class Cursor {
23
+ text;
24
+ position = 0;
25
+ error = null;
26
+ /**
27
+ * @param text - The field value, several lines already joined with `,`
28
+ */
29
+ constructor(text) {
30
+ this.text = text;
31
+ }
32
+ /** @returns The next character without consuming it, `""` at the end */
33
+ peek() {
34
+ return this.text.charAt(this.position);
35
+ }
36
+ /** @returns The next character, consumed, `""` at the end */
37
+ next() {
38
+ let char = this.text.charAt(this.position);
39
+ this.position++;
40
+ return char;
41
+ }
42
+ /** @returns Whether every character has been consumed */
43
+ get done() {
44
+ return this.position >= this.text.length;
45
+ }
46
+ /** Skips spaces, the only whitespace allowed between most tokens. */
47
+ skipSpaces() {
48
+ while (this.peek() === " ")
49
+ this.position++;
50
+ }
51
+ /** Skips optional whitespace (spaces and tabs), allowed around List and Dictionary commas. */
52
+ skipOWS() {
53
+ while (this.peek() === " " || this.peek() === "\t")
54
+ this.position++;
55
+ }
56
+ /**
57
+ * Records the failure and returns the marker, so a step writes `return cursor.fail(…)`.
58
+ *
59
+ * @param message - Why parsing stopped
60
+ * @returns The failure marker
61
+ */
62
+ fail(message) {
63
+ this.error ??= new StructuredFieldParseError(message, this.position);
64
+ return FAILED;
65
+ }
66
+ }
67
+ /**
68
+ * Parses a field value as its definition's top-level type (RFC 9651 section 4.2).
69
+ *
70
+ * @param text - The field value, several field lines joined with `,`
71
+ * @param type - The top-level type the field's definition fixes
72
+ * @returns The model, or where the text stops being valid
73
+ */
74
+ export function parseField(text, type) {
75
+ let cursor = new Cursor(text);
76
+ let invalid = NON_ASCII.exec(text);
77
+ if (invalid) {
78
+ cursor.position = invalid.index;
79
+ cursor.fail("Field value contains a character outside visible ASCII");
80
+ return failure(cursor.error ?? new StructuredFieldParseError("Invalid field", 0));
81
+ }
82
+ cursor.skipSpaces();
83
+ let value;
84
+ if (type === "list")
85
+ value = parseList(cursor);
86
+ else if (type === "dictionary")
87
+ value = parseDictionary(cursor);
88
+ else
89
+ value = parseItem(cursor);
90
+ if (value !== FAILED) {
91
+ cursor.skipSpaces();
92
+ if (!cursor.done)
93
+ value = cursor.fail("Unexpected character after the field value");
94
+ }
95
+ if (value === FAILED) {
96
+ return failure(cursor.error ?? new StructuredFieldParseError("Invalid field", cursor.position));
97
+ }
98
+ return success(value);
99
+ }
100
+ /**
101
+ * Parses a List (section 4.2.1); a trailing comma fails, an empty value is an empty List.
102
+ *
103
+ * @param cursor - The parser state
104
+ * @returns The members in field order
105
+ */
106
+ function parseList(cursor) {
107
+ let members = [];
108
+ while (!cursor.done) {
109
+ let member = parseMember(cursor);
110
+ if (member === FAILED)
111
+ return FAILED;
112
+ members.push(member);
113
+ cursor.skipOWS();
114
+ if (cursor.done)
115
+ return members;
116
+ if (cursor.next() !== ",")
117
+ return cursor.fail("Expected `,` between List members");
118
+ cursor.skipOWS();
119
+ if (cursor.done)
120
+ return cursor.fail("Trailing `,` after the last List member");
121
+ }
122
+ return members;
123
+ }
124
+ /**
125
+ * Parses a Dictionary (section 4.2.2). A key without `=` is the Boolean `true`, and a
126
+ * repeated key overwrites the earlier value in the earlier position, which assignment onto
127
+ * a null-prototype object does by itself.
128
+ *
129
+ * @param cursor - The parser state
130
+ * @returns The members keyed in field order
131
+ */
132
+ function parseDictionary(cursor) {
133
+ let dictionary = Object.create(null);
134
+ while (!cursor.done) {
135
+ let key = parseKey(cursor);
136
+ if (key === FAILED)
137
+ return FAILED;
138
+ let member;
139
+ if (cursor.peek() === "=") {
140
+ cursor.next();
141
+ member = parseMember(cursor);
142
+ }
143
+ else {
144
+ let params = parseParameters(cursor);
145
+ member = params === FAILED ? FAILED : { value: true, params };
146
+ }
147
+ if (member === FAILED)
148
+ return FAILED;
149
+ dictionary[key] = member;
150
+ cursor.skipOWS();
151
+ if (cursor.done)
152
+ return dictionary;
153
+ if (cursor.next() !== ",")
154
+ return cursor.fail("Expected `,` between Dictionary members");
155
+ cursor.skipOWS();
156
+ if (cursor.done)
157
+ return cursor.fail("Trailing `,` after the last Dictionary member");
158
+ }
159
+ return dictionary;
160
+ }
161
+ /**
162
+ * Parses an Item or an Inner List, whichever the next character starts.
163
+ *
164
+ * @param cursor - The parser state
165
+ * @returns The member
166
+ */
167
+ function parseMember(cursor) {
168
+ return cursor.peek() === "(" ? parseInnerList(cursor) : parseItem(cursor);
169
+ }
170
+ /**
171
+ * Parses an Inner List (section 4.2.1.2): items separated by spaces inside parentheses,
172
+ * then the list's own parameters.
173
+ *
174
+ * @param cursor - The parser state
175
+ * @returns The Inner List
176
+ */
177
+ function parseInnerList(cursor) {
178
+ cursor.next();
179
+ let items = [];
180
+ while (!cursor.done) {
181
+ cursor.skipSpaces();
182
+ if (cursor.peek() === ")") {
183
+ cursor.next();
184
+ let params = parseParameters(cursor);
185
+ if (params === FAILED)
186
+ return FAILED;
187
+ return { items, params };
188
+ }
189
+ let item = parseItem(cursor);
190
+ if (item === FAILED)
191
+ return FAILED;
192
+ items.push(item);
193
+ let after = cursor.peek();
194
+ if (after !== " " && after !== ")")
195
+ return cursor.fail("Expected a space or `)` in Inner List");
196
+ }
197
+ return cursor.fail("Inner List is missing its closing `)`");
198
+ }
199
+ /**
200
+ * Parses an Item (section 4.2.3): a bare item, then its parameters.
201
+ *
202
+ * @param cursor - The parser state
203
+ * @returns The Item
204
+ */
205
+ function parseItem(cursor) {
206
+ let value = parseBareItem(cursor);
207
+ if (value === FAILED)
208
+ return FAILED;
209
+ let params = parseParameters(cursor);
210
+ if (params === FAILED)
211
+ return FAILED;
212
+ return { value, params };
213
+ }
214
+ /**
215
+ * Parses Parameters (section 4.2.3.2); a key without `=` is the Boolean `true`, and a
216
+ * repeated key overwrites the earlier value in the earlier position.
217
+ *
218
+ * @param cursor - The parser state
219
+ * @returns The parameters in field order, on a null-prototype object
220
+ */
221
+ function parseParameters(cursor) {
222
+ let params = Object.create(null);
223
+ while (cursor.peek() === ";") {
224
+ cursor.next();
225
+ cursor.skipSpaces();
226
+ let key = parseKey(cursor);
227
+ if (key === FAILED)
228
+ return FAILED;
229
+ let value = true;
230
+ if (cursor.peek() === "=") {
231
+ cursor.next();
232
+ value = parseBareItem(cursor);
233
+ if (value === FAILED)
234
+ return FAILED;
235
+ }
236
+ params[key] = value;
237
+ }
238
+ return params;
239
+ }
240
+ /**
241
+ * Parses a key (section 4.2.3.3): a lowercase letter or `*`, then lowercase letters,
242
+ * digits, `_`, `-`, `.` and `*`.
243
+ *
244
+ * @param cursor - The parser state
245
+ * @returns The key
246
+ */
247
+ function parseKey(cursor) {
248
+ let first = cursor.peek();
249
+ if (!/^[a-z*]$/.test(first))
250
+ return cursor.fail("Key must start with a lowercase letter or `*`");
251
+ let start = cursor.position;
252
+ while (KEY_CHAR.test(cursor.peek()))
253
+ cursor.position++;
254
+ return cursor.text.slice(start, cursor.position);
255
+ }
256
+ /**
257
+ * Parses a bare item (section 4.2.3.1), choosing the type from its first character.
258
+ *
259
+ * @param cursor - The parser state
260
+ * @returns The bare item
261
+ */
262
+ function parseBareItem(cursor) {
263
+ let char = cursor.peek();
264
+ if (char === "-" || /^[0-9]$/.test(char))
265
+ return parseNumber(cursor);
266
+ if (char === '"')
267
+ return parseString(cursor);
268
+ if (char === "*" || /^[A-Za-z]$/.test(char))
269
+ return parseToken(cursor);
270
+ if (char === ":")
271
+ return parseByteSequence(cursor);
272
+ if (char === "?")
273
+ return parseBoolean(cursor);
274
+ if (char === "@")
275
+ return parseDate(cursor);
276
+ if (char === "%")
277
+ return parseDisplayString(cursor);
278
+ return cursor.fail("Expected a bare item");
279
+ }
280
+ /**
281
+ * Parses an Integer or Decimal (section 4.2.4): at most 15 digits for an Integer, at most
282
+ * 12 integer and 3 fractional digits for a Decimal. Negative zero reads as zero.
283
+ *
284
+ * @param cursor - The parser state
285
+ * @returns A `number` for an Integer, a `Decimal` for a Decimal
286
+ */
287
+ function parseNumber(cursor) {
288
+ let sign = 1;
289
+ if (cursor.peek() === "-") {
290
+ cursor.next();
291
+ sign = -1;
292
+ }
293
+ if (!/^[0-9]$/.test(cursor.peek()))
294
+ return cursor.fail("Expected a digit");
295
+ let digits = "";
296
+ let isDecimal = false;
297
+ while (!cursor.done) {
298
+ let char = cursor.peek();
299
+ if (/^[0-9]$/.test(char)) {
300
+ digits += char;
301
+ }
302
+ else if (!isDecimal && char === ".") {
303
+ if (digits.length > 12)
304
+ return cursor.fail("Decimal has more than 12 integer digits");
305
+ digits += char;
306
+ isDecimal = true;
307
+ }
308
+ else {
309
+ break;
310
+ }
311
+ cursor.next();
312
+ if (!isDecimal && digits.length > 15)
313
+ return cursor.fail("Integer has more than 15 digits");
314
+ if (isDecimal && digits.length > 16)
315
+ return cursor.fail("Decimal has too many digits");
316
+ }
317
+ if (!isDecimal)
318
+ return sign * Number(digits) + 0;
319
+ if (digits.endsWith("."))
320
+ return cursor.fail("Decimal ends with `.`");
321
+ if (digits.length - digits.indexOf(".") - 1 > 3) {
322
+ return cursor.fail("Decimal has more than 3 fractional digits");
323
+ }
324
+ return new Decimal(sign * Number(digits) + 0);
325
+ }
326
+ /**
327
+ * Parses a String (section 4.2.5): visible ASCII and space between double quotes, with
328
+ * only `"` and `\` escaped.
329
+ *
330
+ * @param cursor - The parser state
331
+ * @returns The unescaped string
332
+ */
333
+ function parseString(cursor) {
334
+ cursor.next();
335
+ let output = "";
336
+ while (!cursor.done) {
337
+ let char = cursor.next();
338
+ if (char === "\\") {
339
+ if (cursor.done)
340
+ return cursor.fail("String ends inside an escape");
341
+ let escaped = cursor.next();
342
+ if (escaped !== '"' && escaped !== "\\")
343
+ return cursor.fail("Invalid escape in String");
344
+ output += escaped;
345
+ }
346
+ else if (char === '"') {
347
+ return output;
348
+ }
349
+ else if (char < " " || char > "~") {
350
+ return cursor.fail("String contains a character outside visible ASCII");
351
+ }
352
+ else {
353
+ output += char;
354
+ }
355
+ }
356
+ return cursor.fail('String is missing its closing `"`');
357
+ }
358
+ /**
359
+ * Parses a Token (section 4.2.6).
360
+ *
361
+ * @param cursor - The parser state
362
+ * @returns The Token
363
+ */
364
+ function parseToken(cursor) {
365
+ let start = cursor.position;
366
+ cursor.next();
367
+ while (TOKEN_CHAR.test(cursor.peek()))
368
+ cursor.position++;
369
+ return new Token(cursor.text.slice(start, cursor.position));
370
+ }
371
+ /**
372
+ * Parses a Byte Sequence (section 4.2.7): base64 between colons.
373
+ *
374
+ * @param cursor - The parser state
375
+ * @returns The bytes
376
+ */
377
+ function parseByteSequence(cursor) {
378
+ cursor.next();
379
+ let end = cursor.text.indexOf(":", cursor.position);
380
+ if (end === -1)
381
+ return cursor.fail("Byte Sequence is missing its closing `:`");
382
+ let content = cursor.text.slice(cursor.position, end);
383
+ if (!BASE64_CONTENT.test(content))
384
+ return cursor.fail("Byte Sequence is not base64");
385
+ let bytes = decodeBase64(content);
386
+ if (bytes === null)
387
+ return cursor.fail("Byte Sequence is not valid base64");
388
+ cursor.position = end + 1;
389
+ return bytes;
390
+ }
391
+ /**
392
+ * Parses a Boolean (section 4.2.8): `?1` or `?0`.
393
+ *
394
+ * @param cursor - The parser state
395
+ * @returns The Boolean
396
+ */
397
+ function parseBoolean(cursor) {
398
+ cursor.next();
399
+ let char = cursor.next();
400
+ if (char === "1")
401
+ return true;
402
+ if (char === "0")
403
+ return false;
404
+ cursor.position--;
405
+ return cursor.fail("Boolean must be `?1` or `?0`");
406
+ }
407
+ /**
408
+ * Parses a Date (section 4.2.9): `@` and an Integer of seconds since the epoch. A value
409
+ * outside the range a JavaScript `Date` holds fails, which the RFC permits.
410
+ *
411
+ * @param cursor - The parser state
412
+ * @returns The Date
413
+ */
414
+ function parseDate(cursor) {
415
+ cursor.next();
416
+ let seconds = parseNumber(cursor);
417
+ if (seconds === FAILED)
418
+ return FAILED;
419
+ if (typeof seconds !== "number")
420
+ return cursor.fail("Date must be an Integer");
421
+ let date = new Date(seconds * 1000);
422
+ if (Number.isNaN(date.getTime()))
423
+ return cursor.fail("Date is outside the supported range");
424
+ return date;
425
+ }
426
+ /**
427
+ * Parses a Display String (section 4.2.10): `%"`, visible ASCII with UTF-8 octets
428
+ * percent-encoded in lowercase hex, and `"`. Invalid UTF-8 fails.
429
+ *
430
+ * @param cursor - The parser state
431
+ * @returns The Display String
432
+ */
433
+ function parseDisplayString(cursor) {
434
+ cursor.next();
435
+ if (cursor.next() !== '"')
436
+ return cursor.fail('Display String must start with `%"`');
437
+ let octets = [];
438
+ while (!cursor.done) {
439
+ let char = cursor.next();
440
+ if (char < " " || char > "~") {
441
+ return cursor.fail("Display String contains a character outside visible ASCII");
442
+ }
443
+ if (char === "%") {
444
+ let hex = cursor.text.slice(cursor.position, cursor.position + 2);
445
+ if (!LOWER_HEX.test(hex))
446
+ return cursor.fail("Display String escape must be lowercase hex");
447
+ octets.push(Number.parseInt(hex, 16));
448
+ cursor.position += 2;
449
+ }
450
+ else if (char === '"') {
451
+ let text = decodeUtf8(new Uint8Array(octets));
452
+ if (text === null)
453
+ return cursor.fail("Display String is not valid UTF-8");
454
+ return new DisplayString(text);
455
+ }
456
+ else {
457
+ octets.push(char.charCodeAt(0));
458
+ }
459
+ }
460
+ return cursor.fail('Display String is missing its closing `"`');
461
+ }
462
+ /**
463
+ * Decodes UTF-8 octets strictly.
464
+ *
465
+ * @param octets - The decoded percent-escapes and literal characters
466
+ * @returns The text, or `null` for an invalid sequence
467
+ */
468
+ function decodeUtf8(octets) {
469
+ try {
470
+ return UTF8.decode(octets);
471
+ }
472
+ catch {
473
+ return null;
474
+ }
475
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The RFC 9651 section 4.1 serialization algorithms: canonical text for the three
3
+ * top-level types, with every range and character-set rule enforced, so a value either
4
+ * serializes to a field any conforming recipient reads or fails with where it went wrong.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { SF } from "./types.js";
11
+ import { StructuredFieldStringifyError } from "./errors.js";
12
+ /**
13
+ * Serializes a value as its definition's top-level type (RFC 9651 section 4.1). An empty
14
+ * List or Dictionary serializes as `""`, which RFC 9651 reads as "do not send the field".
15
+ *
16
+ * @param value - The model, or members written in their shorthand forms
17
+ * @param type - The top-level type the field's definition fixes
18
+ * @returns The canonical text, or the path to the value with no representation
19
+ */
20
+ export declare function stringifyField<Type extends SF.FieldType>(value: SF.InputOf[Type], type: Type): Result<string, StructuredFieldStringifyError>;