@flaredev/core 0.1.2 → 0.2.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,410 @@
1
+ // src/resource/fields.ts
2
+ var STRING_FORMATS = ["email", "url", "tel", "domain", "country", "color", "slug"];
3
+ var FILE_CATEGORIES = {
4
+ image: ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"],
5
+ pdf: ["application/pdf"],
6
+ video: ["video/*"],
7
+ audio: ["audio/*"],
8
+ text: ["text/plain"],
9
+ csv: ["text/csv"],
10
+ document: [
11
+ "application/msword",
12
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
13
+ "application/vnd.oasis.opendocument.text"
14
+ ],
15
+ spreadsheet: [
16
+ "application/vnd.ms-excel",
17
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
18
+ "application/vnd.oasis.opendocument.spreadsheet"
19
+ ],
20
+ archive: ["application/zip"]
21
+ };
22
+ function mimeTypesFor(accept) {
23
+ return [...new Set(accept.flatMap((category) => FILE_CATEGORIES[category]))];
24
+ }
25
+ var DEFAULT_FILE_MAX_BYTES = 10 * 1024 * 1024;
26
+ var fileMaxBytes = (field2) => field2.maxBytes ?? DEFAULT_FILE_MAX_BYTES;
27
+ function build(kind, options, extra = {}) {
28
+ return { kind, ...extra, ...options, required: options?.required !== false };
29
+ }
30
+ var field = {
31
+ string: (options) => build("string", options),
32
+ text: (options) => build("text", options, { list: false, searchable: false }),
33
+ int: (options) => build("int", options),
34
+ float: (options) => build("float", options),
35
+ boolean: (options) => build("boolean", options),
36
+ date: (options) => build("date", options),
37
+ datetime: (options) => build("datetime", options),
38
+ enum: (values, options) => build("enum", options, { options: values }),
39
+ /** One of a list, picked from a dropdown. The same as `enum`. */
40
+ select: (values, options) => build("enum", options, { options: values, widget: "select" }),
41
+ /** One of a list, picked with radio buttons. Stored like `enum`. */
42
+ radio: (values, options) => build("enum", options, { options: values, widget: "radio" }),
43
+ /** Any number of a list, picked with checkboxes. Stored as a JSON array. */
44
+ multiselect: (values, options) => build("multiselect", options, { options: values, sortable: false }),
45
+ email: (options) => build("string", options, { format: "email" }),
46
+ url: (options) => build("string", options, { format: "url" }),
47
+ tel: (options) => build("string", options, { format: "tel" }),
48
+ domain: (options) => build("string", options, { format: "domain" }),
49
+ country: (options) => build("string", options, { format: "country" }),
50
+ color: (options) => build("string", options, { format: "color" }),
51
+ slug: (options) => build("string", options, { format: "slug" }),
52
+ file: (accept, options) => build("file", options, { accept, list: false, sortable: false }),
53
+ belongsTo: (target, options) => build("belongsTo", options, { target }),
54
+ hasMany: (target, options) => ({ kind: "hasMany", target, ...options, required: false })
55
+ };
56
+
57
+ // src/resource/naming.ts
58
+ function words(value) {
59
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
60
+ }
61
+ var pascalCase = (value) => words(value).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
62
+ var camelCase = (value) => {
63
+ const pascal = pascalCase(value);
64
+ return pascal ? pascal[0].toLowerCase() + pascal.slice(1) : pascal;
65
+ };
66
+ var snakeCase = (value) => words(value).join("_");
67
+ var kebabCase = (value) => words(value).join("-");
68
+ var humanize = (value) => {
69
+ const text = words(value).join(" ");
70
+ return text ? text[0].toUpperCase() + text.slice(1) : text;
71
+ };
72
+ var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news"]);
73
+ var IRREGULAR = { person: "people", man: "men", woman: "women", child: "children", mouse: "mice" };
74
+ function pluralize(value) {
75
+ const match = /([A-Za-z]+)$/.exec(value);
76
+ if (!match) return value;
77
+ const word = match[1];
78
+ const lower = word.toLowerCase();
79
+ const head = value.slice(0, value.length - word.length);
80
+ const keepCase = (plural) => word[0] === word[0].toUpperCase() ? plural[0].toUpperCase() + plural.slice(1) : plural;
81
+ if (UNCOUNTABLE.has(lower)) return value;
82
+ if (IRREGULAR[lower]) return head + keepCase(IRREGULAR[lower]);
83
+ if (/[^aeiou]y$/i.test(word)) return head + word.slice(0, -1) + "ies";
84
+ if (/(s|x|z|ch|sh)$/i.test(word)) return head + word + "es";
85
+ return head + word + "s";
86
+ }
87
+
88
+ // src/resource/define.ts
89
+ var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt"];
90
+ var ResourceDefinitionError = class extends Error {
91
+ constructor(resource, message) {
92
+ super(`Resource "${resource}": ${message}`);
93
+ this.name = "ResourceDefinitionError";
94
+ }
95
+ };
96
+ var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
97
+ function defaultFieldLabel(key, field2) {
98
+ return humanize(field2.kind === "belongsTo" ? key.replace(/Id$/, "") : key);
99
+ }
100
+ function defineResource(config) {
101
+ const name = config.name;
102
+ const fail = (message) => {
103
+ throw new ResourceDefinitionError(name, message);
104
+ };
105
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || pascalCase(name) !== name) fail('name must be PascalCase, e.g. "OrderItem".');
106
+ const entries = Object.entries(config.fields);
107
+ if (entries.length === 0) fail("define at least one field.");
108
+ const fields = {};
109
+ for (const [key, def] of entries) {
110
+ if (!IDENTIFIER.test(key)) fail(`field "${key}" must be camelCase (letters and digits, starting lowercase).`);
111
+ if (RESERVED_FIELD_NAMES.includes(key)) fail(`"${key}" is added automatically; don't declare it.`);
112
+ if (def.kind === "enum") {
113
+ if (def.options.length === 0) fail(`enum field "${key}" needs at least one option.`);
114
+ if (new Set(def.options).size !== def.options.length) fail(`enum field "${key}" has duplicate options.`);
115
+ if (def.default !== void 0 && !def.options.includes(def.default)) fail(`default of "${key}" isn't one of its options.`);
116
+ }
117
+ if (def.kind === "multiselect") {
118
+ if (def.options.length === 0) fail(`multiselect field "${key}" needs at least one option.`);
119
+ if (new Set(def.options).size !== def.options.length) fail(`multiselect field "${key}" has duplicate options.`);
120
+ if (def.default?.some((value) => !def.options.includes(value))) fail(`default of "${key}" includes a value that isn't one of its options.`);
121
+ if (def.minItems !== void 0 && def.maxItems !== void 0 && def.minItems > def.maxItems) fail(`"${key}": minItems is larger than maxItems.`);
122
+ }
123
+ if (def.kind === "string" && def.format && !STRING_FORMATS.includes(def.format)) {
124
+ fail(`field "${key}" has unknown format "${def.format}". Formats: ${STRING_FORMATS.join(", ")}.`);
125
+ }
126
+ if (def.kind === "belongsTo" && !key.endsWith("Id")) fail(`belongsTo field "${key}" must end in "Id", e.g. "${camelCase(def.target)}Id".`);
127
+ if ((def.kind === "belongsTo" || def.kind === "hasMany") && pascalCase(def.target) !== def.target) {
128
+ fail(`relation "${key}" must target a PascalCase resource name.`);
129
+ }
130
+ if (def.kind === "belongsTo" && def.onDelete === "set null" && def.required) {
131
+ fail(`"${key}" uses onDelete "set null" but is required; mark it required: false.`);
132
+ }
133
+ if (def.kind === "file" && def.accept.length === 0) fail(`file field "${key}" must accept at least one category.`);
134
+ fields[key] = { ...def, label: def.label ?? defaultFieldLabel(key, def) };
135
+ }
136
+ const stored = entries.filter(([, def]) => def.kind !== "hasMany");
137
+ const titleField = config.titleField ?? stored.find(([, def]) => def.kind === "string" && !["tel", "country", "color"].includes(def.format ?? ""))?.[0] ?? stored.find(([, def]) => def.kind === "string")?.[0] ?? stored[0]?.[0] ?? "id";
138
+ if (config.titleField && !stored.some(([key]) => key === config.titleField)) {
139
+ fail(`titleField "${config.titleField}" isn't a stored field.`);
140
+ }
141
+ const defaultSort = config.defaultSort ?? { field: "createdAt", direction: "desc" };
142
+ if (!["createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
143
+ fail(`defaultSort field "${defaultSort.field}" isn't a stored field.`);
144
+ }
145
+ const perPage = config.perPage ?? 25;
146
+ if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) fail("perPage must be an integer from 1 to 100.");
147
+ const pluralName = pluralize(name);
148
+ return {
149
+ name,
150
+ table: config.table ?? snakeCase(pluralName),
151
+ slug: config.slug ?? kebabCase(pluralName),
152
+ label: config.label ?? humanize(name),
153
+ pluralLabel: config.pluralLabel ?? humanize(pluralName),
154
+ icon: config.icon,
155
+ titleField,
156
+ defaultSort,
157
+ perPage,
158
+ fields,
159
+ $types: void 0
160
+ };
161
+ }
162
+ function storedFields(resource) {
163
+ return Object.entries(resource.fields).filter(([, def]) => def.kind !== "hasMany");
164
+ }
165
+ var columnName = (key) => snakeCase(key);
166
+
167
+ // src/resource/formats.ts
168
+ import {
169
+ getCountries,
170
+ getCountryCallingCode,
171
+ isValidPhoneNumber,
172
+ parsePhoneNumberFromString
173
+ } from "libphonenumber-js/min";
174
+ function flagEmoji(code) {
175
+ const upper = code.toUpperCase();
176
+ if (!/^[A-Z]{2}$/.test(upper)) return "";
177
+ return String.fromCodePoint(...[...upper].map((letter) => 127462 + letter.charCodeAt(0) - 65));
178
+ }
179
+ var namesCache = /* @__PURE__ */ new Map();
180
+ function displayNames(locale) {
181
+ if (!namesCache.has(locale)) {
182
+ try {
183
+ namesCache.set(locale, new Intl.DisplayNames([locale, "en"], { type: "region" }));
184
+ } catch {
185
+ namesCache.set(locale, null);
186
+ }
187
+ }
188
+ return namesCache.get(locale);
189
+ }
190
+ function countryName(code, locale = "en") {
191
+ const upper = code.toUpperCase();
192
+ try {
193
+ return displayNames(locale)?.of(upper) ?? upper;
194
+ } catch {
195
+ return upper;
196
+ }
197
+ }
198
+ var listCache = /* @__PURE__ */ new Map();
199
+ function countries(locale = "en") {
200
+ let list = listCache.get(locale);
201
+ if (!list) {
202
+ list = getCountries().map((code) => ({ code, name: countryName(code, locale), dialCode: getCountryCallingCode(code), flag: flagEmoji(code) })).filter((country) => country.name !== country.code).sort((a, b) => a.name.localeCompare(b.name, locale));
203
+ listCache.set(locale, list);
204
+ }
205
+ return list;
206
+ }
207
+ function isCountryCode(value) {
208
+ return getCountries().includes(value.toUpperCase());
209
+ }
210
+ function isPhoneNumber(value) {
211
+ return value.startsWith("+") && isValidPhoneNumber(value);
212
+ }
213
+ function formatPhone(value) {
214
+ return parsePhoneNumberFromString(value)?.formatInternational() ?? value;
215
+ }
216
+ function splitPhone(value) {
217
+ const parsed = value ? parsePhoneNumberFromString(value) : void 0;
218
+ if (!parsed) return { country: void 0, national: value.replace(/^\+/, "") };
219
+ return { country: parsed.country, national: parsed.nationalNumber };
220
+ }
221
+ function toE164(country, national) {
222
+ const digits = national.replace(/[^\d+]/g, "");
223
+ if (!digits) return "";
224
+ if (digits.startsWith("+")) return parsePhoneNumberFromString(digits)?.number ?? digits;
225
+ const parsed = parsePhoneNumberFromString(digits, country.toUpperCase());
226
+ return parsed?.number ?? `+${getCountryCallingCode(country.toUpperCase())}${digits.replace(/^0+/, "")}`;
227
+ }
228
+ var DOMAIN = /^(?=.{1,253}$)(?:(?!-)[a-z0-9-]{1,63}(?<!-)\.)+[a-z][a-z0-9-]{0,62}[a-z0-9]$/;
229
+ function normalizeDomain(value) {
230
+ return value.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/?#].*$/, "").replace(/\.$/, "");
231
+ }
232
+ var isDomain = (value) => DOMAIN.test(value);
233
+ var isColor = (value) => /^#[0-9a-f]{6}$/i.test(value);
234
+ var isSlug = (value) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
235
+ function slugify(value) {
236
+ return value.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
237
+ }
238
+
239
+ // src/resource/validators.ts
240
+ import { z } from "zod";
241
+ var DEFAULT_STRING_MAX = 255;
242
+ var DEFAULT_TEXT_MAX = 65535;
243
+ var OBJECT_KEY = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).{1,1024}$/;
244
+ var typeError = (message) => (issue) => issue.input === void 0 || issue.input === null || issue.input === "" ? "Required" : message;
245
+ function stringSchema(max, min, required) {
246
+ let s = z.string({ error: typeError("Enter text") });
247
+ if (max) s = s.max(max, `At most ${max} characters`);
248
+ if (min !== void 0) s = s.min(min, min === 1 ? "Required" : `At least ${min} characters`);
249
+ else if (required) s = s.min(1, "Required");
250
+ return s;
251
+ }
252
+ function fieldSchema(def) {
253
+ switch (def.kind) {
254
+ case "string": {
255
+ let s = z.string({ error: typeError("Enter text") }).trim().max(def.maxLength ?? DEFAULT_STRING_MAX, `At most ${def.maxLength ?? DEFAULT_STRING_MAX} characters`);
256
+ if (def.minLength !== void 0) s = s.min(def.minLength, `At least ${def.minLength} characters`);
257
+ else if (def.required) s = s.min(1, "Required");
258
+ if (def.pattern) s = s.regex(new RegExp(def.pattern), "Invalid format");
259
+ switch (def.format) {
260
+ case "email":
261
+ return s.pipe(z.email({ error: "Enter a valid email address" }));
262
+ case "url":
263
+ return s.pipe(z.url({ protocol: /^https?$/, error: "Enter a valid URL (http or https)" }));
264
+ case "tel":
265
+ return s.refine((value) => value === "" || isPhoneNumber(value), "Enter a valid phone number, including the country");
266
+ case "domain":
267
+ return s.transform(normalizeDomain).refine((value) => value === "" || isDomain(value), "Enter a domain such as example.com");
268
+ case "country":
269
+ return s.transform((value) => value.toUpperCase()).refine((value) => value === "" || isCountryCode(value), "Choose a country");
270
+ case "color":
271
+ return s.transform((value) => value.toLowerCase()).refine((value) => value === "" || isColor(value), "Enter a colour such as #f2541d");
272
+ case "slug":
273
+ return s.refine((value) => value === "" || isSlug(value), "Use lowercase letters, numbers and hyphens, e.g. my-first-post");
274
+ default:
275
+ return s;
276
+ }
277
+ }
278
+ case "text":
279
+ return stringSchema(def.maxLength ?? DEFAULT_TEXT_MAX, def.minLength, def.required);
280
+ case "int":
281
+ case "float": {
282
+ let s = z.number({ error: typeError("Enter a number") });
283
+ if (def.kind === "int") s = s.int("Enter a whole number");
284
+ if (def.min !== void 0) s = s.min(def.min, `Must be at least ${def.min}`);
285
+ if (def.max !== void 0) s = s.max(def.max, `Must be at most ${def.max}`);
286
+ return s;
287
+ }
288
+ case "boolean":
289
+ return z.boolean({ error: typeError("Choose yes or no") });
290
+ case "date":
291
+ return z.iso.date({ error: typeError("Enter a valid date") });
292
+ case "datetime":
293
+ return z.iso.datetime({ offset: true, error: typeError("Enter a valid date and time") });
294
+ case "enum":
295
+ return z.enum(def.options, { error: typeError("Choose one of the options") });
296
+ case "multiselect": {
297
+ let s = z.array(z.enum(def.options, { error: "Choose from the listed options" }), { error: typeError("Choose one or more options") }).refine((values) => new Set(values).size === values.length, "Each option can be picked once");
298
+ const min = def.minItems ?? (def.required ? 1 : 0);
299
+ if (min > 0) s = s.refine((values) => values.length >= min, min === 1 ? "Choose at least one" : `Choose at least ${min}`);
300
+ if (def.maxItems !== void 0) s = s.refine((values) => values.length <= def.maxItems, `Choose at most ${def.maxItems}`);
301
+ return s;
302
+ }
303
+ case "file":
304
+ return z.string({ error: typeError("Upload a file") }).regex(OBJECT_KEY, "Invalid file");
305
+ case "belongsTo":
306
+ return z.string({ error: typeError("Choose a record") }).min(1, "Required");
307
+ }
308
+ }
309
+ var fileKeyPrefix = (resource, fieldKey) => `${resource.table}/${fieldKey}/`;
310
+ function createValidators(resource) {
311
+ const create = {};
312
+ const update = {};
313
+ for (const [key, def] of storedFields(resource)) {
314
+ let base = fieldSchema(def);
315
+ if (def.kind === "file") {
316
+ const prefix = fileKeyPrefix(resource, key);
317
+ base = base.refine(
318
+ (value2) => typeof value2 !== "string" || !OBJECT_KEY.test(value2) || value2.startsWith(prefix),
319
+ "This file wasn't uploaded for this field"
320
+ );
321
+ }
322
+ const value = def.required ? base : base.nullable();
323
+ update[key] = value.optional();
324
+ const hasDefault = "default" in def && def.default !== void 0;
325
+ create[key] = def.required && !hasDefault ? value : value.optional();
326
+ }
327
+ const unknownKeys = { error: (issue) => issue.code === "unrecognized_keys" ? `Unknown field(s): ${issue.keys?.join(", ")}` : void 0 };
328
+ return {
329
+ create: z.strictObject(create, unknownKeys),
330
+ update: z.strictObject(update, unknownKeys)
331
+ };
332
+ }
333
+
334
+ // src/resource/policy.ts
335
+ var ROLE = /^(\*|[a-z][a-z0-9_-]{0,31})$/;
336
+ var PolicyError = class extends Error {
337
+ constructor(message) {
338
+ super(message);
339
+ this.name = "PolicyError";
340
+ }
341
+ };
342
+ function definePolicy(config) {
343
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(config.resource)) {
344
+ throw new PolicyError(`Policy resource "${config.resource}" must be a PascalCase resource name.`);
345
+ }
346
+ const clean = (action) => {
347
+ const roles = config[action] ?? [];
348
+ for (const role of roles) {
349
+ if (!ROLE.test(role)) throw new PolicyError(`Policy ${config.resource}.${action}: invalid role "${role}" (lowercase letters, digits, _ or -, or "*").`);
350
+ }
351
+ return [...new Set(roles)];
352
+ };
353
+ return { resource: config.resource, read: clean("read"), create: clean("create"), update: clean("update"), delete: clean("delete") };
354
+ }
355
+ function can(policy, role, action) {
356
+ if (!policy) return true;
357
+ const allowed = policy[action === "list" ? "read" : action];
358
+ if (allowed.includes("*")) return Boolean(role);
359
+ return Boolean(role) && allowed.includes(role);
360
+ }
361
+ function allowedActions(policy, role) {
362
+ return {
363
+ read: can(policy, role, "read"),
364
+ create: can(policy, role, "create"),
365
+ update: can(policy, role, "update"),
366
+ delete: can(policy, role, "delete")
367
+ };
368
+ }
369
+
370
+ export {
371
+ STRING_FORMATS,
372
+ FILE_CATEGORIES,
373
+ mimeTypesFor,
374
+ DEFAULT_FILE_MAX_BYTES,
375
+ fileMaxBytes,
376
+ field,
377
+ words,
378
+ pascalCase,
379
+ camelCase,
380
+ snakeCase,
381
+ kebabCase,
382
+ humanize,
383
+ pluralize,
384
+ RESERVED_FIELD_NAMES,
385
+ ResourceDefinitionError,
386
+ defaultFieldLabel,
387
+ defineResource,
388
+ storedFields,
389
+ columnName,
390
+ flagEmoji,
391
+ countryName,
392
+ countries,
393
+ isCountryCode,
394
+ isPhoneNumber,
395
+ formatPhone,
396
+ splitPhone,
397
+ toE164,
398
+ normalizeDomain,
399
+ isDomain,
400
+ isColor,
401
+ isSlug,
402
+ slugify,
403
+ fieldSchema,
404
+ fileKeyPrefix,
405
+ createValidators,
406
+ PolicyError,
407
+ definePolicy,
408
+ can,
409
+ allowedActions
410
+ };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './define-D5CtCkOm.js';
2
- export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES, b as DateField, c as DateTimeField, E as EnumField, d as FILE_CATEGORIES, e as FieldKind, f as FieldValue, g as FileCategory, h as FileField, i as FloatField, H as HasManyField, I as IntField, j as RESERVED_FIELD_NAMES, k as RecordOf, l as ResourceConfig, m as ResourceDefinitionError, n as StringField, T as TextField, o as columnName, p as defaultFieldLabel, q as defineResource, r as field, s as fileMaxBytes, t as mimeTypesFor, u as storedFields } from './define-D5CtCkOm.js';
1
+ import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './policy-BqRg9RVM.js';
2
+ export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES, b as DateField, c as DateTimeField, E as EnumField, d as FILE_CATEGORIES, e as FieldKind, f as FieldValue, g as FileCategory, h as FileField, i as FloatField, H as HasManyField, I as IntField, M as MultiSelectField, P as Policy, j as PolicyAction, k as PolicyConfig, l as PolicyError, m as RESERVED_FIELD_NAMES, n as RecordOf, o as ResourceConfig, p as ResourceDefinitionError, q as STRING_FORMATS, r as StringField, s as StringFormat, T as TextField, t as allowedActions, u as can, v as columnName, w as defaultFieldLabel, x as definePolicy, y as defineResource, z as field, A as fileMaxBytes, G as mimeTypesFor, J as storedFields } from './policy-BqRg9RVM.js';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -358,7 +358,8 @@ declare function formatValue(def: StoredField | {
358
358
  }, value: unknown, options?: FormatOptions): string;
