@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.
- package/LICENSE.md +21 -0
- package/README.md +269 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +71 -0
- package/dist/lib/base64.d.ts +24 -0
- package/dist/lib/base64.js +44 -0
- package/dist/lib/errors.d.ts +35 -0
- package/dist/lib/errors.js +41 -0
- package/dist/lib/parse.d.ts +19 -0
- package/dist/lib/parse.js +475 -0
- package/dist/lib/stringify.d.ts +20 -0
- package/dist/lib/stringify.js +352 -0
- package/dist/lib/types.d.ts +84 -0
- package/dist/lib/types.js +0 -0
- package/dist/lib/values.d.ts +53 -0
- package/dist/lib/values.js +65 -0
- package/dist/schema.d.ts +56 -0
- package/dist/schema.js +181 -0
- package/package.json +25 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { encodeBase64 } from "./base64.js";
|
|
3
|
+
import { StructuredFieldStringifyError } from "./errors.js";
|
|
4
|
+
import { Decimal, DisplayString, Token } from "./values.js";
|
|
5
|
+
/** The largest magnitude an Integer may carry: fifteen nines. */
|
|
6
|
+
const MAX_INTEGER = 999_999_999_999_999;
|
|
7
|
+
/** A Decimal's integer part may carry at most twelve digits. */
|
|
8
|
+
const MAX_DECIMAL_INTEGER_DIGITS = 12;
|
|
9
|
+
/** The key grammar (section 3.1.2). */
|
|
10
|
+
const KEY = /^[a-z*][a-z0-9_\-.*]*$/;
|
|
11
|
+
/** The token grammar (section 3.3.4). */
|
|
12
|
+
const TOKEN = /^[A-Za-z*][!#$%&'*+\-.^_`|~0-9A-Za-z:/]*$/;
|
|
13
|
+
/** The characters an sf-string may carry: visible ASCII and space. */
|
|
14
|
+
const STRING_CHARS = /^[\x20-\x7e]*$/;
|
|
15
|
+
/** A UTF-16 surrogate without its pair, which has no UTF-8 encoding. */
|
|
16
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
17
|
+
/** Encodes a Display String's text into the octets it percent-encodes. */
|
|
18
|
+
const UTF8 = new TextEncoder();
|
|
19
|
+
/**
|
|
20
|
+
* Serializes a value as its definition's top-level type (RFC 9651 section 4.1). An empty
|
|
21
|
+
* List or Dictionary serializes as `""`, which RFC 9651 reads as "do not send the field".
|
|
22
|
+
*
|
|
23
|
+
* @param value - The model, or members written in their shorthand forms
|
|
24
|
+
* @param type - The top-level type the field's definition fixes
|
|
25
|
+
* @returns The canonical text, or the path to the value with no representation
|
|
26
|
+
*/
|
|
27
|
+
export function stringifyField(value, type) {
|
|
28
|
+
let state = { error: null };
|
|
29
|
+
let text;
|
|
30
|
+
if (type === "list")
|
|
31
|
+
text = serializeList(value, state);
|
|
32
|
+
else if (type === "dictionary")
|
|
33
|
+
text = serializeDictionary(value, state);
|
|
34
|
+
else
|
|
35
|
+
text = serializeItem(value, [], state);
|
|
36
|
+
return state.error ? failure(state.error) : success(text);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Records a failure unless an earlier one is already recorded.
|
|
40
|
+
*
|
|
41
|
+
* @param state - The shared failure slot
|
|
42
|
+
* @param message - Why the value has no representation
|
|
43
|
+
* @param path - Where the value sits
|
|
44
|
+
* @returns `""`, so a serializer writes `return reject(…)`
|
|
45
|
+
*/
|
|
46
|
+
function reject(state, message, path) {
|
|
47
|
+
state.error ??= new StructuredFieldStringifyError(message, path);
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* @param value - Anything
|
|
52
|
+
* @returns Whether it is a plain object usable as Parameters or a Dictionary
|
|
53
|
+
*/
|
|
54
|
+
function isRecord(value) {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @param value - Anything
|
|
59
|
+
* @returns Whether it is one of the bare item types
|
|
60
|
+
*/
|
|
61
|
+
function isBareItem(value) {
|
|
62
|
+
return (typeof value === "number" ||
|
|
63
|
+
typeof value === "string" ||
|
|
64
|
+
typeof value === "boolean" ||
|
|
65
|
+
value instanceof Token ||
|
|
66
|
+
value instanceof Decimal ||
|
|
67
|
+
value instanceof DisplayString ||
|
|
68
|
+
value instanceof Uint8Array ||
|
|
69
|
+
value instanceof Date);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* @param value - A member in any accepted form
|
|
73
|
+
* @returns Whether it is an Inner List, the one form carrying `items`
|
|
74
|
+
*/
|
|
75
|
+
function isInnerList(value) {
|
|
76
|
+
return isRecord(value) && !isBareItem(value) && "items" in value;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Serializes a List (section 4.1.1), members joined by `, `.
|
|
80
|
+
*
|
|
81
|
+
* @param list - The members
|
|
82
|
+
* @param state - The shared failure slot
|
|
83
|
+
* @returns The text
|
|
84
|
+
*/
|
|
85
|
+
function serializeList(list, state) {
|
|
86
|
+
if (!Array.isArray(list))
|
|
87
|
+
return reject(state, "List must be an array", []);
|
|
88
|
+
return list.map((member, index) => serializeMember(member, [index], state)).join(", ");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Serializes a Dictionary (section 4.1.2). A member whose value is `true` writes the
|
|
92
|
+
* bare key followed by its parameters.
|
|
93
|
+
*
|
|
94
|
+
* @param dictionary - The members, keyed
|
|
95
|
+
* @param state - The shared failure slot
|
|
96
|
+
* @returns The text
|
|
97
|
+
*/
|
|
98
|
+
function serializeDictionary(dictionary, state) {
|
|
99
|
+
if (!isRecord(dictionary))
|
|
100
|
+
return reject(state, "Dictionary must be an object", []);
|
|
101
|
+
let parts = [];
|
|
102
|
+
for (let [key, member] of Object.entries(dictionary)) {
|
|
103
|
+
let text = serializeKey(key, [key], state);
|
|
104
|
+
if (member === true) {
|
|
105
|
+
parts.push(text);
|
|
106
|
+
}
|
|
107
|
+
else if (!isBareItem(member) && !isInnerList(member) && member.value === true) {
|
|
108
|
+
parts.push(text + serializeParameters(member.params, [key, "params"], state));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
parts.push(`${text}=${serializeMember(member, [key], state)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return parts.join(", ");
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Serializes a List or Dictionary member in any of its accepted forms.
|
|
118
|
+
*
|
|
119
|
+
* @param member - A bare item, an Item or an Inner List
|
|
120
|
+
* @param path - Where the member sits
|
|
121
|
+
* @param state - The shared failure slot
|
|
122
|
+
* @returns The text
|
|
123
|
+
*/
|
|
124
|
+
function serializeMember(member, path, state) {
|
|
125
|
+
if (isInnerList(member))
|
|
126
|
+
return serializeInnerList(member, path, state);
|
|
127
|
+
return serializeItem(member, path, state);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Serializes an Inner List (section 4.1.1.1): items separated by spaces inside
|
|
131
|
+
* parentheses, then the list's parameters.
|
|
132
|
+
*
|
|
133
|
+
* @param list - The items and parameters
|
|
134
|
+
* @param path - Where the Inner List sits
|
|
135
|
+
* @param state - The shared failure slot
|
|
136
|
+
* @returns The text
|
|
137
|
+
*/
|
|
138
|
+
function serializeInnerList(list, path, state) {
|
|
139
|
+
if (!Array.isArray(list.items))
|
|
140
|
+
return reject(state, "Inner List items must be an array", path);
|
|
141
|
+
let items = list.items.map((item, index) => serializeItem(item, [...path, "items", index], state));
|
|
142
|
+
return `(${items.join(" ")})${serializeParameters(list.params, [...path, "params"], state)}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Serializes an Item (section 4.1.3), written bare or with parameters.
|
|
146
|
+
*
|
|
147
|
+
* @param item - A bare item, or its value with parameters
|
|
148
|
+
* @param path - Where the Item sits
|
|
149
|
+
* @param state - The shared failure slot
|
|
150
|
+
* @returns The text
|
|
151
|
+
*/
|
|
152
|
+
function serializeItem(item, path, state) {
|
|
153
|
+
if (isBareItem(item))
|
|
154
|
+
return serializeBareItem(item, path, state);
|
|
155
|
+
if (!isRecord(item) || !("value" in item)) {
|
|
156
|
+
return reject(state, "Expected a bare item, an Item or an Inner List", path);
|
|
157
|
+
}
|
|
158
|
+
let value = serializeBareItem(item.value, [...path, "value"], state);
|
|
159
|
+
return value + serializeParameters(item.params, [...path, "params"], state);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Serializes Parameters (section 4.1.1.2); a `true` value writes the bare key.
|
|
163
|
+
*
|
|
164
|
+
* @param params - The parameters, absent for none
|
|
165
|
+
* @param path - Where the parameters sit
|
|
166
|
+
* @param state - The shared failure slot
|
|
167
|
+
* @returns The text, each parameter prefixed with `;`
|
|
168
|
+
*/
|
|
169
|
+
function serializeParameters(params, path, state) {
|
|
170
|
+
if (params === undefined)
|
|
171
|
+
return "";
|
|
172
|
+
if (!isRecord(params))
|
|
173
|
+
return reject(state, "Parameters must be an object", path);
|
|
174
|
+
let output = "";
|
|
175
|
+
for (let key of Object.keys(params)) {
|
|
176
|
+
let value = params[key];
|
|
177
|
+
output += `;${serializeKey(key, [...path, key], state)}`;
|
|
178
|
+
if (value !== true)
|
|
179
|
+
output += `=${serializeBareItem(value, [...path, key], state)}`;
|
|
180
|
+
}
|
|
181
|
+
return output;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Serializes a key (section 4.1.1.3).
|
|
185
|
+
*
|
|
186
|
+
* @param key - The key
|
|
187
|
+
* @param path - Where the keyed value sits
|
|
188
|
+
* @param state - The shared failure slot
|
|
189
|
+
* @returns The key
|
|
190
|
+
*/
|
|
191
|
+
function serializeKey(key, path, state) {
|
|
192
|
+
if (!KEY.test(key)) {
|
|
193
|
+
return reject(state, "Key must be lowercase letters, digits, `_-.*`, first a letter or `*`", path);
|
|
194
|
+
}
|
|
195
|
+
return key;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Serializes a bare item (section 4.1.3.1), choosing the type from the JavaScript value.
|
|
199
|
+
* A `number` that is an integer is an Integer, and any other `number` a Decimal.
|
|
200
|
+
*
|
|
201
|
+
* @param value - The bare item
|
|
202
|
+
* @param path - Where it sits
|
|
203
|
+
* @param state - The shared failure slot
|
|
204
|
+
* @returns The text
|
|
205
|
+
*/
|
|
206
|
+
function serializeBareItem(value, path, state) {
|
|
207
|
+
if (typeof value === "number") {
|
|
208
|
+
return Number.isInteger(value)
|
|
209
|
+
? serializeInteger(value, path, state)
|
|
210
|
+
: serializeDecimal(value, path, state);
|
|
211
|
+
}
|
|
212
|
+
if (value instanceof Decimal)
|
|
213
|
+
return serializeDecimal(value.value, path, state);
|
|
214
|
+
if (typeof value === "string")
|
|
215
|
+
return serializeString(value, path, state);
|
|
216
|
+
if (value instanceof Token)
|
|
217
|
+
return serializeToken(value.value, path, state);
|
|
218
|
+
if (value instanceof Uint8Array)
|
|
219
|
+
return `:${encodeBase64(value)}:`;
|
|
220
|
+
if (typeof value === "boolean")
|
|
221
|
+
return value ? "?1" : "?0";
|
|
222
|
+
if (value instanceof Date)
|
|
223
|
+
return serializeDate(value, path, state);
|
|
224
|
+
if (value instanceof DisplayString)
|
|
225
|
+
return serializeDisplayString(value.value, path, state);
|
|
226
|
+
return reject(state, "Value is not a Structured Field bare item", path);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Serializes an Integer (section 4.1.4), at most fifteen digits.
|
|
230
|
+
*
|
|
231
|
+
* @param value - An integral number
|
|
232
|
+
* @param path - Where it sits
|
|
233
|
+
* @param state - The shared failure slot
|
|
234
|
+
* @returns The digits, `-` first when negative
|
|
235
|
+
*/
|
|
236
|
+
function serializeInteger(value, path, state) {
|
|
237
|
+
if (Math.abs(value) > MAX_INTEGER)
|
|
238
|
+
return reject(state, "Integer is outside ±999,999,999,999,999", path);
|
|
239
|
+
return String(value + 0);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Serializes a Decimal (section 4.1.5): rounded half-to-even to three fractional digits,
|
|
243
|
+
* at most twelve integer digits, trailing fractional zeros dropped but one digit kept.
|
|
244
|
+
* Rounding works on the number's shortest decimal representation, so `0.0025` rounds as
|
|
245
|
+
* the decimal text it was written as, to `0.002`.
|
|
246
|
+
*
|
|
247
|
+
* @param value - The number
|
|
248
|
+
* @param path - Where it sits
|
|
249
|
+
* @param state - The shared failure slot
|
|
250
|
+
* @returns The text
|
|
251
|
+
*/
|
|
252
|
+
function serializeDecimal(value, path, state) {
|
|
253
|
+
if (!Number.isFinite(value))
|
|
254
|
+
return reject(state, "Decimal must be a finite number", path);
|
|
255
|
+
let magnitude = Math.abs(value);
|
|
256
|
+
if (magnitude >= 1e12)
|
|
257
|
+
return reject(state, "Decimal has more than 12 integer digits", path);
|
|
258
|
+
let thousandths = roundToThousandths(magnitude);
|
|
259
|
+
let integer = thousandths / 1000n;
|
|
260
|
+
if (integer.toString().length > MAX_DECIMAL_INTEGER_DIGITS) {
|
|
261
|
+
return reject(state, "Decimal has more than 12 integer digits", path);
|
|
262
|
+
}
|
|
263
|
+
let fraction = (thousandths % 1000n).toString().padStart(3, "0").replace(/0+$/, "") || "0";
|
|
264
|
+
return `${value < 0 ? "-" : ""}${integer}.${fraction}`;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Rounds a non-negative number below 1e12 to a whole count of thousandths, half-to-even,
|
|
268
|
+
* reading its digits from `toString` so the rounding sees the decimal the caller wrote.
|
|
269
|
+
*
|
|
270
|
+
* @param magnitude - The absolute value
|
|
271
|
+
* @returns Thousandths, as a bigint to keep every digit exact
|
|
272
|
+
*/
|
|
273
|
+
function roundToThousandths(magnitude) {
|
|
274
|
+
if (magnitude < 1e-6)
|
|
275
|
+
return 0n;
|
|
276
|
+
let [whole = "0", fraction = ""] = magnitude.toString().split(".");
|
|
277
|
+
let kept = BigInt(whole + fraction.slice(0, 3).padEnd(3, "0"));
|
|
278
|
+
let rest = fraction.slice(3);
|
|
279
|
+
if (rest === "")
|
|
280
|
+
return kept;
|
|
281
|
+
let first = rest.charCodeAt(0) - 48;
|
|
282
|
+
let beyondHalf = /[1-9]/.test(rest.slice(1));
|
|
283
|
+
if (first > 5 || (first === 5 && (beyondHalf || kept % 2n === 1n)))
|
|
284
|
+
return kept + 1n;
|
|
285
|
+
return kept;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Serializes a String (section 4.1.6): visible ASCII and space, escaping `"` and `\`.
|
|
289
|
+
*
|
|
290
|
+
* @param value - The string
|
|
291
|
+
* @param path - Where it sits
|
|
292
|
+
* @param state - The shared failure slot
|
|
293
|
+
* @returns The quoted text
|
|
294
|
+
*/
|
|
295
|
+
function serializeString(value, path, state) {
|
|
296
|
+
if (!STRING_CHARS.test(value)) {
|
|
297
|
+
return reject(state, "String may only carry visible ASCII and space; use DisplayString", path);
|
|
298
|
+
}
|
|
299
|
+
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Serializes a Token (section 4.1.7).
|
|
303
|
+
*
|
|
304
|
+
* @param value - The token text
|
|
305
|
+
* @param path - Where it sits
|
|
306
|
+
* @param state - The shared failure slot
|
|
307
|
+
* @returns The token
|
|
308
|
+
*/
|
|
309
|
+
function serializeToken(value, path, state) {
|
|
310
|
+
if (!TOKEN.test(value))
|
|
311
|
+
return reject(state, "Token breaks the token grammar", path);
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Serializes a Date (section 4.1.10): `@` and whole seconds since the epoch.
|
|
316
|
+
*
|
|
317
|
+
* @param value - The Date
|
|
318
|
+
* @param path - Where it sits
|
|
319
|
+
* @param state - The shared failure slot
|
|
320
|
+
* @returns The text
|
|
321
|
+
*/
|
|
322
|
+
function serializeDate(value, path, state) {
|
|
323
|
+
let time = value.getTime();
|
|
324
|
+
if (Number.isNaN(time))
|
|
325
|
+
return reject(state, "Date is invalid", path);
|
|
326
|
+
if (time % 1000 !== 0)
|
|
327
|
+
return reject(state, "Date must be whole seconds", path);
|
|
328
|
+
return `@${serializeInteger(time / 1000, path, state)}`;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Serializes a Display String (section 4.1.11): `%"`, then each UTF-8 octet as itself
|
|
332
|
+
* when it is visible ASCII other than `%` and `"`, and as lowercase `%xx` otherwise.
|
|
333
|
+
*
|
|
334
|
+
* @param value - The Unicode text
|
|
335
|
+
* @param path - Where it sits
|
|
336
|
+
* @param state - The shared failure slot
|
|
337
|
+
* @returns The text
|
|
338
|
+
*/
|
|
339
|
+
function serializeDisplayString(value, path, state) {
|
|
340
|
+
if (LONE_SURROGATE.test(value))
|
|
341
|
+
return reject(state, "Display String has an unpaired surrogate", path);
|
|
342
|
+
let output = '%"';
|
|
343
|
+
for (let octet of UTF8.encode(value)) {
|
|
344
|
+
if (octet === 0x25 || octet === 0x22 || octet < 0x20 || octet > 0x7e) {
|
|
345
|
+
output += `%${octet.toString(16).padStart(2, "0")}`;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
output += String.fromCharCode(octet);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return `${output}"`;
|
|
352
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Structured Field value model as types: what `parse` produces and what `stringify`
|
|
3
|
+
* accepts. Kept in one namespace so a caller writes `SF.Dictionary` next to the functions
|
|
4
|
+
* that read and write one.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
10
|
+
import type { Decimal, DisplayString, Token } from "./values.js";
|
|
11
|
+
/**
|
|
12
|
+
* Groups the Structured Field value model under a single import surface.
|
|
13
|
+
*/
|
|
14
|
+
export declare namespace SF {
|
|
15
|
+
/**
|
|
16
|
+
* Integer and Decimal as `number` and `Decimal`, String as `string`, Token as `Token`,
|
|
17
|
+
* Byte Sequence as `Uint8Array`, Boolean as `boolean`, Date as `Date`,
|
|
18
|
+
* Display String as `DisplayString`.
|
|
19
|
+
*/
|
|
20
|
+
type BareItem = number | Decimal | string | Token | Uint8Array | boolean | Date | DisplayString;
|
|
21
|
+
/** Keys in insertion order, on a null-prototype object. */
|
|
22
|
+
type Parameters = Record<string, BareItem>;
|
|
23
|
+
/**
|
|
24
|
+
* A bare item with its parameters.
|
|
25
|
+
*
|
|
26
|
+
* @template Value - The bare item type the field allows
|
|
27
|
+
*/
|
|
28
|
+
interface Item<Value extends BareItem = BareItem> {
|
|
29
|
+
value: Value;
|
|
30
|
+
params: Parameters;
|
|
31
|
+
}
|
|
32
|
+
/** A parenthesized list of items, itself carrying parameters. */
|
|
33
|
+
interface InnerList {
|
|
34
|
+
items: Item[];
|
|
35
|
+
params: Parameters;
|
|
36
|
+
}
|
|
37
|
+
/** One List or Dictionary member. */
|
|
38
|
+
type Member = Item | InnerList;
|
|
39
|
+
/** Members in field order. */
|
|
40
|
+
type List = Member[];
|
|
41
|
+
/** Members keyed in field order, on a null-prototype object. */
|
|
42
|
+
type Dictionary = Record<string, Member>;
|
|
43
|
+
/** The top-level type a field's definition fixes. */
|
|
44
|
+
type FieldType = "list" | "dictionary" | "item";
|
|
45
|
+
/** The value `parse` produces for each field type. */
|
|
46
|
+
interface ValueOf {
|
|
47
|
+
list: List;
|
|
48
|
+
dictionary: Dictionary;
|
|
49
|
+
item: Item;
|
|
50
|
+
}
|
|
51
|
+
/** An Item as `stringify` accepts it, with `params` optional. */
|
|
52
|
+
interface ItemInput {
|
|
53
|
+
value: BareItem;
|
|
54
|
+
params?: Parameters;
|
|
55
|
+
}
|
|
56
|
+
/** An Inner List as `stringify` accepts it, each item bare or with parameters. */
|
|
57
|
+
interface InnerListInput {
|
|
58
|
+
items: Array<BareItem | ItemInput>;
|
|
59
|
+
params?: Parameters;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What `stringify` accepts: the parsed model, or any member written as its bare value
|
|
63
|
+
* (a member without parameters), and `params` optional everywhere.
|
|
64
|
+
*/
|
|
65
|
+
type MemberInput = BareItem | ItemInput | InnerListInput;
|
|
66
|
+
/** The value `stringify` accepts for each field type. */
|
|
67
|
+
interface InputOf {
|
|
68
|
+
list: MemberInput[];
|
|
69
|
+
dictionary: Record<string, MemberInput>;
|
|
70
|
+
item: BareItem | ItemInput;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A Standard Schema whose `validate` answers synchronously, which is every
|
|
74
|
+
* `remix/data-schema` schema. Header parsing sits on hot paths that stay synchronous,
|
|
75
|
+
* so a schema with an async refinement is a type error here.
|
|
76
|
+
*
|
|
77
|
+
* @template Output - What the schema produces
|
|
78
|
+
*/
|
|
79
|
+
type SyncSchema<Output = unknown> = StandardSchemaV1<unknown, Output> & {
|
|
80
|
+
readonly "~standard": {
|
|
81
|
+
validate(value: unknown, options?: StandardSchemaV1.Options): StandardSchemaV1.Result<Output>;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bare item wrappers for the three RFC 9651 types a JavaScript primitive cannot tell
|
|
3
|
+
* apart from another type: Token from String, Decimal from Integer, Display String from
|
|
4
|
+
* String. Each keeps its type through a parse and stringify round trip.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* An sf-token: a bare word such as `HIT` or `text/html`, distinct from the sf-string `"HIT"`.
|
|
11
|
+
* Construction always succeeds; `stringify` checks the token grammar when it writes one.
|
|
12
|
+
*/
|
|
13
|
+
export declare class Token {
|
|
14
|
+
readonly value: string;
|
|
15
|
+
/**
|
|
16
|
+
* @param value - The token text, written without quotes
|
|
17
|
+
*/
|
|
18
|
+
constructor(value: string);
|
|
19
|
+
/**
|
|
20
|
+
* @returns The token text, so a Token interpolates as the word it carries
|
|
21
|
+
*/
|
|
22
|
+
toString(): string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* An sf-decimal, so `1.0` stays a Decimal through a round trip where a plain `1` would be
|
|
26
|
+
* written as an Integer. `stringify` rounds it half-to-even to three fractional digits.
|
|
27
|
+
*/
|
|
28
|
+
export declare class Decimal {
|
|
29
|
+
readonly value: number;
|
|
30
|
+
/**
|
|
31
|
+
* @param value - The number to write as a Decimal
|
|
32
|
+
*/
|
|
33
|
+
constructor(value: number);
|
|
34
|
+
/**
|
|
35
|
+
* @returns The number, so a Decimal takes part in arithmetic and comparisons directly
|
|
36
|
+
*/
|
|
37
|
+
valueOf(): number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An sf-displaystring: Unicode text, written as `%"…"` with its non-ASCII UTF-8 bytes
|
|
41
|
+
* percent-encoded. A plain JavaScript string is an sf-string, which only carries ASCII.
|
|
42
|
+
*/
|
|
43
|
+
export declare class DisplayString {
|
|
44
|
+
readonly value: string;
|
|
45
|
+
/**
|
|
46
|
+
* @param value - The Unicode text to carry
|
|
47
|
+
*/
|
|
48
|
+
constructor(value: string);
|
|
49
|
+
/**
|
|
50
|
+
* @returns The Unicode text
|
|
51
|
+
*/
|
|
52
|
+
toString(): string;
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bare item wrappers for the three RFC 9651 types a JavaScript primitive cannot tell
|
|
3
|
+
* apart from another type: Token from String, Decimal from Integer, Display String from
|
|
4
|
+
* String. Each keeps its type through a parse and stringify round trip.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* An sf-token: a bare word such as `HIT` or `text/html`, distinct from the sf-string `"HIT"`.
|
|
11
|
+
* Construction always succeeds; `stringify` checks the token grammar when it writes one.
|
|
12
|
+
*/
|
|
13
|
+
export class Token {
|
|
14
|
+
value;
|
|
15
|
+
/**
|
|
16
|
+
* @param value - The token text, written without quotes
|
|
17
|
+
*/
|
|
18
|
+
constructor(value) {
|
|
19
|
+
this.value = value;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* @returns The token text, so a Token interpolates as the word it carries
|
|
23
|
+
*/
|
|
24
|
+
toString() {
|
|
25
|
+
return this.value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* An sf-decimal, so `1.0` stays a Decimal through a round trip where a plain `1` would be
|
|
30
|
+
* written as an Integer. `stringify` rounds it half-to-even to three fractional digits.
|
|
31
|
+
*/
|
|
32
|
+
export class Decimal {
|
|
33
|
+
value;
|
|
34
|
+
/**
|
|
35
|
+
* @param value - The number to write as a Decimal
|
|
36
|
+
*/
|
|
37
|
+
constructor(value) {
|
|
38
|
+
this.value = value;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* @returns The number, so a Decimal takes part in arithmetic and comparisons directly
|
|
42
|
+
*/
|
|
43
|
+
valueOf() {
|
|
44
|
+
return this.value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* An sf-displaystring: Unicode text, written as `%"…"` with its non-ASCII UTF-8 bytes
|
|
49
|
+
* percent-encoded. A plain JavaScript string is an sf-string, which only carries ASCII.
|
|
50
|
+
*/
|
|
51
|
+
export class DisplayString {
|
|
52
|
+
value;
|
|
53
|
+
/**
|
|
54
|
+
* @param value - The Unicode text to carry
|
|
55
|
+
*/
|
|
56
|
+
constructor(value) {
|
|
57
|
+
this.value = value;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* @returns The Unicode text
|
|
61
|
+
*/
|
|
62
|
+
toString() {
|
|
63
|
+
return this.value;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Schema } from "remix/data-schema";
|
|
2
|
+
import type { SF } from "./index.js";
|
|
3
|
+
/** The schema builders, as one catalog of functions. */
|
|
4
|
+
export interface StructuredFieldSchemas {
|
|
5
|
+
/** An Integer, checked against the RFC range of fifteen digits. */
|
|
6
|
+
integer(): Schema<SF.BareItem, number>;
|
|
7
|
+
/** A Decimal or Integer, output as `number`. */
|
|
8
|
+
decimal(): Schema<SF.BareItem, number>;
|
|
9
|
+
/**
|
|
10
|
+
* A Token, output as its text; with a list, narrowed to those literals.
|
|
11
|
+
*
|
|
12
|
+
* @template T - The literals the field allows
|
|
13
|
+
*/
|
|
14
|
+
token<const T extends string = string>(allowed?: readonly T[]): Schema<SF.BareItem, T>;
|
|
15
|
+
/** A Display String, output as its text. A plain sf-string is `s.string()`. */
|
|
16
|
+
displayString(): Schema<SF.BareItem, string>;
|
|
17
|
+
/** A Byte Sequence. */
|
|
18
|
+
bytes(): Schema<SF.BareItem, Uint8Array>;
|
|
19
|
+
/** A Date. */
|
|
20
|
+
date(): Schema<SF.BareItem, Date>;
|
|
21
|
+
/**
|
|
22
|
+
* An Item: its bare value through `value`, its parameters through `params`, which pass
|
|
23
|
+
* through untouched when no parameter schema is given.
|
|
24
|
+
*
|
|
25
|
+
* @template V - The bare value's output
|
|
26
|
+
* @template P - The parameters' output
|
|
27
|
+
*/
|
|
28
|
+
item<V, P = SF.Parameters>(value: SF.SyncSchema<V>, params?: SF.SyncSchema<P>): Schema<SF.Member, {
|
|
29
|
+
value: V;
|
|
30
|
+
params: P;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* An Item whose parameters the caller ignores, output as its bare value alone.
|
|
34
|
+
*
|
|
35
|
+
* @template V - The bare value's output
|
|
36
|
+
*/
|
|
37
|
+
value<V>(value: SF.SyncSchema<V>): Schema<SF.Member, V>;
|
|
38
|
+
/**
|
|
39
|
+
* An Inner List of items matching `item`.
|
|
40
|
+
*
|
|
41
|
+
* @template I - Each item's output
|
|
42
|
+
* @template P - The list parameters' output
|
|
43
|
+
*/
|
|
44
|
+
innerList<I, P = SF.Parameters>(item: SF.SyncSchema<I>, params?: SF.SyncSchema<P>): Schema<SF.Member, {
|
|
45
|
+
items: I[];
|
|
46
|
+
params: P;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The Structured Field schema builders. Each call returns a fresh schema, and none holds
|
|
51
|
+
* state, so a field's schema is built once at module scope and reused on every request.
|
|
52
|
+
*
|
|
53
|
+
* @example s.object({ u: s.optional(sf.value(sf.integer())) })
|
|
54
|
+
* @example sf.item(sf.token(), s.object({ ttl: s.optional(sf.integer()) }))
|
|
55
|
+
*/
|
|
56
|
+
export declare const sf: StructuredFieldSchemas;
|