@c9up/rune 0.1.6 → 0.1.8
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/dist/MessagesProvider.d.ts +45 -0
- package/dist/MessagesProvider.d.ts.map +1 -0
- package/dist/MessagesProvider.js +77 -0
- package/dist/MessagesProvider.js.map +1 -0
- package/dist/Schema.d.ts +912 -38
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +2841 -219
- package/dist/Schema.js.map +1 -1
- package/dist/date.d.ts +36 -0
- package/dist/date.d.ts.map +1 -0
- package/dist/date.js +275 -0
- package/dist/date.js.map +1 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -1
- package/dist/formats.d.ts +149 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +612 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +152 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +181 -2
- package/dist/index.js.map +1 -1
- package/dist/magic.d.ts +30 -0
- package/dist/magic.d.ts.map +1 -0
- package/dist/magic.js +154 -0
- package/dist/magic.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +11 -1
- package/src/MessagesProvider.ts +115 -0
- package/src/Schema.ts +3970 -215
- package/src/date.ts +320 -0
- package/src/errors.ts +59 -0
- package/src/formats.ts +721 -0
- package/src/index.ts +266 -1
- package/src/magic.ts +181 -0
- package/src/types.ts +55 -0
package/src/date.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date parsing for `rules.date()` — VineJS `vine.date()` parity.
|
|
3
|
+
*
|
|
4
|
+
* VineJS delegates parsing to dayjs. rune has **zero runtime dependencies** and
|
|
5
|
+
* keeps it that way, so the formats it accepts are implemented here: ISO 8601,
|
|
6
|
+
* unix timestamps, and a small token grammar covering the shapes VineJS users
|
|
7
|
+
* actually pass (`DD/MM/YYYY`, `YYYY-MM-DD HH:mm:ss`, …).
|
|
8
|
+
*
|
|
9
|
+
* Every parse is calendar-strict: `2026-02-31` and `2026-13-01` match the
|
|
10
|
+
* pattern but are not dates, and a regex-only check would let them through.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Formats accepted by `rules.date({ formats })`. */
|
|
14
|
+
export type DateFormat = "iso8601" | "x" | "X" | (string & {});
|
|
15
|
+
|
|
16
|
+
const ISO_RE =
|
|
17
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
18
|
+
|
|
19
|
+
/** Reject a day that the month does not have (incl. leap years). */
|
|
20
|
+
function isRealDate(y: number, m: number, d: number): boolean {
|
|
21
|
+
if (m < 1 || m > 12 || d < 1) return false;
|
|
22
|
+
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
23
|
+
return d <= daysInMonth;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildDate(
|
|
27
|
+
y: number,
|
|
28
|
+
mo: number,
|
|
29
|
+
d: number,
|
|
30
|
+
h = 0,
|
|
31
|
+
mi = 0,
|
|
32
|
+
s = 0,
|
|
33
|
+
ms = 0,
|
|
34
|
+
offset?: string,
|
|
35
|
+
): Date | null {
|
|
36
|
+
if (!isRealDate(y, mo, d)) return null;
|
|
37
|
+
if (h > 23 || mi > 59 || s > 59) return null;
|
|
38
|
+
if (offset === undefined) {
|
|
39
|
+
const local = new Date(y, mo - 1, d, h, mi, s, ms);
|
|
40
|
+
return Number.isNaN(local.getTime()) ? null : local;
|
|
41
|
+
}
|
|
42
|
+
let utcMs = Date.UTC(y, mo - 1, d, h, mi, s, ms);
|
|
43
|
+
if (offset !== "Z") {
|
|
44
|
+
const sign = offset.startsWith("-") ? 1 : -1;
|
|
45
|
+
const [oh, om] = offset.slice(1).replace(":", "").match(/\d{2}/g) ?? [];
|
|
46
|
+
utcMs += sign * ((Number(oh) || 0) * 60 + (Number(om) || 0)) * 60_000;
|
|
47
|
+
}
|
|
48
|
+
const utc = new Date(utcMs);
|
|
49
|
+
return Number.isNaN(utc.getTime()) ? null : utc;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse an ISO 8601 date or date-time. Returns `null` when not parseable. */
|
|
53
|
+
export function parseIso(value: string): Date | null {
|
|
54
|
+
const m = ISO_RE.exec(value.trim());
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
const frac = m[7] ? Number(`0.${m[7]}`) * 1000 : 0;
|
|
57
|
+
return buildDate(
|
|
58
|
+
Number(m[1]),
|
|
59
|
+
Number(m[2]),
|
|
60
|
+
Number(m[3]),
|
|
61
|
+
Number(m[4] ?? 0),
|
|
62
|
+
Number(m[5] ?? 0),
|
|
63
|
+
Number(m[6] ?? 0),
|
|
64
|
+
Math.round(frac),
|
|
65
|
+
m[8],
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Token grammar for custom formats — longest tokens first so `YYYY` beats `YY`. */
|
|
70
|
+
/** Month names, long then short — index 0 is January. */
|
|
71
|
+
const MONTHS_LONG = [
|
|
72
|
+
"january",
|
|
73
|
+
"february",
|
|
74
|
+
"march",
|
|
75
|
+
"april",
|
|
76
|
+
"may",
|
|
77
|
+
"june",
|
|
78
|
+
"july",
|
|
79
|
+
"august",
|
|
80
|
+
"september",
|
|
81
|
+
"october",
|
|
82
|
+
"november",
|
|
83
|
+
"december",
|
|
84
|
+
];
|
|
85
|
+
const MONTHS_SHORT = MONTHS_LONG.map((month) => month.slice(0, 3));
|
|
86
|
+
|
|
87
|
+
/** Weekday names. Parsed and CONSUMED, but they never set the date: a name that
|
|
88
|
+
* contradicts the numeric date would otherwise silently win. */
|
|
89
|
+
const DAYS_LONG = [
|
|
90
|
+
"sunday",
|
|
91
|
+
"monday",
|
|
92
|
+
"tuesday",
|
|
93
|
+
"wednesday",
|
|
94
|
+
"thursday",
|
|
95
|
+
"friday",
|
|
96
|
+
"saturday",
|
|
97
|
+
];
|
|
98
|
+
const DAYS_SHORT = DAYS_LONG.map((day) => day.slice(0, 3));
|
|
99
|
+
|
|
100
|
+
// Longest-first ordering is load-bearing: `MMMM` must be tried before `MM`,
|
|
101
|
+
// which must come before `M`, or a name is read as a number and the parse fails.
|
|
102
|
+
const TOKENS: Array<[string, string]> = [
|
|
103
|
+
["MMMM", `(${MONTHS_LONG.join("|")})`],
|
|
104
|
+
["MMM", `(${MONTHS_SHORT.join("|")})`],
|
|
105
|
+
["dddd", `(${DAYS_LONG.join("|")})`],
|
|
106
|
+
["ddd", `(${DAYS_SHORT.join("|")})`],
|
|
107
|
+
["YYYY", "(\\d{4})"],
|
|
108
|
+
["YY", "(\\d{2})"],
|
|
109
|
+
["MM", "(\\d{2})"],
|
|
110
|
+
["M", "(\\d{1,2})"],
|
|
111
|
+
["Do", "(\\d{1,2})(?:st|nd|rd|th)"],
|
|
112
|
+
["DD", "(\\d{2})"],
|
|
113
|
+
["D", "(\\d{1,2})"],
|
|
114
|
+
["HH", "(\\d{2})"],
|
|
115
|
+
["H", "(\\d{1,2})"],
|
|
116
|
+
["hh", "(\\d{2})"],
|
|
117
|
+
["h", "(\\d{1,2})"],
|
|
118
|
+
["mm", "(\\d{2})"],
|
|
119
|
+
["m", "(\\d{1,2})"],
|
|
120
|
+
["ss", "(\\d{2})"],
|
|
121
|
+
["s", "(\\d{1,2})"],
|
|
122
|
+
["SSS", "(\\d{3})"],
|
|
123
|
+
["A", "(AM|PM)"],
|
|
124
|
+
["a", "(am|pm)"],
|
|
125
|
+
["ZZ", "([+-]\\d{4}|Z)"],
|
|
126
|
+
["Z", "([+-]\\d{2}:\\d{2}|Z)"],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/** Parse `value` against a token format such as `DD/MM/YYYY`. */
|
|
130
|
+
export function parseWithFormat(value: string, format: string): Date | null {
|
|
131
|
+
const order: string[] = [];
|
|
132
|
+
let pattern = "";
|
|
133
|
+
let i = 0;
|
|
134
|
+
while (i < format.length) {
|
|
135
|
+
// `[…]` escapes a literal run, so `[at] HH:mm` does not read the `a` as a
|
|
136
|
+
// meridiem token.
|
|
137
|
+
if (format[i] === "[") {
|
|
138
|
+
const close = format.indexOf("]", i);
|
|
139
|
+
if (close === -1) return null;
|
|
140
|
+
pattern += format
|
|
141
|
+
.slice(i + 1, close)
|
|
142
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
143
|
+
i = close + 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const token = TOKENS.find(([t]) => format.startsWith(t, i));
|
|
147
|
+
if (token) {
|
|
148
|
+
order.push(token[0]);
|
|
149
|
+
pattern += token[1];
|
|
150
|
+
i += token[0].length;
|
|
151
|
+
} else {
|
|
152
|
+
pattern += format[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Case-insensitive: `Jan`, `jan` and `JAN` are the same month, and a format
|
|
157
|
+
// carrying a name token would otherwise only match one spelling.
|
|
158
|
+
const m = new RegExp(`^${pattern}$`, "i").exec(value.trim());
|
|
159
|
+
if (!m) return null;
|
|
160
|
+
const part: Record<string, number> = {};
|
|
161
|
+
let meridiem: "am" | "pm" | null = null;
|
|
162
|
+
let offset: string | undefined;
|
|
163
|
+
order.forEach((t, idx) => {
|
|
164
|
+
const raw = m[idx + 1];
|
|
165
|
+
if (t === "A" || t === "a") {
|
|
166
|
+
meridiem = raw.toLowerCase() as "am" | "pm";
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (t === "MMMM" || t === "MMM") {
|
|
170
|
+
const names = t === "MMMM" ? MONTHS_LONG : MONTHS_SHORT;
|
|
171
|
+
part.MM = names.indexOf(raw.toLowerCase()) + 1;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (t === "dddd" || t === "ddd") {
|
|
175
|
+
// Consumed only. A weekday name that contradicts the numeric date must
|
|
176
|
+
// not silently override it — the date wins, the name is decoration.
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (t === "Do") {
|
|
180
|
+
part.DD = Number(raw);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (t === "Z" || t === "ZZ") {
|
|
184
|
+
offset = raw.toUpperCase() === "Z" ? "Z" : raw;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
part[t] = Number(raw);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// A two-digit year follows the Day.js pivot: 00-68 → 2000s, 69-99 → 1900s.
|
|
191
|
+
const year =
|
|
192
|
+
part.YYYY ??
|
|
193
|
+
(part.YY === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: part.YY <= 68
|
|
196
|
+
? 2000 + part.YY
|
|
197
|
+
: 1900 + part.YY);
|
|
198
|
+
const month = part.MM ?? part.M;
|
|
199
|
+
const day = part.DD ?? part.D;
|
|
200
|
+
if (year === undefined || month === undefined || day === undefined) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let hour = part.HH ?? part.H ?? part.hh ?? part.h ?? 0;
|
|
205
|
+
if (meridiem !== null) {
|
|
206
|
+
const twelveHour = part.hh ?? part.h;
|
|
207
|
+
if (twelveHour === undefined || twelveHour < 1 || twelveHour > 12) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
hour = twelveHour % 12;
|
|
211
|
+
if (meridiem === "pm") hour += 12;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return buildDate(
|
|
215
|
+
year,
|
|
216
|
+
month,
|
|
217
|
+
day,
|
|
218
|
+
hour,
|
|
219
|
+
part.mm ?? part.m ?? 0,
|
|
220
|
+
part.ss ?? part.s ?? 0,
|
|
221
|
+
part.SSS ?? 0,
|
|
222
|
+
offset,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Parse a value against the configured formats. `Date` instances pass straight
|
|
228
|
+
* through (already parsed); numbers and numeric strings are read as timestamps
|
|
229
|
+
* only when the `x`/`X` format is enabled, matching VineJS.
|
|
230
|
+
*/
|
|
231
|
+
export function parseDateValue(
|
|
232
|
+
value: unknown,
|
|
233
|
+
formats: DateFormat[],
|
|
234
|
+
): Date | null {
|
|
235
|
+
if (value instanceof Date) {
|
|
236
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
237
|
+
}
|
|
238
|
+
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
239
|
+
|
|
240
|
+
for (const format of formats) {
|
|
241
|
+
if (format === "x" || format === "X") {
|
|
242
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
243
|
+
if (!Number.isFinite(n)) continue;
|
|
244
|
+
const d = new Date(format === "x" ? n : n * 1000);
|
|
245
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const s = String(value);
|
|
249
|
+
const d = format === "iso8601" ? parseIso(s) : parseWithFormat(s, format);
|
|
250
|
+
if (d) return d;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Resolve a comparison operand: a keyword, an ISO string, a number or a Date. */
|
|
256
|
+
export function resolveOperand(operand: unknown): Date | null {
|
|
257
|
+
if (typeof operand === "string") {
|
|
258
|
+
const midnight = (offsetDays: number): Date => {
|
|
259
|
+
const d = new Date();
|
|
260
|
+
d.setHours(0, 0, 0, 0);
|
|
261
|
+
d.setDate(d.getDate() + offsetDays);
|
|
262
|
+
return d;
|
|
263
|
+
};
|
|
264
|
+
if (operand === "today") return midnight(0);
|
|
265
|
+
if (operand === "tomorrow") return midnight(1);
|
|
266
|
+
if (operand === "yesterday") return midnight(-1);
|
|
267
|
+
}
|
|
268
|
+
return parseDateValue(operand, ["iso8601", "x"]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Truncate to the start of the day — backs `{ compare: 'day' }`. */
|
|
272
|
+
export function startOfDay(date: Date): Date {
|
|
273
|
+
const d = new Date(date.getTime());
|
|
274
|
+
d.setHours(0, 0, 0, 0);
|
|
275
|
+
return d;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Granularity of a date comparison (VineJS `{ compare }`, dayjs units). */
|
|
279
|
+
export type CompareUnit =
|
|
280
|
+
| "millisecond"
|
|
281
|
+
| "second"
|
|
282
|
+
| "minute"
|
|
283
|
+
| "hour"
|
|
284
|
+
| "day"
|
|
285
|
+
| "month"
|
|
286
|
+
| "year";
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Truncate a date to `unit`, so a comparison ignores everything finer.
|
|
290
|
+
* VineJS compares at DAY granularity by default (`options.compare || "day"`),
|
|
291
|
+
* which is why a bare `after('today')` is about the date, not the clock.
|
|
292
|
+
*/
|
|
293
|
+
export function truncateTo(date: Date, unit: CompareUnit): number {
|
|
294
|
+
const d = new Date(date.getTime());
|
|
295
|
+
switch (unit) {
|
|
296
|
+
case "year":
|
|
297
|
+
d.setMonth(0, 1);
|
|
298
|
+
d.setHours(0, 0, 0, 0);
|
|
299
|
+
break;
|
|
300
|
+
case "month":
|
|
301
|
+
d.setDate(1);
|
|
302
|
+
d.setHours(0, 0, 0, 0);
|
|
303
|
+
break;
|
|
304
|
+
case "day":
|
|
305
|
+
d.setHours(0, 0, 0, 0);
|
|
306
|
+
break;
|
|
307
|
+
case "hour":
|
|
308
|
+
d.setMinutes(0, 0, 0);
|
|
309
|
+
break;
|
|
310
|
+
case "minute":
|
|
311
|
+
d.setSeconds(0, 0);
|
|
312
|
+
break;
|
|
313
|
+
case "second":
|
|
314
|
+
d.setMilliseconds(0);
|
|
315
|
+
break;
|
|
316
|
+
case "millisecond":
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
return d.getTime();
|
|
320
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -12,3 +12,62 @@ export class RuneError extends Error {
|
|
|
12
12
|
this.hint = options?.hint;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A single validation failure in a {@link RuneValidationError} — mirrors the
|
|
18
|
+
* VineJS `SimpleErrorReporter` node shape `{ message, rule, field, index?, meta? }`.
|
|
19
|
+
*/
|
|
20
|
+
export interface RuneErrorNode {
|
|
21
|
+
/** Human-readable (already interpolated) error message. */
|
|
22
|
+
message: string;
|
|
23
|
+
/** The rule that failed, e.g. `required`, `minLength`, `email`. */
|
|
24
|
+
rule: string;
|
|
25
|
+
/** Dotted field path, e.g. `user.email` or `tags.0`. */
|
|
26
|
+
field: string;
|
|
27
|
+
/** Array index when the field is an array item (VineJS parity). */
|
|
28
|
+
index?: number;
|
|
29
|
+
/** Rule metadata carried for reporters/i18n (e.g. `{ min: 3 }`). */
|
|
30
|
+
meta?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thrown by {@link ValidationSchema.validateOrThrow} — VineJS's
|
|
35
|
+
* `E_VALIDATION_ERROR`. Carries the structured `messages` array and an HTTP
|
|
36
|
+
* `status` (422) so web layers can render it directly, matching AdonisJS/VineJS.
|
|
37
|
+
*/
|
|
38
|
+
export class RuneValidationError extends Error {
|
|
39
|
+
/** Internal error code for programmatic handling (VineJS parity). */
|
|
40
|
+
readonly code = "E_VALIDATION_ERROR";
|
|
41
|
+
/** HTTP status for the failure (422 Unprocessable Entity). */
|
|
42
|
+
readonly status = 422;
|
|
43
|
+
/** Structured, per-field validation messages. */
|
|
44
|
+
readonly messages: RuneErrorNode[];
|
|
45
|
+
|
|
46
|
+
constructor(messages: RuneErrorNode[], options?: ErrorOptions) {
|
|
47
|
+
super("Validation failure", options);
|
|
48
|
+
this.name = "RuneValidationError";
|
|
49
|
+
this.messages = messages;
|
|
50
|
+
if ("captureStackTrace" in Error) {
|
|
51
|
+
Error.captureStackTrace(this, RuneValidationError);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get [Symbol.toStringTag](): string {
|
|
56
|
+
return this.name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override toString(): string {
|
|
60
|
+
return `${this.name} [${this.code}]: ${this.message}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* VineJS-compatible alias. Adonis/Vine code catches on the error's NAME:
|
|
66
|
+
*
|
|
67
|
+
* import { errors } from '@c9up/rune'
|
|
68
|
+
* if (error instanceof errors.E_VALIDATION_ERROR) { ... }
|
|
69
|
+
*
|
|
70
|
+
* Without this binding the namespace exists but the member does not, and a
|
|
71
|
+
* copy-pasted Adonis handler silently never matches.
|
|
72
|
+
*/
|
|
73
|
+
export const E_VALIDATION_ERROR = RuneValidationError;
|