359
359
 
360
360
  /**
361
- * Form state for a resource: strings for text-like inputs, booleans for toggles.
361
+ * Form state for a resource: strings for text-like inputs, booleans for toggles, and a
362
+ * JSON array string for multiselects.
362
363
  * These helpers convert between records, form state, and API input so every admin
363
364
  * form handles empty values, numbers, and defaults the same way.
364
365
  */
@@ -371,47 +372,46 @@ declare function initialFormValues(resource: Resource, record?: Record<string, u
371
372
  * empty required fields are left for validation to report as "Required".
372
373
  */
373
374
  declare function formValuesToInput(resource: Resource, values: FormValues, mode: "create" | "edit"): Record<string, unknown>;
375
+ /** A multiselect's form state (a JSON array string) as the picked values. */
376
+ declare function parseMultiValue(raw: string | boolean | undefined): string[];
374
377
  /** Field errors keyed by field name, from validation or API issues (first message wins). */
375
378
  declare function issuesByField(issues: {
376
379
  path: string | PropertyKey[];
377
380
  message: string;
378
381
  }[]): Record<string, string>;
379
382
 
380
- /**
381
- * Resource-level policies: which roles may read, create, update, or delete a resource.
382
- *
383
- * Policies are plain data, like descriptors, so the API layer and the admin UI read the
384
- * same rules at runtime. Field-level rules and per-record ownership are deliberately out
385
- * of scope for v1.
386
- */
387
- type PolicyAction = "list" | "read" | "create" | "update" | "delete";
388
- /** Roles allowed per action; `"*"` means any signed-in user. */
389
- interface Policy {
390
- resource: string;
391
- read: string[];
392
- create: string[];
393
- update: string[];
394
- delete: string[];
395
- }
396
- interface PolicyConfig {
397
- resource: string;
398
- read?: string[];
399
- create?: string[];
400
- update?: string[];
401
- delete?: string[];
402
- }
403
- declare class PolicyError extends Error {
404
- constructor(message: string);
383
+ interface Country {
384
+ /** ISO 3166-1 alpha-2, e.g. "UG". */
385
+ code: string;
386
+ name: string;
387
+ /** International dialling code without "+", e.g. "256". */
388
+ dialCode: string;
389
+ flag: string;
405
390
  }
406
- declare function definePolicy(config: PolicyConfig): Policy;
407
- /**
408
- * Whether `role` may perform `action`. Without a policy any signed-in user is allowed
409
- * (callers must already have required a session); generate one with
410
- * `flare gen policy <Resource> --roles …` to restrict a resource.
411
- */
412
- declare function can(policy: Policy | undefined | null, role: string | null | undefined, action: PolicyAction): boolean;
413
- /** Actions `role` may perform, for hiding UI it can't use. */
414
- declare function allowedActions(policy: Policy | undefined | null, role: string | null | undefined): Record<Exclude<PolicyAction, "list">, boolean>;
391
+ /** 🇺🇬 from "UG": two regional-indicator letters. */
392
+ declare function flagEmoji(code: string): string;
393
+ declare function countryName(code: string, locale?: string): string;
394
+ /** Every country with a dialling code, sorted by name in `locale`. */
395
+ declare function countries(locale?: string): Country[];
396
+ declare function isCountryCode(value: string): boolean;
397
+ /** A valid international number, e.g. "+256772123456". */
398
+ declare function isPhoneNumber(value: string): boolean;
399
+ /** "+256 772 123456" for display; the value itself when it can't be parsed. */
400
+ declare function formatPhone(value: string): string;
401
+ /** The country and national part of a stored number, to fill the admin input. */
402
+ declare function splitPhone(value: string): {
403
+ country: string | undefined;
404
+ national: string;
405
+ };
406
+ /** E.164 from a country and whatever the person typed, e.g. ("UG", "0772 123456") → "+256772123456". */
407
+ declare function toE164(country: string, national: string): string;
408
+ /** "https://Example.com/path" → "example.com": what someone pasted, as a bare hostname. */
409
+ declare function normalizeDomain(value: string): string;
410
+ declare const isDomain: (value: string) => boolean;
411
+ declare const isColor: (value: string) => boolean;
412
+ declare const isSlug: (value: string) => boolean;
413
+ /** "My First Post!" → "my-first-post". */
414
+ declare function slugify(value: string): string;
415
415
 
416
416
  /**
417
417
  * Seed files: `seeds/<name>.seed.ts`, run by `flare seed` against the local D1 database.
@@ -433,6 +433,6 @@ interface SeedContext {
433
433
  type Seed = (context: SeedContext) => void | Promise<void>;
434
434
  declare function defineSeed(seed: Seed): Seed;
435
435
 
436
- declare const FLARE_VERSION = "0.1.2";
436
+ declare const FLARE_VERSION = "0.2.0";
437
437
 
438
- export { type BelongsToRelation, CreateInput, FLARE_VERSION, Field, type FormValues, type FormatOptions, type HasManyRelation, type MailMessage, type MailResult, type Mailer, type MailerOptions, PBKDF2_ITERATIONS, type Policy, type PolicyAction, type PolicyConfig, PolicyError, type ReadUrlOptions, RelationError, type RelationGraph, Resource, type ResourceRelations, type ResourceValidators, SNIFF_BYTES, SUBSCRIPTION_STATUSES, type Seed, type SeedContext, type Storage, type StorageBucket, type StorageOptions, StoredField, type SubscriptionLike, type SubscriptionState, type SubscriptionStatus, type Tone, type TransactionalEmail, UpdateInput, type UploadUrlOptions, allowedActions, camelCase, can, checkoutPaid, createMailer, createObjectKey, createStorage, createValidators, currentPeriodEnd, definePolicy, defineSeed, fieldSchema, fileKeyPrefix, formValuesToInput, formatValue, grantsAccess, hashPassword, humanize, importSigningKey, initialFormValues, isLiveSubscription, issuesByField, kebabCase, matchesContentType, optionLabel, pascalCase, pluralize, randomLetters, relationGraph, renderTransactionalEmail, shouldApplySubscription, signToken, snakeCase, sniffMatches, statusTone, subscriptionPriceId, subscriptionState, toSubscriptionStatus, verifyPassword, verifyToken, words };
438
+ export { type BelongsToRelation, type Country, CreateInput, FLARE_VERSION, Field, type FormValues, type FormatOptions, type HasManyRelation, type MailMessage, type MailResult, type Mailer, type MailerOptions, PBKDF2_ITERATIONS, type ReadUrlOptions, RelationError, type RelationGraph, Resource, type ResourceRelations, type ResourceValidators, SNIFF_BYTES, SUBSCRIPTION_STATUSES, type Seed, type SeedContext, type Storage, type StorageBucket, type StorageOptions, StoredField, type SubscriptionLike, type SubscriptionState, type SubscriptionStatus, type Tone, type TransactionalEmail, UpdateInput, type UploadUrlOptions, camelCase, checkoutPaid, countries, countryName, createMailer, createObjectKey, createStorage, createValidators, currentPeriodEnd, defineSeed, fieldSchema, fileKeyPrefix, flagEmoji, formValuesToInput, formatPhone, formatValue, grantsAccess, hashPassword, humanize, importSigningKey, initialFormValues, isColor, isCountryCode, isDomain, isLiveSubscription, isPhoneNumber, isSlug, issuesByField, kebabCase, matchesContentType, normalizeDomain, optionLabel, parseMultiValue, pascalCase, pluralize, randomLetters, relationGraph, renderTransactionalEmail, shouldApplySubscription, signToken, slugify, snakeCase, sniffMatches, splitPhone, statusTone, subscriptionPriceId, subscriptionState, toE164, toSubscriptionStatus, verifyPassword, verifyToken, words };