@lontar/core 0.1.1 → 0.1.2

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.
@@ -1,5 +1,6 @@
1
1
  import { A as ClientUploadField, C as ClientRichTextField, D as ClientTextField, E as ClientSlugField, O as ClientTextareaField, S as ClientRelationshipField, T as ClientSelectOption, _ as ClientDateField, a as WhereClause, b as ClientGlobal, c as SystemFieldName, d as UploadSystemFieldName, f as UploadSystemFields, g as ClientConfig, h as ClientCollection, i as Where, k as ClientUploadConfig, l as SystemFields, m as ClientCheckboxField, n as FilterOperator, o as WhereField, p as CLIENT_CONFIG_VERSION, r as SortItem, s as SYSTEM_FIELDS, t as FieldFilter, u as UPLOAD_SYSTEM_FIELDS, v as ClientEmailField, w as ClientSelectField, x as ClientNumberField, y as ClientField } from "../query-XQ3OKPOy.js";
2
2
  import { z } from "zod";
3
+ export declare function isRichTextEmpty(val: unknown): boolean;
3
4
  /** Optional fields accept `null` and `undefined`, since their column is nullable. */
4
5
  export declare function clientFieldToZod(field: ClientField): z.ZodType;
5
6
  /**
@@ -1 +1 @@
1
- export { r as CLIENT_CONFIG_VERSION, i as SYSTEM_FIELDS, a as UPLOAD_SYSTEM_FIELDS, t as clientFieldToZod, n as clientFieldsToZod } from "../zod-ClIdYqay.js";
1
+ export { i as CLIENT_CONFIG_VERSION, a as SYSTEM_FIELDS, o as UPLOAD_SYSTEM_FIELDS, t as clientFieldToZod, n as clientFieldsToZod, r as isRichTextEmpty } from "../zod-DShD3pW7.js";
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { a as WhereClause, b as ClientGlobal, c as SystemFieldName, d as UploadSystemFieldName, f as UploadSystemFields, g as ClientConfig, h as ClientCollection, i as Where, l as SystemFields, n as FilterOperator, r as SortItem, s as SYSTEM_FIELDS, t as FieldFilter, u as UPLOAD_SYSTEM_FIELDS } from "./query-XQ3OKPOy.js";
2
2
  import { betterAuth } from "better-auth";
3
3
  import { Context, Hono } from "hono";
4
+ import sharp from "sharp";
4
5
  interface BaseField {
5
6
  /** Shown in the admin. Defaults to the field name, humanized. */
6
7
  label?: string;
@@ -127,7 +128,20 @@ interface ProcessedImageResult {
127
128
  sizes: Record<string, ProcessedImageSize>;
128
129
  }
129
130
  export declare function isResizableImage(mimeType: string): boolean;
131
+ export declare function isAnimatedMimeType(mimeType: string): boolean;
132
+ export declare function getOrientedDimensions(meta: {
133
+ width?: number;
134
+ height?: number;
135
+ orientation?: number;
136
+ pageHeight?: number;
137
+ }): {
138
+ width?: number;
139
+ height?: number;
140
+ };
130
141
  export declare function formatVariantFilename(originalFilename: string, sizeName: string, format?: string): string;
