@meantjs/format 0.0.0-bootstrap → 0.1.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,272 @@
1
+ // formatterFor(node): the one entry point for consumers (arm C, the ICRC-21 consent generator).
2
+ //
3
+ // `node` is a position from the resolved form (docs/profile-v0.md §10): its `candid` type and its
4
+ // `tags`. The function picks the value tag at that position and returns a { parse, display } pair
5
+ // bound to the tag's arguments. Dynamic arguments such as { "from": "query:icrc1_decimals" } are
6
+ // resolved by the caller (the library does no I/O) and passed in `refs`, keyed by the `from` string.
7
+
8
+ import { CODES, FormatError, fail } from "./errors.mjs";
9
+ import { AMOUNT_TYPES, checkDecimals, checkSymbol, displayAmount, parseAmount } from "./amount.mjs";
10
+ import { TIME_TYPES, displayDuration, displayTimestamp, parseDuration, parseTimestamp, unitFactor } from "./time.mjs";
11
+ import { displayAccount, parseAccount } from "./account.mjs";
12
+ import { displaySubaccount, parseSubaccount } from "./subaccount.mjs";
13
+ import { BYTES_ENCODINGS, checkMaxLen, displayBytes, parseBytes } from "./bytes.mjs";
14
+
15
+ /** The tags that give a position a text form, in spec §5.1. */
16
+ export const VALUE_TAGS = Object.freeze(["amount", "timestamp", "duration", "account", "subaccount", "bytes"]);
17
+
18
+ /** A dynamic value ({ from }) replaced by the caller-supplied one; literals pass through. */
19
+ function resolveArg(arg, refs, where) {
20
+ if (arg === null || typeof arg !== "object" || Array.isArray(arg)) return arg;
21
+ const from = arg.from;
22
+ if (typeof from !== "string") fail(CODES.ARG, `${where} is an object without a "from" string`);
23
+ if (from === "now" || from === "caller") fail(CODES.ARG, `${where}: { "from": "${from}" } is not valid here (only default.value may use it)`);
24
+ if (!Object.hasOwn(refs, from)) {
25
+ fail(CODES.REF_UNRESOLVED, `${where} needs the value of "${from}", which the caller did not supply (call it once per canister and pass it in \`refs\`)`);
26
+ }
27
+ return refs[from];
28
+ }
29
+
30
+ function requireCandid(node, allowed, tag) {
31
+ if (!allowed.includes(node.candid)) fail(CODES.TAG_TYPE, `the ${tag} tag applies to ${allowed.join(", ")}, not ${node.candid}`);
32
+ }
33
+
34
+ function requireUnit(unit, tag) {
35
+ if (unit === undefined) fail(CODES.ARG, `the ${tag} tag needs a unit`);
36
+ unitFactor(unit);
37
+ return unit;
38
+ }
39
+
40
+ /**
41
+ * @typedef {object} Formatter
42
+ * @property {string} tag the value tag: amount, timestamp, duration, account, subaccount or bytes
43
+ * @property {string} candid the Candid type of the position (`opt` is the node's `optional` flag, not handled here)
44
+ * @property {Readonly<Record<string, unknown>>} args the tag's arguments with dynamic values resolved
45
+ * @property {(input: string, ctx?: { now?: bigint }) => any} parse throws FormatError. amount, timestamp,
46
+ * duration: decimal string in base units / tagged unit; account: { owner, subaccount }; subaccount, bytes: Uint8Array
47
+ * @property {(value: any) => string} display throws FormatError
48
+ * @property {(input: string, ctx?: { now?: bigint }) => { ok: true, value: any } | { ok: false, error: { code: string, message: string } }} safeParse
49
+ * @property {(value: any) => { ok: true, value: string } | { ok: false, error: { code: string, message: string } }} safeDisplay
50
+ * @property {() => Description} describe the text this formatter reads, in words, with examples (see `describeInput`)
51
+ */
52
+
53
+ /**
54
+ * @typedef {object} Description
55
+ * @property {string} summary one paragraph for a form hint or a tool input's description
56
+ * @property {string[]} examples inputs `parse` accepts (a relative time needs `now`)
57
+ * @property {string} [pattern] an ECMAScript regular expression every accepted input matches (JSON Schema `pattern`).
58
+ * A necessary condition only: a matching input can still be refused (a range, a checksum). Given for amount and
59
+ * subaccount, whose grammar is regular.
60
+ */
61
+
62
+ /**
63
+ * @param {{ candid: string, tags?: Record<string, { args?: Record<string, unknown> }> }} node
64
+ * @param {{ refs?: Record<string, unknown>, now?: bigint }} [options]
65
+ * `refs`: resolved dynamic values keyed by their `from` string, for example
66
+ * `{ "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }`.
67
+ * `now`: default clock (nanoseconds, bigint) for relative timestamps; `parse(input, { now })` overrides it.
68
+ * @returns {Formatter | null} null when the position has no value tag (render it as Candid text, spec §6)
69
+ */
70
+ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
71
+ if (node === null || typeof node !== "object" || typeof node.candid !== "string") fail(CODES.TYPE, "node must be a resolved-form position with a `candid` type");
72
+ const tags = node.tags ?? {};
73
+ const present = VALUE_TAGS.filter((t) => Object.hasOwn(tags, t));
74
+ if (present.length === 0) return null;
75
+ if (present.length > 1) fail(CODES.TAG_CONFLICT, `a position cannot carry more than one value tag, found ${present.join(", ")}`);
76
+ const tag = present[0];
77
+ const raw = tags[tag]?.args ?? {};
78
+ const arg = (name) => resolveArg(raw[name], refs, `${tag}.${name}`);
79
+
80
+ let args;
81
+ let parse;
82
+ let display;
83
+ switch (tag) {
84
+ case "amount": {
85
+ requireCandid(node, AMOUNT_TYPES, tag);
86
+ if (raw.decimals === undefined) fail(CODES.ARG, "the amount tag needs decimals");
87
+ const decimals = checkDecimals(arg("decimals"));
88
+ const symbol = checkSymbol(arg("symbol"));
89
+ const o = { decimals, symbol, candid: node.candid };
90
+ args = { decimals, symbol };
91
+ parse = (input) => parseAmount(input, o);
92
+ display = (value) => displayAmount(value, o);
93
+ break;
94
+ }
95
+ case "timestamp": {
96
+ requireCandid(node, TIME_TYPES, tag);
97
+ if (raw.epoch !== undefined && raw.epoch !== "unix") fail(CODES.ARG, `timestamp epoch must be "unix", got ${JSON.stringify(raw.epoch)}`);
98
+ const unit = requireUnit(arg("unit"), tag);
99
+ const o = { unit, candid: node.candid };
100
+ args = { unit, epoch: "unix" };
101
+ parse = (input, ctx = {}) => parseTimestamp(input, { ...o, now: ctx.now ?? defaultNow });
102
+ display = (value) => displayTimestamp(value, o);
103
+ break;
104
+ }
105
+ case "duration": {
106
+ requireCandid(node, TIME_TYPES, tag);
107
+ const unit = requireUnit(arg("unit"), tag);
108
+ const o = { unit, candid: node.candid };
109
+ args = { unit };
110
+ parse = (input) => parseDuration(input, o);
111
+ display = (value) => displayDuration(value, o);
112
+ break;
113
+ }
114
+ case "account": {
115
+ requireCandid(node, ["record"], tag);
116
+ if (raw.encoding !== undefined && raw.encoding !== "icrc1") fail(CODES.ARG, `account encoding must be "icrc1", got ${JSON.stringify(raw.encoding)}`);
117
+ args = { encoding: "icrc1" };
118
+ parse = (input) => parseAccount(input);
119
+ display = (value) => displayAccount(value);
120
+ break;
121
+ }
122
+ case "subaccount": {
123
+ requireCandid(node, ["blob"], tag);
124
+ args = {};
125
+ parse = (input) => parseSubaccount(input);
126
+ display = (value) => displaySubaccount(value);
127
+ break;
128
+ }
129
+ case "bytes": {
130
+ requireCandid(node, ["blob"], tag);
131
+ const encoding = arg("encoding");
132
+ if (!BYTES_ENCODINGS.includes(encoding)) fail(CODES.ARG, `the bytes tag needs encoding utf8, hex or base64, got ${JSON.stringify(encoding)}`);
133
+ const maxLen = raw.max_len;
134
+ args = { encoding, ...(maxLen === undefined ? {} : { max_len: maxLen }) };
135
+ parse = (input) => parseBytes(input, { encoding, maxLen });
136
+ display = (value) => displayBytes(value, { encoding });
137
+ break;
138
+ }
139
+ }
140
+
141
+ const safe = (fn) => (...a) => {
142
+ try {
143
+ return { ok: true, value: fn(...a) };
144
+ } catch (e) {
145
+ if (e instanceof FormatError) return { ok: false, error: { code: e.code, message: e.message } };
146
+ throw e;
147
+ }
148
+ };
149
+ // Keep only the examples this position accepts: a range (nat8, nat32 in ns) or a max_len can refuse one. A relative time
150
+ // is checked against the formatter's own clock when it was given one (`parse` uses it), else against a fixed instant
151
+ // (2026-01-01T00:00:00Z), since describing must not read a clock.
152
+ const description = describeInput(tag, args, node.candid, { accepts: (x) => safe(parse)(x, { now: defaultNow ?? DESCRIBE_NOW }).ok });
153
+ return Object.freeze({
154
+ tag,
155
+ candid: node.candid,
156
+ args: Object.freeze(args),
157
+ describe: () => ({ ...description, examples: [...description.examples] }),
158
+ parse,
159
+ display,
160
+ safeParse: safe(parse),
161
+ safeDisplay: safe(display),
162
+ });
163
+ }
164
+
165
+ // ---------------------------------------------------------------- describe
166
+
167
+ /** The Candid types each value tag applies to (the `requireCandid` calls in `formatterFor`). */
168
+ const CANDID_OF = { amount: AMOUNT_TYPES, timestamp: TIME_TYPES, duration: TIME_TYPES, account: ["record"], subaccount: ["blob"], bytes: ["blob"] };
169
+ /** 2026-01-01T00:00:00Z in ns: the reference instant relative examples are checked against. */
170
+ const DESCRIBE_NOW = 1_767_225_600_000_000_000n;
171
+ const SIGNED = new Set(["int", "int8", "int16", "int32", "int64"]);
172
+ // ECMAScript syntax characters only: escaping anything else (such as "-") is an error under the `u` flag, which JSON
173
+ // Schema validators such as Ajv use.
174
+ const reEscape = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
175
+ /** The symbol as `parseAmount` compares it: ASCII letters in either case, everything else exactly. */
176
+ const symbolPattern = (symbol) => [...symbol].map((c) => (/[A-Za-z]/.test(c) ? `[${c.toUpperCase()}${c.toLowerCase()}]` : reEscape(c))).join("");
177
+ const quoted = (xs) => xs.map((x) => JSON.stringify(x)).join(", ");
178
+
179
+ /** The grammar in words (no examples), candidate examples, and a pattern where the grammar is regular. */
180
+ function grammar(tag, args, candid) {
181
+ const signed = SIGNED.has(candid);
182
+ const finer = (what) => (args.unit === "ns" ? "" : ` Stored in ${args.unit}: a ${what} finer than that is an error, not rounded.`);
183
+ switch (tag) {
184
+ case "amount": {
185
+ const { decimals: d, symbol } = args;
186
+ const examples = d === 0 ? ["1", "250"] : ["1", d === 1 ? "1.5" : "1.25"];
187
+ if (symbol !== undefined) examples.push(`${examples[1]} ${symbol}`);
188
+ return {
189
+ words:
190
+ `Amount in ${symbol === undefined ? "the token's units" : symbol}: ` +
191
+ (d === 0 ? "a whole number (this token has no decimal places)" : `a decimal number with at most ${d} digit${d === 1 ? "" : "s"} after the point`) +
192
+ (symbol === undefined ? "" : `, optionally followed by the symbol ${symbol}`) +
193
+ `. Digits on both sides of a point, no thousands separators or exponents${signed ? ', "-" for a negative amount' : ""}. ` +
194
+ "Never rounded: more digits after the point is an error, even zeros.",
195
+ examples,
196
+ pattern:
197
+ `^\\s*${signed ? "-?" : ""}[0-9]+${d === 0 ? "" : `(?:\\.[0-9]{1,${d}})?`}` + (symbol === undefined ? "" : `(?:\\s+${symbolPattern(symbol)})?`) + "\\s*$",
198
+ };
199
+ }
200
+ case "timestamp":
201
+ return {
202
+ words:
203
+ 'Time in RFC 3339 with an explicit zone, or relative to the current time as "+" and an ISO 8601 duration with ' +
204
+ "weeks, days, hours, minutes and seconds only (no years or months; the seconds may have a fraction)." +
205
+ finer("time"),
206
+ examples: ["2026-12-25T10:00:00Z", "+PT24H", "+P7D"],
207
+ };
208
+ case "duration":
209
+ return {
210
+ words:
211
+ `ISO 8601 duration with weeks, days, hours, minutes and seconds only (no years or months); the seconds may have a fraction${signed ? '; "-" for a negative duration' : ""}.` +
212
+ finer("duration"),
213
+ examples: ["PT24H", "P7D", "PT1M30S"],
214
+ };
215
+ case "account":
216
+ return {
217
+ words: 'ICRC-1 account in text form: a principal for its default subaccount, or "principal-checksum.subaccounthex" for another subaccount.',
218
+ examples: ["aaaaa-aa", "2vxsx-fae"],
219
+ };
220
+ case "subaccount":
221
+ return {
222
+ words: 'Subaccount as "#n" or n (a number below 2^64 without leading zeros, stored as 32 big-endian bytes), or as exactly 64 hex characters, optionally after "0x".',
223
+ examples: ["#0", "#7", "0".repeat(63) + "1"],
224
+ pattern: "^\\s*(?:#?(?:0|[1-9][0-9]*)|(?:0[xX])?[0-9a-fA-F]{64})\\s*$",
225
+ };
226
+ case "bytes": {
227
+ const max = args.max_len === undefined || args.max_len === null ? "" : ` At most ${args.max_len} bytes after encoding.`;
228
+ if (args.encoding === "hex") return { words: `Bytes as hexadecimal, an even number of digits, optionally after "0x".${max}`, examples: ["0x1a2b", "1a2b"] };
229
+ if (args.encoding === "base64") return {
230
+ words: `Bytes as standard base64 (RFC 4648: A-Z, a-z, 0-9, "+" and "/"; not the URL-safe "-" and "_"), with "=" padding or none, no spaces or line breaks, and the unused bits of the last character zero.${max}`,
231
+ examples: ["aGk="],
232
+ };
233
+ return { words: `Text, stored as its UTF-8 bytes. Text that starts with "0x" is read as hex bytes instead.${max}`, examples: ["invoice 42", "0x1a2b"] };
234
+ }
235
+ }
236
+ throw new Error(`no description for the ${tag} tag`);
237
+ }
238
+
239
+ /**
240
+ * The input grammar of a value tag in words (spec §5.3), with the tag's resolved arguments filled in, so a form hint,
241
+ * a tool schema and the parser share one description. The summary quotes exactly the examples kept.
242
+ *
243
+ * @param {string} tag
244
+ * @param {Readonly<Record<string, any>>} args the tag's arguments, dynamic values resolved (`Formatter.args`)
245
+ * @param {string} candid
246
+ * @param {{ accepts?: (input: string) => boolean }} [options] keeps only the examples it accepts (`Formatter.describe`
247
+ * passes its own parser, so a range or `max_len` that refuses an example leaves it out); default: all of them
248
+ * @returns {Description}
249
+ */
250
+ export function describeInput(tag, args, candid, { accepts = () => true } = {}) {
251
+ // The same checks and normalisation formatterFor applies (decimals may arrive as "0" or 0n), so arguments it would
252
+ // refuse are refused here too, never described as another grammar.
253
+ if (VALUE_TAGS.includes(tag)) {
254
+ requireCandid({ candid }, CANDID_OF[tag], tag);
255
+ const a = args ?? {};
256
+ if (tag === "amount") args = { ...a, decimals: checkDecimals(a.decimals), symbol: checkSymbol(a.symbol) };
257
+ if (tag === "timestamp" || tag === "duration") requireUnit(a.unit, tag);
258
+ if (tag === "timestamp" && a.epoch !== undefined && a.epoch !== "unix") fail(CODES.ARG, `timestamp epoch must be "unix", got ${JSON.stringify(a.epoch)}`);
259
+ if (tag === "account" && a.encoding !== undefined && a.encoding !== "icrc1") fail(CODES.ARG, `account encoding must be "icrc1", got ${JSON.stringify(a.encoding)}`);
260
+ if (tag === "bytes") {
261
+ if (!BYTES_ENCODINGS.includes(a.encoding)) fail(CODES.ARG, `the bytes tag needs encoding utf8, hex or base64, got ${JSON.stringify(a.encoding)}`);
262
+ checkMaxLen(a.max_len);
263
+ }
264
+ }
265
+ const g = grammar(tag, args, candid);
266
+ const examples = g.examples.filter((x) => accepts(x));
267
+ return {
268
+ summary: g.words + (examples.length > 0 ? ` For example ${quoted(examples)}.` : ""),
269
+ examples,
270
+ ...(g.pattern === undefined ? {} : { pattern: g.pattern }),
271
+ };
272
+ }
@@ -0,0 +1,169 @@
1
+ // Types for @meantjs/format. Hand-written; the source is plain ES modules (src/*.mjs).
2
+ // test/types.test.mjs keeps the declared names equal to the runtime exports, and `npm run types` compiles a consumer
3
+ // against these declarations under strict TypeScript.
4
+
5
+ /** The value tags (spec §5.1). */
6
+ export type ValueTag = "amount" | "timestamp" | "duration" | "account" | "subaccount" | "bytes";
7
+ export type TimeUnit = "ns" | "us" | "ms" | "s";
8
+ export type BytesEncoding = "utf8" | "hex" | "base64";
9
+ /** Bytes as the library takes them: a Uint8Array (from any realm) or an array of byte values. */
10
+ export type ByteInput = Uint8Array | readonly number[];
11
+ /** An ICRC-1 account as `parseAccount` returns it: `subaccount` null is the default subaccount. */
12
+ export interface Account {
13
+ owner: string;
14
+ subaccount: Uint8Array | null;
15
+ }
16
+
17
+ /** The stable rejection codes; `FormatError.code` is one of them. */
18
+ export const CODES: Readonly<{
19
+ TYPE: "E_TYPE";
20
+ SYNTAX: "E_SYNTAX";
21
+ RANGE: "E_RANGE";
22
+ TOO_LONG: "E_TOO_LONG";
23
+ ARG: "E_ARG";
24
+ TAG_TYPE: "E_TAG_TYPE";
25
+ TAG_CONFLICT: "E_TAG_CONFLICT";
26
+ REF_UNRESOLVED: "E_REF_UNRESOLVED";
27
+ EXCESS_PRECISION: "E_EXCESS_PRECISION";
28
+ NEGATIVE: "E_NEGATIVE";
29
+ SYMBOL_MISMATCH: "E_SYMBOL_MISMATCH";
30
+ INEXACT: "E_INEXACT";
31
+ CALENDAR_UNIT: "E_CALENDAR_UNIT";
32
+ NEEDS_NOW: "E_NEEDS_NOW";
33
+ CHECKSUM: "E_CHECKSUM";
34
+ CHECKSUM_MISSING: "E_CHECKSUM_MISSING";
35
+ NOT_CANONICAL: "E_NOT_CANONICAL";
36
+ PRINCIPAL: "E_PRINCIPAL";
37
+ HEX: "E_HEX";
38
+ BASE64: "E_BASE64";
39
+ UTF8: "E_UTF8";
40
+ LENGTH: "E_LENGTH";
41
+ MAX_LEN: "E_MAX_LEN";
42
+ }>;
43
+ export type FormatErrorCode = (typeof CODES)[keyof typeof CODES];
44
+
45
+ /** Every rejection in this package. `message` says how to fix the input. */
46
+ export declare class FormatError extends Error {
47
+ constructor(code: FormatErrorCode, message: string);
48
+ name: "FormatError";
49
+ code: FormatErrorCode;
50
+ }
51
+
52
+ export interface AmountOptions {
53
+ decimals: number | bigint | string;
54
+ symbol?: string;
55
+ /** The Candid type, for the sign and range (default `nat`). */
56
+ candid?: string;
57
+ }
58
+ /** "1.5" or "1.5 ICP" to base units as a decimal string. Excess precision throws; nothing is rounded. */
59
+ export function parseAmount(input: string, options: AmountOptions): string;
60
+ /** Base units to "1.5 ICP". A number is refused: pass a decimal string or a bigint. */
61
+ export function displayAmount(value: string | bigint, options: AmountOptions): string;
62
+
63
+ /** An integer between units; to a coarser unit it must be an exact multiple (E_INEXACT otherwise). */
64
+ export function convertUnit(value: string | bigint, from: TimeUnit, to: TimeUnit): string;
65
+ /** A clock reading in ns floored to `unit`, as `parseTimestamp` applies it to `now`. */
66
+ export function alignNow(nowNs: bigint, unit: TimeUnit): bigint;
67
+ /** An RFC 3339 time, or "+" and an ISO 8601 duration from `now` (ns, required then). Returns the count of `unit`. */
68
+ export function parseTimestamp(input: string, options: { unit: TimeUnit; candid?: string; now?: bigint }): string;
69
+ /** Canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z". */
70
+ export function displayTimestamp(value: string | bigint, options: { unit: TimeUnit; candid?: string }): string;
71
+ /** An ISO 8601 duration of W, D, H, M and S ("PT24H"); returns the count of `unit`. */
72
+ export function parseDuration(input: string, options: { unit: TimeUnit; candid?: string }): string;
73
+ /** Canonical ISO 8601: "P1D", "PT1H30M", "PT0S". */
74
+ export function displayDuration(value: string | bigint, options: { unit: TimeUnit; candid?: string }): string;
75
+
76
+ /** ICRC-1 account text; the checksum is verified and non-canonical text is refused. */
77
+ export function parseAccount(input: string): Account;
78
+ /** An account in the ICRC-1 textual encoding; a subaccount absent, null or all zero is the default. */
79
+ export function displayAccount(account: { owner: string | Uint8Array; subaccount?: ByteInput | null }): string;
80
+ /** Canonical principal text to its bytes; the CRC-32 must match. */
81
+ export function principalFromText(text: string): Uint8Array;
82
+ /** Up to 29 bytes to canonical principal text. */
83
+ export function principalToText(bytes: Uint8Array): string;
84
+ /** "#n", a decimal n, or 64 hex characters to 32 bytes. */
85
+ export function parseSubaccount(input: string): Uint8Array;
86
+ /** "#n" when the first 24 bytes are zero, else 64 lowercase hex characters. */
87
+ export function displaySubaccount(value: ByteInput): string;
88
+ /** Text in the tag's encoding to bytes; `maxLen` is the tag's `max_len`. */
89
+ export function parseBytes(input: string, options: { encoding: BytesEncoding; maxLen?: number }): Uint8Array;
90
+ export function displayBytes(value: ByteInput, options: { encoding: BytesEncoding }): string;
91
+ /**
92
+ * The value as a Uint8Array of this realm, viewing the same memory, or null. Accepts a Uint8Array made in another
93
+ * realm (an iframe, a `node:vm` context), which fails `instanceof Uint8Array`; refuses other typed arrays, DataViews,
94
+ * look-alike objects and views of a detached buffer.
95
+ */
96
+ export function asUint8Array(value: unknown): Uint8Array | null;
97
+
98
+ /** The text a position reads, in words, with examples that parse there. */
99
+ export interface Description {
100
+ summary: string;
101
+ examples: string[];
102
+ /** An ECMAScript regular expression every accepted input matches (amount and subaccount only). */
103
+ pattern?: string;
104
+ }
105
+
106
+ export type SafeResult<T> = { ok: true; value: T } | { ok: false; error: { code: FormatErrorCode; message: string } };
107
+
108
+ /** What `parse` returns per tag: base units or a unit count as a decimal string, an account, or bytes. */
109
+ export type ParsedValue = string | Account | Uint8Array;
110
+
111
+ /** One position's formatter: `formatterFor` returns it with the tag's arguments resolved. */
112
+ export interface Formatter {
113
+ readonly tag: ValueTag;
114
+ readonly candid: string;
115
+ readonly args: Readonly<Record<string, unknown>>;
116
+ /** Throws FormatError. */
117
+ parse(input: string, context?: { now?: bigint }): ParsedValue;
118
+ /** Throws FormatError. */
119
+ display(value: unknown): string;
120
+ safeParse(input: string, context?: { now?: bigint }): SafeResult<ParsedValue>;
121
+ safeDisplay(value: unknown): SafeResult<string>;
122
+ describe(): Description;
123
+ }
124
+
125
+ /** A position of a resolved form, as far as formatting reads it. */
126
+ export interface FormatNode {
127
+ candid: string;
128
+ tags?: Record<string, { args?: Record<string, unknown> } | undefined>;
129
+ [key: string]: unknown;
130
+ }
131
+
132
+ /** The value tags, in spec order. */
133
+ export const VALUE_TAGS: readonly ValueTag[];
134
+ /**
135
+ * The formatter of a position, or null when it has no value tag (show it as Candid text). Throws FormatError for a
136
+ * missing ref, an invalid tag argument, a tag that does not fit the Candid type, or two value tags.
137
+ *
138
+ * @param options.refs dynamic tag values keyed by their `from`: `{ "query:icrc1_decimals": 8 }`
139
+ * @param options.now default clock for relative timestamps, ns since the Unix epoch
140
+ */
141
+ export function formatterFor(node: FormatNode, options?: { refs?: Record<string, unknown>; now?: bigint }): Formatter | null;
142
+ /** `Formatter.describe` without a formatter; `accepts` keeps only the examples it returns true for. */
143
+ export function describeInput(tag: ValueTag, args: Readonly<Record<string, unknown>>, candid: string, options?: { accepts?: (input: string) => boolean }): Description;
144
+
145
+ /** One entry of a names set (spec section 5.3, D-045), as the set's method lists it. */
146
+ export interface NameEntry<V = unknown> {
147
+ name: string;
148
+ value: V;
149
+ show?: unknown;
150
+ kind?: unknown;
151
+ }
152
+ /** The result of resolveName: the input itself when it is a value, the one matching entry's value, or why not. */
153
+ export type NameResolution<V = unknown> =
154
+ | { value: string; direct: true }
155
+ | { value: V | unknown; entry: NameEntry<V> }
156
+ | { error: "not_found" }
157
+ | { error: "ambiguous"; entries: NameEntry<V>[] };
158
+ /** Unicode NFKC, the default full lowercase mapping, White_Space runs made one space, then trimmed. */
159
+ export function normalizeName(text: string): string;
160
+ /** Resolves a person's or an agent's text against a set's entries by the rule of spec section 5.3. */
161
+ export function resolveName<V>(
162
+ entries: NameEntry<V>[],
163
+ input: string,
164
+ opts?: { isValue?: (text: string) => boolean; kind?: (kind: unknown) => boolean; by?: "value" | "show" },
165
+ ): NameResolution<V>;
166
+ /** The one entry with a name that a value belongs to, compared as Candid values (an account by owner and subaccount). */
167
+ export function ownerOf<V>(entries: NameEntry<V>[], value: unknown, opts?: { by?: "value" | "show" }): NameEntry<V> | undefined;
168
+ /** A named position's display: the one owning entry's name beside the shown value, or the value alone. */
169
+ export function nameOf<V>(entries: NameEntry<V>[], value: V, opts?: { by?: "value" | "show"; show?: (value: unknown) => string }): string;
package/src/index.mjs ADDED
@@ -0,0 +1,17 @@
1
+ export { FormatError, CODES } from "./errors.mjs";
2
+ export { parseAmount, displayAmount } from "./amount.mjs";
3
+ export {
4
+ alignNow,
5
+ convertUnit,
6
+ displayDuration,
7
+ displayTimestamp,
8
+ parseDuration,
9
+ parseTimestamp,
10
+ } from "./time.mjs";
11
+ export { parseAccount, displayAccount } from "./account.mjs";
12
+ export { principalFromText, principalToText } from "./principal.mjs";
13
+ export { parseSubaccount, displaySubaccount } from "./subaccount.mjs";
14
+ export { parseBytes, displayBytes } from "./bytes.mjs";
15
+ export { describeInput, formatterFor, VALUE_TAGS } from "./formatter.mjs";
16
+ export { asUint8Array } from "./util.mjs";
17
+ export { normalizeName, resolveName, nameOf, ownerOf } from "./names.mjs";
package/src/names.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Names (spec section 5.3, D-045): the one normalization and the one resolution rule every consumer uses to turn a
3
+ * name ("my Savings" without the "my") into the value a `names` set gives for it.
4
+ */
5
+
6
+ /** Unicode NFKC, the default full lowercase mapping, White_Space runs made one space, then trimmed. */
7
+ export function normalizeName(text) {
8
+ return text.normalize("NFKC").toLowerCase().replace(/\p{White_Space}+/gu, " ").trim();
9
+ }
10
+
11
+ /**
12
+ * Resolves `input` against a set's entries.
13
+ *
14
+ * @param entries [{ name, value, show?, kind? }] as the set's method lists them
15
+ * @param input the text a person or an agent gave
16
+ * @param opts.isValue whether a text is a valid value of the position by its own tags and type
17
+ * @param opts.kind when given, only entries for which kind(entry.kind) is true can match
18
+ * @param opts.by "value" (default) or "show": which field of the entry fills the position
19
+ * @returns { value, direct: true } for input that is a value; { value, entry } for one match;
20
+ * { error: "not_found" } or { error: "ambiguous", entries } otherwise
21
+ */
22
+ export function resolveName(entries, input, { isValue = () => false, kind, by = "value" } = {}) {
23
+ if (isValue(input)) return { value: input, direct: true };
24
+ const want = normalizeName(input);
25
+ if (!want) return { error: "not_found" };
26
+ const found = entries.filter((e) => {
27
+ const name = normalizeName(e.name);
28
+ return name !== "" && name === want && !isValue(e.name) && (!kind || kind(e.kind));
29
+ });
30
+ if (found.length === 0) return { error: "not_found" };
31
+ if (found.length > 1) return { error: "ambiguous", entries: found };
32
+ return { value: by === "show" ? found[0].show : found[0].value, entry: found[0] };
33
+ }
34
+
35
+ /**
36
+ * A Candid value as one comparable text: numbers and bigints by their digits, bytes by their content, records with
37
+ * their fields in order of name. Two values have the same key exactly when they are the same Candid value.
38
+ */
39
+ function valueKey(value) {
40
+ return JSON.stringify(value, (_, v) => {
41
+ if (typeof v === "bigint" || typeof v === "number") return `#${v}`;
42
+ if (ArrayBuffer.isView(v)) return `0x${Array.from(new Uint8Array(v.buffer, v.byteOffset, v.byteLength), (b) => b.toString(16).padStart(2, "0")).join("")}`;
43
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
44
+ return v;
45
+ });
46
+ }
47
+
48
+ /** The one entry with a name that `value` belongs to, compared as Candid values (an account by owner and subaccount); undefined if none or several. */
49
+ export function ownerOf(entries, value, { by = "value" } = {}) {
50
+ const key = valueKey(value);
51
+ const owners = entries.filter((e) => valueKey(by === "show" ? e.show : e.value) === key && normalizeName(e.name) !== "");
52
+ return owners.length === 1 ? owners[0] : undefined;
53
+ }
54
+
55
+ /** The display of a named position's value: the name of the one entry it belongs to, then the shown value. */
56
+ export function nameOf(entries, value, { by = "value", show = String } = {}) {
57
+ const owner = ownerOf(entries, value, { by });
58
+ return owner ? `${owner.name} (${show(owner.show ?? value)})` : show(value);
59
+ }
@@ -0,0 +1,117 @@
1
+ // Principal text <-> bytes, and the CRC-32 and base32 pieces shared with the ICRC-1 account text.
2
+ //
3
+ // Implemented here, with no dependency, because it is ~60 lines, the algorithm is fixed by the
4
+ // Internet Computer interface specification ("textual ids": lowercase base32 without padding of
5
+ // CRC-32 big-endian followed by the bytes, in groups of five separated by dashes), and it is
6
+ // checked in the tests against the official ICRC-1 vectors, well-known canister ids, node:zlib's
7
+ // CRC-32 and an independent big-integer base32 encoder. See README.md for why not
8
+ // @dfinity/principal (deprecated) or @icp-sdk/core (large).
9
+
10
+ import { CODES, fail } from "./errors.mjs";
11
+ import { MAX_INPUT_CHARS, asUint8Array } from "./util.mjs";
12
+
13
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
14
+ export const MAX_PRINCIPAL_BYTES = 29;
15
+
16
+ const CRC_TABLE = (() => {
17
+ const table = new Uint32Array(256);
18
+ for (let n = 0; n < 256; n++) {
19
+ let c = n;
20
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
21
+ table[n] = c >>> 0;
22
+ }
23
+ return table;
24
+ })();
25
+
26
+ /** CRC-32 (IEEE 802.3, the zlib polynomial) as an unsigned 32-bit integer. */
27
+ export function crc32(bytes) {
28
+ let c = 0xffffffff;
29
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
30
+ return (c ^ 0xffffffff) >>> 0;
31
+ }
32
+
33
+ const crc32BigEndian = (bytes) => {
34
+ const out = new Uint8Array(4);
35
+ new DataView(out.buffer).setUint32(0, crc32(bytes), false);
36
+ return out;
37
+ };
38
+
39
+ /** RFC 4648 base32, lowercase, no padding. */
40
+ export function base32Encode(bytes) {
41
+ let out = "";
42
+ let bits = 0;
43
+ let acc = 0;
44
+ for (const b of bytes) {
45
+ acc = (acc << 8) | b;
46
+ bits += 8;
47
+ while (bits >= 5) {
48
+ out += ALPHABET[(acc >>> (bits - 5)) & 31];
49
+ bits -= 5;
50
+ }
51
+ acc &= (1 << bits) - 1;
52
+ }
53
+ if (bits > 0) out += ALPHABET[(acc << (5 - bits)) & 31];
54
+ return out;
55
+ }
56
+
57
+ /** Strict decode of lowercase base32 without padding; null on a bad character, length or spare bit. */
58
+ function base32Decode(text) {
59
+ const out = [];
60
+ let bits = 0;
61
+ let acc = 0;
62
+ for (const ch of text) {
63
+ const v = ALPHABET.indexOf(ch);
64
+ if (v < 0) return null;
65
+ acc = (acc << 5) | v;
66
+ bits += 5;
67
+ if (bits >= 8) {
68
+ out.push((acc >>> (bits - 8)) & 0xff);
69
+ bits -= 8;
70
+ }
71
+ acc &= (1 << bits) - 1;
72
+ }
73
+ if (bits >= 5 || acc !== 0) return null; // a stray character or non-zero padding bits
74
+ return Uint8Array.from(out);
75
+ }
76
+
77
+ /** The ICRC-1 checksum of an account: base32(CRC-32(owner bytes ++ subaccount bytes)), 7 characters. */
78
+ export function accountChecksum(ownerBytes, subaccount) {
79
+ const all = new Uint8Array(ownerBytes.length + subaccount.length);
80
+ all.set(ownerBytes, 0);
81
+ all.set(subaccount, ownerBytes.length);
82
+ return base32Encode(crc32BigEndian(all));
83
+ }
84
+
85
+ /** Canonical principal text for up to 29 bytes. */
86
+ export function principalToText(input) {
87
+ const bytes = asUint8Array(input);
88
+ if (bytes === null) fail(CODES.TYPE, "principal bytes must be a Uint8Array");
89
+ if (bytes.length > MAX_PRINCIPAL_BYTES) fail(CODES.PRINCIPAL, `a principal is at most ${MAX_PRINCIPAL_BYTES} bytes, got ${bytes.length}`);
90
+ const crc = crc32BigEndian(bytes);
91
+ const all = new Uint8Array(4 + bytes.length);
92
+ all.set(crc, 0);
93
+ all.set(bytes, 4);
94
+ return base32Encode(all).match(/.{1,5}/g).join("-");
95
+ }
96
+
97
+ /**
98
+ * Principal text to bytes. Case-insensitive; the dashes must be exactly where the canonical text
99
+ * puts them, and the CRC-32 must match. Anything else throws E_PRINCIPAL.
100
+ */
101
+ export function principalFromText(text) {
102
+ if (typeof text !== "string") fail(CODES.TYPE, `principal must be a string, got ${typeof text}`);
103
+ if (text.length > MAX_INPUT_CHARS) fail(CODES.TOO_LONG, "principal text is too long");
104
+ const lower = text.toLowerCase();
105
+ const decoded = base32Decode(lower.replaceAll("-", ""));
106
+ if (decoded === null) fail(CODES.PRINCIPAL, `${JSON.stringify(text)} is not a principal: it must be base32 (a to z, 2 to 7) in groups of five separated by dashes`);
107
+ if (decoded.length < 4) fail(CODES.PRINCIPAL, `${JSON.stringify(text)} is not a principal: too short`);
108
+ const data = decoded.subarray(4);
109
+ if (data.length > MAX_PRINCIPAL_BYTES) fail(CODES.PRINCIPAL, `${JSON.stringify(text)} is not a principal: more than ${MAX_PRINCIPAL_BYTES} bytes`);
110
+ const expected = crc32BigEndian(data);
111
+ if (!decoded.subarray(0, 4).every((b, i) => b === expected[i])) {
112
+ fail(CODES.PRINCIPAL, `${JSON.stringify(text)} is not a principal: the checksum does not match (a character was probably mistyped)`);
113
+ }
114
+ const canonical = principalToText(data);
115
+ if (lower !== canonical) fail(CODES.PRINCIPAL, `${JSON.stringify(text)} is not a canonical principal: dashes must separate groups of five, as in "${canonical}"`);
116
+ return Uint8Array.from(data);
117
+ }