142
+ type SharpModule = typeof sharp;
143
+ export declare function getSharp(): Promise<SharpModule>;
144
+ export declare function setSharp(impl: SharpModule | null): void;
131
145
  export declare function processImage(args: {
132
146
  filename: string;
133
147
  buffer: Buffer;
package/dist/index.js CHANGED
@@ -1,9 +1,8 @@
1
- import { a as UPLOAD_SYSTEM_FIELDS, i as SYSTEM_FIELDS, n as clientFieldsToZod } from "./zod-ClIdYqay.js";
1
+ import { a as SYSTEM_FIELDS, n as clientFieldsToZod, o as UPLOAD_SYSTEM_FIELDS } from "./zod-DShD3pW7.js";
2
2
  import { betterAuth } from "better-auth";
3
3
  import { Hono } from "hono";
4
4
  import { bodyLimit } from "hono/body-limit";
5
5
  import { fileTypeFromBuffer } from "file-type";
6
- import sharp from "sharp";
7
6
  // src/config/collection.ts
8
7
  /**
9
8
  * Returns the config unchanged. It exists to infer `F`, which types InferDoc and
@@ -30,6 +29,12 @@ const RESERVED_SLUGS = /* @__PURE__ */ new Set([
30
29
  ]);
31
30
  const SYSTEM_FIELD_NAMES = new Set(SYSTEM_FIELDS);
32
31
  const UPLOAD_SYSTEM_FIELD_NAMES = new Set(UPLOAD_SYSTEM_FIELDS);
32
+ const TEXT_LIKE_FIELD_TYPES = /* @__PURE__ */ new Set([
33
+ "text",
34
+ "textarea",
35
+ "email",
36
+ "select"
37
+ ]);
33
38
  const MAX_VARCHAR_LENGTH = 16383;
34
39
  var LontarConfigError = class extends Error {
35
40
  issues;
@@ -51,9 +56,9 @@ function defineConfig(config) {
51
56
  function validateConfig(config) {
52
57
  const issues = validateSecret(config.secret);
53
58
  issues.push(...validatePagination("Config", config.pagination));
54
- const collectionsResult = validateCollections(config.collections);
55
- issues.push(...collectionsResult.issues);
56
- issues.push(...validateGlobals(config.globals, collectionsResult.slugs));
59
+ const { issues: collectionIssues, collectionMap } = validateCollections(config.collections);
60
+ issues.push(...collectionIssues);
61
+ issues.push(...validateGlobals(config.globals, collectionMap));
57
62
  issues.push(...validateStorage(config));
58
63
  return issues;
59
64
  }
@@ -75,41 +80,48 @@ function validateCollections(collections) {
75
80
  collectionMap.set(collection.slug, collection);
76
81
  issues.push(...validateCollection(collection));
77
82
  }
78
- for (const collection of collections) issues.push(...validateUploadTargets(collection, collectionMap));
83
+ for (const collection of collections) issues.push(...validateFieldTargets(`Collection "${collection.slug}"`, collection.fields, collectionMap));
79
84
  return {
80
85
  issues,
81
- slugs
86
+ collectionMap
82
87
  };
83
88
  }
84
- function validateGlobals(globals, collectionSlugs) {
89
+ function validateGlobals(globals, collectionMap) {
85
90
  if (!globals) return [];
86
91
  const issues = [];
87
92
  const seen = /* @__PURE__ */ new Set();
88
93
  for (const global of globals) {
89
94
  if (seen.has(global.slug)) issues.push(`Global slug "${global.slug}" is used twice.`);
90
- if (collectionSlugs.has(global.slug)) issues.push(`Global slug "${global.slug}" collides with a collection slug.`);
95
+ if (collectionMap.has(global.slug)) issues.push(`Global slug "${global.slug}" collides with a collection slug.`);
91
96
  seen.add(global.slug);
92
97
  issues.push(...validateGlobal(global));
98
+ issues.push(...validateFieldTargets(`Global "${global.slug}"`, global.fields, collectionMap));
93
99
  }
94
100
  return issues;
95
101
  }
96
- function validateUploadTargets(collection, collectionMap) {
102
+ function validateFieldTargets(where, fields, collectionMap) {
97
103
  const issues = [];
98
- for (const [name, field] of Object.entries(collection.fields)) {
99
- if (field.type !== "upload") continue;
100
- const target = collectionMap.get(field.relationTo);
101
- if (!target) issues.push(`Collection "${collection.slug}": upload field "${name}" refers to unknown collection "${field.relationTo}".`);
102
- else if (!target.upload) issues.push(`Collection "${collection.slug}": upload field "${name}" refers to collection "${field.relationTo}" which does not have upload enabled.`);
103
- }
104
+ for (const [name, field] of Object.entries(fields)) if (field.type === "upload") issues.push(...validateUploadTarget(where, name, field, collectionMap));
105
+ else if (field.type === "relationship") issues.push(...validateRelationshipTarget(where, name, field, collectionMap));
104
106
  return issues;
105
107
  }
108
+ function validateUploadTarget(where, name, field, collectionMap) {
109
+ const target = collectionMap.get(field.relationTo);
110
+ if (!target) return [`${where}: upload field "${name}" refers to unknown collection "${field.relationTo}".`];
111
+ if (!target.upload) return [`${where}: upload field "${name}" refers to collection "${field.relationTo}" which does not have upload enabled.`];
112
+ return [];
113
+ }
114
+ function validateRelationshipTarget(where, name, field, collectionMap) {
115
+ if (!collectionMap.has(field.relationTo)) return [`${where}: relationship field "${name}" refers to unknown collection "${field.relationTo}".`];
116
+ return [];
117
+ }
106
118
  function validateCollection(collection) {
107
119
  const where = `Collection "${collection.slug}"`;
108
120
  const isUpload = Boolean(collection.upload);
109
121
  return [
110
122
  ...validateSlug(collection.slug, "Collection"),
111
123
  ...Object.keys(collection.fields).flatMap((name) => validateFieldName(where, name, isUpload)),
112
- ...Object.entries(collection.fields).flatMap(([name, field]) => validateField(where, name, field)),
124
+ ...Object.entries(collection.fields).flatMap(([name, field]) => validateField(where, name, field, collection.fields)),
113
125
  ...validatePagination(where, collection.pagination),
114
126
  ...validateAdmin(where, collection),
115
127
  ...validateAccess(where, collection)
@@ -132,13 +144,22 @@ function validateGlobal(global) {
132
144
  return [
133
145
  ...validateSlug(global.slug, "Global"),
134
146
  ...Object.keys(global.fields).flatMap((name) => validateFieldName(where, name)),
135
- ...Object.entries(global.fields).flatMap(([name, field]) => validateField(where, name, field)),
147
+ ...Object.entries(global.fields).flatMap(([name, field]) => validateField(where, name, field, global.fields)),
136
148
  ...validateGlobalAccess(where, global)
137
149
  ];
138
150
  }
139
- function validateField(where, name, field) {
151
+ function validateField(where, name, field, fields) {
140
152
  if (field.type === "text") return validateTextField(where, name, field);
141
153
  if (field.type === "textarea") return validateTextareaField(where, name, field);
154
+ if (field.type === "slug") return validateSlugField(where, name, field, fields);
155
+ return [];
156
+ }
157
+ function validateSlugField(where, name, field, fields) {
158
+ if (field.from === void 0) return [];
159
+ if (field.from === name) return [`${where}: slug field "${name}" cannot refer to itself in \`from\`.`];
160
+ const sourceField = fields[field.from];
161
+ if (!sourceField) return [`${where}: slug field "${name}" \`from\` refers to unknown field "${field.from}".`];
162
+ if (!TEXT_LIKE_FIELD_TYPES.has(sourceField.type)) return [`${where}: slug field "${name}" \`from\` must refer to a text-like field, but "${field.from}" is of type "${sourceField.type}".`];
142
163
  return [];
143
164
  }
144
165
  function validateTextField(where, name, field) {
@@ -349,10 +370,18 @@ function clientLabels({ slug, labels = {} }) {
349
370
  plural
350
371
  };
351
372
  }
373
+ function isTextLike(type) {
374
+ return type === "text" || type === "email" || type === "slug";
375
+ }
376
+ function resolveDefaultTitle(fields, upload) {
377
+ const textLikeField = fields.find((field) => isTextLike(field.type));
378
+ if (textLikeField) return textLikeField.name;
379
+ if (upload) return "filename";
380
+ return "id";
381
+ }
352
382
  function clientAdmin(collection, fields) {
353
383
  const { admin = {}, upload } = collection;
354
- const defaultTitle = upload ? "filename" : "id";
355
- const useAsTitle = admin.useAsTitle ?? defaultTitle;
384
+ const useAsTitle = admin.useAsTitle ?? resolveDefaultTitle(fields, Boolean(upload));
356
385
  const others = fields.map((field) => field.name).filter((name) => name !== useAsTitle);
357
386
  return {
358
387
  useAsTitle,
@@ -900,6 +929,22 @@ const FORMAT_TO_MIME = {
900
929
  function isResizableImage(mimeType) {
901
930
  return RESIZABLE_MIME_TYPES.has(mimeType.toLowerCase());
902
931
  }
932
+ function isAnimatedMimeType(mimeType) {
933
+ const lower = mimeType.toLowerCase();
934
+ return lower === "image/gif" || lower === "image/webp";
935
+ }
936
+ function getOrientedDimensions(meta) {
937
+ if (meta.width === void 0 || meta.height === void 0) return {
938
+ width: meta.width,
939
+ height: meta.height
940
+ };
941
+ const isTransposed = typeof meta.orientation === "number" && meta.orientation >= 5;
942
+ const rawHeight = meta.pageHeight ?? meta.height;
943
+ return {
944
+ width: isTransposed ? rawHeight : meta.width,
945
+ height: isTransposed ? meta.width : rawHeight
946
+ };
947
+ }
903
948
  function formatVariantFilename(originalFilename, sizeName, format) {
904
949
  const lastDot = originalFilename.lastIndexOf(".");
905
950
  const base = lastDot > 0 ? originalFilename.slice(0, lastDot) : originalFilename;
@@ -910,9 +955,25 @@ function resolveMimeType(originalMime, format) {
910
955
  if (format && FORMAT_TO_MIME[format]) return FORMAT_TO_MIME[format];
911
956
  return originalMime;
912
957
  }
913
- async function resizeVariant(buffer, name, config, originalFilename, originalMimeType) {
958
+ let cachedSharp = null;
959
+ async function getSharp() {
960
+ if (cachedSharp) return cachedSharp;
914
961
  try {
915
- let pipeline = sharp(buffer).rotate();
962
+ const mod = await import("sharp");
963
+ cachedSharp = mod.default ?? mod;
964
+ return cachedSharp;
965
+ } catch (cause) {
966
+ throw new Error("The \"sharp\" image processing library is required to process images, but failed to load.", { cause });
967
+ }
968
+ }
969
+ function setSharp(impl) {
970
+ cachedSharp = impl;
971
+ }
972
+ async function resizeVariant(sharp, buffer, name, config, originalFilename, originalMimeType) {
973
+ try {
974
+ const targetMimeType = resolveMimeType(originalMimeType, config.format);
975
+ const isAnimated = isAnimatedMimeType(originalMimeType) && isAnimatedMimeType(targetMimeType);
976
+ let pipeline = sharp(buffer, isAnimated ? { animated: true } : void 0).rotate();
916
977
  pipeline = pipeline.resize({
917
978
  width: config.width,
918
979
  height: config.height,
@@ -921,16 +982,17 @@ async function resizeVariant(buffer, name, config, originalFilename, originalMim
921
982
  });
922
983
  if (config.format) pipeline = pipeline.toFormat(config.format, { quality: config.quality });
923
984
  const resizedBuffer = await pipeline.toBuffer();
924
- const meta = await sharp(resizedBuffer).metadata();
925
- const mimeType = resolveMimeType(originalMimeType, config.format);
985
+ const meta = await sharp(resizedBuffer, isAnimated ? { animated: true } : void 0).metadata();
986
+ const filename = formatVariantFilename(originalFilename, name, config.format);
987
+ const { width, height } = getOrientedDimensions(meta);
926
988
  return {
927
989
  name,
928
- filename: formatVariantFilename(originalFilename, name, config.format),
990
+ filename,
929
991
  buffer: resizedBuffer,
930
- width: meta.width,
931
- height: meta.height,
992
+ width,
993
+ height,
932
994
  filesize: resizedBuffer.length,
933
- mimeType
995
+ mimeType: targetMimeType
934
996
  };
935
997
  } catch (err) {
936
998
  if (err instanceof InvalidImageError) throw err;
@@ -948,17 +1010,19 @@ async function processImage(args) {
948
1010
  },
949
1011
  sizes: {}
950
1012
  };
1013
+ const sharp = await getSharp();
951
1014
  let meta;
952
1015
  try {
953
- meta = await sharp(buffer).metadata();
1016
+ meta = await sharp(buffer, { animated: isAnimatedMimeType(mimeType) }).metadata();
954
1017
  } catch {
955
1018
  throw new InvalidImageError("Invalid image");
956
1019
  }
1020
+ const { width, height } = getOrientedDimensions(meta);
957
1021
  const original = {
958
1022
  filename,
959
1023
  buffer,
960
- width: meta.width,
961
- height: meta.height,
1024
+ width,
1025
+ height,
962
1026
  filesize: buffer.length,
963
1027
  mimeType
964
1028
  };
@@ -967,7 +1031,7 @@ async function processImage(args) {
967
1031
  sizes: {}
968
1032
  };
969
1033
  const processedSizes = {};
970
- for (const [name, config] of Object.entries(sizes)) processedSizes[name] = await resizeVariant(buffer, name, config, filename, mimeType);
1034
+ for (const [name, config] of Object.entries(sizes)) processedSizes[name] = await resizeVariant(sharp, buffer, name, config, filename, mimeType);
971
1035
  return {
972
1036
  original,
973
1037
  sizes: processedSizes
@@ -2622,4 +2686,4 @@ function getLontar(config) {
2622
2686
  };
2623
2687
  return Promise.resolve(client);
2624
2688
  }
2625
- export { AccessDeniedError, DEFAULT_LIMIT, DEFAULT_MAX_FILE_SIZE, DatabaseError, InvalidImageError, LontarConfigError, MAX_LIMIT, NotFoundError, SYSTEM_FIELDS, StorageUploadError, UPLOAD_SYSTEM_FIELDS, UniqueConstraintError, ValidationError, accessDenied, checkAccess, collection, countTotalUsers, createLontar, createOperation, createUserWithCredential, defaultAccess, defineConfig, deleteMedia, deleteOperation, fields, findByIDOperation, findGlobalOperation, findOperation, formatUniqueConstraintError, formatVariantFilename, getLontar, global, isResizableImage, parseDepth, parseSort, parseWhere, parseWhereFromSearchParams, populateDoc, populateDocs, processImage, runAfterChangeHooks, runAfterReadHooks, runBeforeChangeHooks, toClientCollection, toClientConfig, toClientGlobal, updateGlobalOperation, updateOperation, uploadMedia, validateWhere };
2689
+ export { AccessDeniedError, DEFAULT_LIMIT, DEFAULT_MAX_FILE_SIZE, DatabaseError, InvalidImageError, LontarConfigError, MAX_LIMIT, NotFoundError, SYSTEM_FIELDS, StorageUploadError, UPLOAD_SYSTEM_FIELDS, UniqueConstraintError, ValidationError, accessDenied, checkAccess, collection, countTotalUsers, createLontar, createOperation, createUserWithCredential, defaultAccess, defineConfig, deleteMedia, deleteOperation, fields, findByIDOperation, findGlobalOperation, findOperation, formatUniqueConstraintError, formatVariantFilename, getLontar, getOrientedDimensions, getSharp, global, isAnimatedMimeType, isResizableImage, parseDepth, parseSort, parseWhere, parseWhereFromSearchParams, populateDoc, populateDocs, processImage, runAfterChangeHooks, runAfterReadHooks, runBeforeChangeHooks, setSharp, toClientCollection, toClientConfig, toClientGlobal, updateGlobalOperation, updateOperation, uploadMedia, validateWhere };
@@ -24,13 +24,26 @@ const CLIENT_CONFIG_VERSION = 1;
24
24
  // src/client/zod.ts
25
25
  const MAX_INT_32 = 2147483647;
26
26
  const DEFAULT_STRING_MAX_LENGTH = 255;
27
+ function isRichTextEmpty(val) {
28
+ if (!val || typeof val !== "object") return true;
29
+ const doc = val;
30
+ if (typeof doc.text === "string") return doc.text.trim().length === 0;
31
+ if (Array.isArray(doc.content)) {
32
+ if (doc.content.length === 0) return true;
33
+ return doc.content.every((child) => isRichTextEmpty(child));
34
+ }
35
+ if (doc.type && doc.type !== "doc" && doc.type !== "paragraph") return false;
36
+ return true;
37
+ }
27
38
  const zodByType = {
28
39
  text: (field) => {
29
- const minLength = Math.max(field.minLength ?? 0, field.required ? 1 : 0);
30
40
  const maxLength = field.maxLength ?? DEFAULT_STRING_MAX_LENGTH;
31
- let schema = z.string();
32
- if (minLength > 0) schema = schema.min(minLength);
33
- schema = schema.max(maxLength);
41
+ if (field.required) {
42
+ const minLength = Math.max(field.minLength ?? 1, 1);
43
+ return z.string().min(minLength).max(maxLength);
44
+ }
45
+ const schema = z.string().max(maxLength);
46
+ if (field.minLength !== void 0 && field.minLength > 0) return schema.min(field.minLength).or(z.literal(""));
34
47
  return schema;
35
48
  },
36
49
  number: (field) => {
@@ -41,10 +54,15 @@ const zodByType = {
41
54
  return schema;
42
55
  },
43
56
  textarea: (field) => {
44
- const minLength = Math.max(field.minLength ?? 0, field.required ? 1 : 0);
57
+ if (field.required) {
58
+ const minLength = Math.max(field.minLength ?? 1, 1);
59
+ let schema = z.string().min(minLength);
60
+ if (field.maxLength !== void 0) schema = schema.max(field.maxLength);
61
+ return schema;
62
+ }
45
63
  let schema = z.string();
46
- if (minLength > 0) schema = schema.min(minLength);
47
64
  if (field.maxLength !== void 0) schema = schema.max(field.maxLength);
65
+ if (field.minLength !== void 0 && field.minLength > 0) return schema.min(field.minLength).or(z.literal(""));
48
66
  return schema;
49
67
  },
50
68
  email: (field) => {
@@ -75,7 +93,7 @@ const zodByType = {
75
93
  },
76
94
  richText: (field) => {
77
95
  const base = z.record(z.string(), z.unknown());
78
- if (field.required) return base.refine((val) => val && Object.keys(val).length > 0, { message: "Rich text content is required" });
96
+ if (field.required) return base.refine((val) => !isRichTextEmpty(val), { message: "Rich text content is required" });
79
97
  return base;
80
98
  },
81
99
  relationship: (field) => {
@@ -103,4 +121,4 @@ function clientFieldToZod(field) {
103
121
  function clientFieldsToZod(fields) {
104
122
  return z.object(Object.fromEntries(fields.map((field) => [field.name, clientFieldToZod(field)])));
105
123
  }
106
- export { UPLOAD_SYSTEM_FIELDS as a, SYSTEM_FIELDS as i, clientFieldsToZod as n, CLIENT_CONFIG_VERSION as r, clientFieldToZod as t };
124
+ export { SYSTEM_FIELDS as a, CLIENT_CONFIG_VERSION as i, clientFieldsToZod as n, UPLOAD_SYSTEM_FIELDS as o, isRichTextEmpty as r, clientFieldToZod as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lontar/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Lontar core: config, fields, validation, hooks, access control, REST API (Hono) and Local API.",
5
5
  "keywords": [
6
6
  "lontar",