includio-cms 0.13.1 → 0.13.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/ROADMAP.md +10 -0
  3. package/dist/admin/api/handler.js +2 -0
  4. package/dist/admin/api/replace.js +2 -1
  5. package/dist/admin/api/rest/routes/upload.js +2 -1
  6. package/dist/admin/api/upload-limit.d.ts +2 -0
  7. package/dist/admin/api/upload-limit.js +7 -0
  8. package/dist/admin/api/upload.js +2 -1
  9. package/dist/admin/client/collection/collection-entries.svelte +41 -3
  10. package/dist/admin/components/media/file-upload.svelte +66 -9
  11. package/dist/admin/remote/entry.remote.d.ts +1 -0
  12. package/dist/admin/remote/entry.remote.js +5 -3
  13. package/dist/admin/remote/reorder.d.ts +1 -0
  14. package/dist/admin/remote/reorder.js +33 -0
  15. package/dist/core/server/entries/operations/get.js +15 -3
  16. package/dist/core/server/fields/utils/imageStyles.js +7 -3
  17. package/dist/core/server/generator/generator.js +3 -1
  18. package/dist/core/server/media/styles/operations/createMediaStyle.d.ts +2 -2
  19. package/dist/core/server/media/styles/operations/createMediaStyle.js +5 -3
  20. package/dist/core/server/media/styles/operations/generateDefaultStyles.js +33 -6
  21. package/dist/core/server/media/styles/operations/getImageStyle.d.ts +1 -0
  22. package/dist/core/server/media/styles/operations/getImageStyle.js +3 -0
  23. package/dist/core/server/media/styles/sharp/generateImageStyle.d.ts +2 -1
  24. package/dist/core/server/media/styles/sharp/generateImageStyle.js +5 -3
  25. package/dist/core/server/media/uploadLimit.d.ts +2 -0
  26. package/dist/core/server/media/uploadLimit.js +26 -0
  27. package/dist/types/entries.d.ts +1 -0
  28. package/dist/updates/0.13.2/index.d.ts +2 -0
  29. package/dist/updates/0.13.2/index.js +20 -0
  30. package/dist/updates/index.js +2 -1
  31. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.13.2 — 2026-03-20
7
+
8
+ Upload limits, stable reordering, relation filters, image style optimization
9
+
10
+ ### Added
11
+ - Configurable upload size limit via BODY_SIZE_LIMIT env var (supports K/M/G suffixes, default 50M)
12
+ - File upload pre-validation — rejects oversized files before upload with localized error messages
13
+ - Stable slot reordering — preserves positions of filtered-out entries during DnD reorder
14
+ - Collection relation field filtering — filter entries by relation field values
15
+ - Expose _sortOrder on entries for orderable collections
16
+ - Codegen: _sortOrder field in generated TypeScript interfaces for orderable collections
17
+ - getImageStyleIfExists — non-blocking image style lookup (returns null if missing)
18
+
19
+ ### Fixed
20
+ - Concurrent image style generation with buffer reuse — download original once, generate styles in parallel (limit 3)
21
+ - Image style resolution gracefully skips missing styles instead of blocking
22
+ - Upload endpoints use configurable size limit instead of hardcoded 50MB
23
+
6
24
  ## 0.13.1 — 2026-03-20
7
25
 
8
26
  Admin UI i18n, codegen cleanup, docs rewrite
package/ROADMAP.md CHANGED
@@ -219,6 +219,16 @@
219
219
  - [x] `[fix]` `[P2]` Layout type: remove unused label property <!-- files: src/lib/types/layout.ts -->
220
220
  - [x] `[chore]` `[P1]` Documentation rewrite — new pages for blocks, content, media-field, layout; expanded API, auth, entries, forms, plugins docs
221
221
 
222
+ ## 0.13.2 — Upload limits, stable reordering, image style optimization
223
+
224
+ - [x] `[feature]` `[P1]` Configurable upload size limit — `BODY_SIZE_LIMIT` env var, pre-validation in UI <!-- files: src/lib/core/server/media/uploadLimit.ts, src/lib/admin/api/upload-limit.ts, src/lib/admin/components/media/file-upload.svelte -->
225
+ - [x] `[feature]` `[P1]` Stable slot reordering — preserve filtered-out entry positions during DnD <!-- files: src/lib/admin/remote/reorder.ts, src/lib/admin/remote/entry.remote.ts -->
226
+ - [x] `[feature]` `[P1]` Collection relation field filtering — filter entries by relation values <!-- files: src/lib/admin/client/collection/collection-entries.svelte -->
227
+ - [x] `[feature]` `[P2]` Expose `_sortOrder` on entries + codegen for orderable collections <!-- files: src/lib/core/server/entries/operations/get.ts, src/lib/core/server/generator/generator.ts, src/lib/types/entries.ts -->
228
+ - [x] `[feature]` `[P2]` `getImageStyleIfExists` — non-blocking image style lookup <!-- files: src/lib/core/server/media/styles/operations/getImageStyle.ts -->
229
+ - [x] `[fix]` `[P1]` Concurrent image style generation with buffer reuse (download once, parallel limit 3) <!-- files: src/lib/core/server/media/styles/operations/generateDefaultStyles.ts, src/lib/core/server/media/styles/sharp/generateImageStyle.ts -->
230
+ - [x] `[fix]` `[P1]` Image style resolution gracefully skips missing styles <!-- files: src/lib/core/server/fields/utils/imageStyles.ts -->
231
+
222
232
  ## 0.14.0 — SEO module
223
233
 
224
234
  - [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
@@ -6,6 +6,7 @@ import * as inviteHandlers from './invite.js';
6
6
  import * as acceptInviteHandlers from './accept-invite.js';
7
7
  import * as mediaGcHandlers from './media-gc.js';
8
8
  import * as generateStylesHandlers from './generate-styles.js';
9
+ import * as uploadLimitHandlers from './upload-limit.js';
9
10
  import { requireAuth } from '../remote/middleware/auth.js';
10
11
  import { getCMS } from '../../core/cms.js';
11
12
  import { lookup } from 'mrmime';
@@ -18,6 +19,7 @@ export function createAdminApiHandler(options) {
18
19
  'accept-invite': acceptInviteHandlers,
19
20
  'media-gc': mediaGcHandlers,
20
21
  'generate-styles': generateStylesHandlers,
22
+ 'upload-limit': uploadLimitHandlers,
21
23
  ...options?.extraRoutes
22
24
  };
23
25
  const privateMediaGet = async (event) => {
@@ -1,7 +1,8 @@
1
1
  import { requireAuth } from '../remote/middleware/auth.js';
2
2
  import { replaceFile } from '../../core/server/media/operations/replaceFile.js';
3
3
  import { json } from '@sveltejs/kit';
4
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
4
+ import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
5
+ const MAX_UPLOAD_SIZE = getMaxUploadSize();
5
6
  export const POST = async ({ request }) => {
6
7
  requireAuth();
7
8
  const form = await request.formData();
@@ -1,7 +1,8 @@
1
1
  import { json } from '@sveltejs/kit';
2
2
  import { uploadFile } from '../../../../core/server/media/operations/uploadFile.js';
3
3
  import { getCMS } from '../../../../core/cms.js';
4
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
4
+ import { getMaxUploadSize } from '../../../../core/server/media/uploadLimit.js';
5
+ const MAX_UPLOAD_SIZE = getMaxUploadSize();
5
6
  export async function POST(event) {
6
7
  const form = await event.request.formData();
7
8
  const file = form.get('file');
@@ -0,0 +1,2 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare const GET: RequestHandler;
@@ -0,0 +1,7 @@
1
+ import { requireAuth } from '../remote/middleware/auth.js';
2
+ import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
3
+ import { json } from '@sveltejs/kit';
4
+ export const GET = async () => {
5
+ requireAuth();
6
+ return json({ maxUploadSize: getMaxUploadSize() });
7
+ };
@@ -2,7 +2,8 @@ import { requireAuth } from '../remote/middleware/auth.js';
2
2
  import { uploadFile } from '../../core/server/media/operations/uploadFile.js';
3
3
  import { getCMS } from '../../core/cms.js';
4
4
  import { json } from '@sveltejs/kit';
5
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
5
+ import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
6
+ const MAX_UPLOAD_SIZE = getMaxUploadSize();
6
7
  export const POST = async ({ request }) => {
7
8
  requireAuth();
8
9
  const form = await request.formData();
@@ -144,6 +144,21 @@
144
144
  );
145
145
 
146
146
 
147
+ // Relation filter options fetched from related collections
148
+ let relationFilterOptions = $state<Record<string, { label: string; value: string }[]>>({});
149
+
150
+ $effect(() => {
151
+ if (relationListFields.length === 0) return;
152
+ for (const field of relationListFields) {
153
+ remotes.getEntryLabels({ slug: field.collection }).then((labels) => {
154
+ relationFilterOptions = {
155
+ ...relationFilterOptions,
156
+ [field.slug]: labels.map((l) => ({ value: l.id, label: l.label }))
157
+ };
158
+ });
159
+ }
160
+ });
161
+
147
162
  // Data filter configs from select/radio fields in listColumns
148
163
  const dataFilterConfigs = $derived(
149
164
  (collection.listColumns ?? [])
@@ -163,6 +178,15 @@
163
178
  return null;
164
179
  })
165
180
  .filter((f): f is NonNullable<typeof f> => f !== null)
181
+ .concat(
182
+ relationListFields
183
+ .filter((f) => relationFilterOptions[f.slug]?.length)
184
+ .map((f) => ({
185
+ slug: f.slug,
186
+ label: getLocalizedLabel(f.label, interfaceLanguage.current),
187
+ options: relationFilterOptions[f.slug]
188
+ }))
189
+ )
166
190
  );
167
191
 
168
192
  let activeDataFilters = $state<Record<string, string | null>>({});
@@ -485,6 +509,8 @@
485
509
  const fieldData = (columnData as Record<string, unknown>)[fieldSlug];
486
510
  const isRelation = relationListFields.some((f) => f.slug === fieldSlug);
487
511
  if (isRelation) {
512
+ // Store raw UUID(s) for filtering
513
+ customData[`__raw_${fieldSlug}`] = fieldData;
488
514
  // Resolve relation UUID(s) to labels
489
515
  if (typeof fieldData === 'string') {
490
516
  customData[fieldSlug] = lookup[fieldData] ?? '';
@@ -599,7 +625,7 @@
599
625
  ...old,
600
626
  entries: orderedIds.map((id) => old.entries.find((e: RawEntry) => e.id === id)).filter(Boolean)
601
627
  }));
602
- await remotes.reorderEntriesCommand({ orderedIds });
628
+ await remotes.reorderEntriesCommand({ orderedIds, collectionSlug: collection.slug });
603
629
  await entriesQuery.refresh();
604
630
  override.release();
605
631
  }
@@ -640,8 +666,20 @@
640
666
  rows = rows.filter((row) => {
641
667
  for (const [slug, value] of Object.entries(activeDataFilters)) {
642
668
  if (value === null) continue;
643
- const rowValue = row.customData[slug];
644
- if (String(rowValue ?? '') !== value) return false;
669
+ // For relation fields, compare against raw UUID(s)
670
+ const rawValue = row.customData[`__raw_${slug}`];
671
+ if (rawValue !== undefined) {
672
+ if (typeof rawValue === 'string') {
673
+ if (rawValue !== value) return false;
674
+ } else if (Array.isArray(rawValue)) {
675
+ if (!rawValue.includes(value)) return false;
676
+ } else {
677
+ return false;
678
+ }
679
+ } else {
680
+ const rowValue = row.customData[slug];
681
+ if (String(rowValue ?? '') !== value) return false;
682
+ }
645
683
  }
646
684
  return true;
647
685
  });
@@ -2,6 +2,7 @@
2
2
  import { getRemotes } from '../../context/remotes.js';
3
3
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
4
4
  import type { InterfaceLanguage } from '../../../types/languages.js';
5
+ import { toast } from 'svelte-sonner';
5
6
  import Upload from '@tabler/icons-svelte/icons/upload';
6
7
  import X from '@tabler/icons-svelte/icons/x';
7
8
 
@@ -9,21 +10,36 @@
9
10
  const interfaceLanguage = useInterfaceLanguage();
10
11
  const lang: Record<
11
12
  InterfaceLanguage,
12
- { addFiles: string; dropFiles: string; dropHint: string; uploading: string; uploadComplete: string }
13
+ {
14
+ addFiles: string;
15
+ dropFiles: string;
16
+ dropHint: string;
17
+ uploading: string;
18
+ uploadComplete: string;
19
+ fileTooLarge: (maxMB: number) => string;
20
+ uploadError: string;
21
+ skippedFiles: (count: number) => string;
22
+ }
13
23
  > = {
14
24
  pl: {
15
25
  addFiles: 'Prześlij pliki',
16
26
  dropFiles: 'Upuść pliki tutaj',
17
27
  dropHint: 'Przeciągnij pliki tutaj',
18
28
  uploading: 'Wysyłanie...',
19
- uploadComplete: 'Ukończono'
29
+ uploadComplete: 'Ukończono',
30
+ fileTooLarge: (maxMB) => `Plik za duży (max ${maxMB} MB)`,
31
+ uploadError: 'Błąd przesyłania',
32
+ skippedFiles: (count) => `Pominięto ${count} plik(ów) przekraczających limit`
20
33
  },
21
34
  en: {
22
35
  addFiles: 'Upload files',
23
36
  dropFiles: 'Drop files here',
24
37
  dropHint: 'Drag files here',
25
38
  uploading: 'Uploading...',
26
- uploadComplete: 'Complete'
39
+ uploadComplete: 'Complete',
40
+ fileTooLarge: (maxMB) => `File too large (max ${maxMB} MB)`,
41
+ uploadError: 'Upload error',
42
+ skippedFiles: (count) => `Skipped ${count} file(s) exceeding the limit`
27
43
  }
28
44
  };
29
45
 
@@ -39,7 +55,17 @@
39
55
 
40
56
  let inputElement: HTMLInputElement;
41
57
  let isDragging = $state(false);
42
- let uploadProgress = $state<{ name: string; progress: number; complete: boolean }[]>([]);
58
+ let uploadProgress = $state<{ name: string; progress: number; complete: boolean; error?: string }[]>([]);
59
+ let maxUploadSize = $state<number>(50 * 1024 * 1024);
60
+
61
+ $effect(() => {
62
+ fetch('/admin/api/upload-limit')
63
+ .then((r) => r.json())
64
+ .then((data) => {
65
+ if (data.maxUploadSize) maxUploadSize = data.maxUploadSize;
66
+ })
67
+ .catch(() => {});
68
+ });
43
69
 
44
70
  async function uploadFile(file: File, index: number) {
45
71
  const form = new FormData();
@@ -58,13 +84,26 @@
58
84
  };
59
85
 
60
86
  xhr.onload = () => {
61
- uploadProgress[index].complete = true;
62
- uploadProgress[index].progress = 100;
87
+ if (xhr.status >= 200 && xhr.status < 300) {
88
+ uploadProgress[index].complete = true;
89
+ uploadProgress[index].progress = 100;
90
+ } else {
91
+ const t = lang[interfaceLanguage.current];
92
+ const error =
93
+ xhr.status === 413
94
+ ? t.fileTooLarge(Math.round(maxUploadSize / 1024 / 1024))
95
+ : t.uploadError;
96
+ uploadProgress[index].progress = -1;
97
+ uploadProgress[index].error = error;
98
+ toast.error(error, { description: file.name });
99
+ }
63
100
  resolve();
64
101
  };
65
102
 
66
103
  xhr.onerror = () => {
67
104
  uploadProgress[index].progress = -1;
105
+ uploadProgress[index].error = lang[interfaceLanguage.current].uploadError;
106
+ toast.error(lang[interfaceLanguage.current].uploadError, { description: file.name });
68
107
  resolve();
69
108
  };
70
109
 
@@ -76,9 +115,27 @@
76
115
  async function handleFiles(files: File[]) {
77
116
  if (!files.length) return;
78
117
 
79
- uploadProgress = files.map((f) => ({ name: f.name, progress: 0, complete: false }));
118
+ const maxMB = Math.round(maxUploadSize / 1024 / 1024);
119
+ uploadProgress = files.map((f) => ({
120
+ name: f.name,
121
+ progress: f.size > maxUploadSize ? -1 : 0,
122
+ complete: false,
123
+ error: f.size > maxUploadSize ? lang[interfaceLanguage.current].fileTooLarge(maxMB) : undefined
124
+ }));
125
+
126
+ const validFiles = files
127
+ .map((file, i) => ({ file, i }))
128
+ .filter(({ file }) => file.size <= maxUploadSize);
129
+
130
+ const rejectedCount = files.length - validFiles.length;
131
+ if (rejectedCount > 0) {
132
+ const t = lang[interfaceLanguage.current];
133
+ toast.error(t.fileTooLarge(maxMB), {
134
+ description: t.skippedFiles(rejectedCount)
135
+ });
136
+ }
80
137
 
81
- await Promise.all(files.map((file, i) => uploadFile(file, i)));
138
+ await Promise.all(validFiles.map(({ file, i }) => uploadFile(file, i)));
82
139
 
83
140
  remotes.getMedia().refresh();
84
141
 
@@ -210,7 +267,7 @@
210
267
  {#if item.complete}
211
268
  {lang[interfaceLanguage.current].uploadComplete}
212
269
  {:else if item.progress === -1}
213
- Error
270
+ {item.error || lang[interfaceLanguage.current].uploadError}
214
271
  {:else}
215
272
  {item.progress}%
216
273
  {/if}
@@ -73,6 +73,7 @@ export declare const unarchiveEntryCommand: import("@sveltejs/kit").RemoteComman
73
73
  export declare const deleteEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
74
74
  export declare const reorderEntriesCommand: import("@sveltejs/kit").RemoteCommand<{
75
75
  orderedIds: string[];
76
+ collectionSlug: string;
76
77
  }, Promise<void>>;
77
78
  export declare const getEntryVersion: import("@sveltejs/kit").RemoteQueryFunction<{
78
79
  id: string;
@@ -5,6 +5,7 @@ import { getCMS } from '../../core/cms.js';
5
5
  import { pruneOldDraftVersions, unpublishEntryLang, upsertDraftVersion, updateEntry, updateEntrySchema, updateEntryVersionCommandTypes } from '../../core/server/entries/operations/update.js';
6
6
  import z from 'zod';
7
7
  import { requireAuth } from './middleware/auth.js';
8
+ import { stableSlotReorder } from './reorder.js';
8
9
  import { entryStatuses } from '../../types/entries.js';
9
10
  export const getRawEntries = query(z.object({
10
11
  ids: z.array(z.string().uuid()).optional(),
@@ -179,10 +180,11 @@ export const deleteEntryCommand = command(z.string().uuid(), async (id) => {
179
180
  return getCMS().databaseAdapter.deleteEntry({ id });
180
181
  });
181
182
  export const reorderEntriesCommand = command(z.object({
182
- orderedIds: z.array(z.string().uuid())
183
- }), async ({ orderedIds }) => {
183
+ orderedIds: z.array(z.string().uuid()),
184
+ collectionSlug: z.string()
185
+ }), async ({ orderedIds, collectionSlug }) => {
184
186
  requireAuth();
185
- await Promise.all(orderedIds.map((id, index) => updateEntry(id, { sortOrder: index })));
187
+ await stableSlotReorder(orderedIds, collectionSlug);
186
188
  });
187
189
  export const getEntryVersion = query(z.object({
188
190
  id: z.string().uuid(),
@@ -0,0 +1 @@
1
+ export declare function stableSlotReorder(orderedIds: string[], collectionSlug: string): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { getDbEntries } from '../../core/server/entries/operations/get.js';
2
+ import { updateEntry } from '../../core/server/entries/operations/update.js';
3
+ export async function stableSlotReorder(orderedIds, collectionSlug) {
4
+ if (orderedIds.length === 0)
5
+ return;
6
+ const allEntries = await getDbEntries({
7
+ slug: collectionSlug,
8
+ orderBy: { column: 'sortOrder', direction: 'asc' }
9
+ });
10
+ const allIds = allEntries.map((e) => e.id);
11
+ const orderedSet = new Set(orderedIds);
12
+ // Find slot positions occupied by orderedIds in the full list
13
+ const slotIndices = [];
14
+ for (let i = 0; i < allIds.length; i++) {
15
+ if (orderedSet.has(allIds[i])) {
16
+ slotIndices.push(i);
17
+ }
18
+ }
19
+ // Fill slots with new order
20
+ const result = [...allIds];
21
+ for (let i = 0; i < orderedIds.length; i++) {
22
+ result[slotIndices[i]] = orderedIds[i];
23
+ }
24
+ // Update only entries whose sortOrder changed
25
+ const updates = [];
26
+ for (let i = 0; i < result.length; i++) {
27
+ const entry = allEntries.find((e) => e.id === result[i]);
28
+ if (!entry || entry.sortOrder !== i) {
29
+ updates.push(updateEntry(result[i], { sortOrder: i }));
30
+ }
31
+ }
32
+ await Promise.all(updates);
33
+ }
@@ -119,7 +119,7 @@ export const getEntries = async (options = {}) => {
119
119
  const slugPath = getEntrySlugPath(entry.slug);
120
120
  const slug = getSlugFromEntryData(version.data, slugPath, language);
121
121
  const _url = slug ? getEntryPath(entry.slug, slug) : undefined;
122
- return {
122
+ const result = {
123
123
  _id: entry.id,
124
124
  _slug: entry.slug,
125
125
  _type: entry.type,
@@ -127,6 +127,10 @@ export const getEntries = async (options = {}) => {
127
127
  _url,
128
128
  ...populatedData
129
129
  };
130
+ if (config.type === 'collection' && config.orderable) {
131
+ result._sortOrder = entry.sortOrder;
132
+ }
133
+ return result;
130
134
  }
131
135
  catch (error) {
132
136
  console.error(`[CMS] Failed to populate entry ${entry.id} (${entry.slug}):`, error);
@@ -206,7 +210,7 @@ export const getEntries = async (options = {}) => {
206
210
  const slugPath = getEntrySlugPath(dbEntry.slug);
207
211
  const slug = getSlugFromEntryData(version.data, slugPath, language);
208
212
  const _url = slug ? getEntryPath(dbEntry.slug, slug) : undefined;
209
- return {
213
+ const result = {
210
214
  _id: dbEntry.id,
211
215
  _slug: dbEntry.slug,
212
216
  _type: dbEntry.type,
@@ -214,6 +218,10 @@ export const getEntries = async (options = {}) => {
214
218
  _url,
215
219
  ...populatedData
216
220
  };
221
+ if (config.type === 'collection' && config.orderable) {
222
+ result._sortOrder = dbEntry.sortOrder;
223
+ }
224
+ return result;
217
225
  }
218
226
  catch (error) {
219
227
  console.error(`[CMS] Failed to populate entry ${dbEntry.id} (${dbEntry.slug}):`, error);
@@ -349,7 +357,7 @@ export const getEntryVersion = async (options) => {
349
357
  const slugPath = getEntrySlugPath(dbEntry.slug);
350
358
  const entrySlug = getSlugFromEntryData(dbEntryVersion.data, slugPath, language);
351
359
  const _url = entrySlug ? getEntryPath(dbEntry.slug, entrySlug) : undefined;
352
- return {
360
+ const result = {
353
361
  _id: dbEntry.id,
354
362
  _slug: dbEntry.slug,
355
363
  _type: dbEntry.type,
@@ -357,6 +365,10 @@ export const getEntryVersion = async (options) => {
357
365
  _url,
358
366
  ...populatedData
359
367
  };
368
+ if (config.type === 'collection' && config.orderable) {
369
+ result._sortOrder = dbEntry.sortOrder;
370
+ }
371
+ return result;
360
372
  }
361
373
  catch (error) {
362
374
  console.error(`[CMS] Failed to populate entry ${dbEntry.id} (${dbEntry.slug}):`, error);
@@ -1,4 +1,4 @@
1
- import { getImageStyle } from '../../media/styles/operations/getImageStyle.js';
1
+ import { getImageStyleIfExists } from '../../media/styles/operations/getImageStyle.js';
2
2
  import { getCMS } from '../../../cms.js';
3
3
  import { generateBlurDataUrl } from '../../media/utils/generateBlurDataUrl.js';
4
4
  export const defaultStyles = [
@@ -67,7 +67,9 @@ export async function getImageStyles(field, val) {
67
67
  const [styles, blurDataUrl] = await Promise.all([
68
68
  Promise.all(stylesArr.map(async (style) => {
69
69
  try {
70
- const styleDbData = await getImageStyle(val.id, style);
70
+ const styleDbData = await getImageStyleIfExists(val.id, style);
71
+ if (!styleDbData)
72
+ return null;
71
73
  const result = {
72
74
  url: styleDbData.url,
73
75
  media: styleDbData.media,
@@ -86,7 +88,9 @@ export async function getImageStyles(field, val) {
86
88
  srcset: undefined,
87
89
  sizes: undefined
88
90
  };
89
- const variantData = await getImageStyle(val.id, variantStyle);
91
+ const variantData = await getImageStyleIfExists(val.id, variantStyle);
92
+ if (!variantData)
93
+ return null;
90
94
  return `${variantData.url} ${w}w`;
91
95
  }
92
96
  catch (e) {
@@ -19,6 +19,8 @@ function generateTypesStringForRecords(type, records) {
19
19
  const fieldsType = generateFlatTsTypeFromFields(getFieldsFromConfig(single));
20
20
  // Strip outer braces to inline fields into the interface
21
21
  const innerFields = fieldsType.replace(/^\s*\{/, '').replace(/\}\s*$/, '');
22
+ const sortOrderField = type === 'collection' && 'orderable' in single && single.orderable
23
+ ? '_sortOrder: number;\n\t\t\t\t' : '';
22
24
  return `
23
25
  export interface ${toPascalCase(single.slug)} {
24
26
  _id: string;
@@ -26,7 +28,7 @@ function generateTypesStringForRecords(type, records) {
26
28
  _type: string;
27
29
  _publishedAt: Date | null;
28
30
  _url?: string;
29
- ${innerFields}
31
+ ${sortOrderField}${innerFields}
30
32
  }
31
33
  `;
32
34
  })
@@ -1,3 +1,3 @@
1
1
  import type { ImageFieldStyle } from '../../../../../types/fields.js';
2
- import type { ImageStyle } from '../../../../../types/media.js';
3
- export declare function createImageStyle(mediaFileId: string, style: ImageFieldStyle): Promise<ImageStyle>;
2
+ import type { ImageStyle, MediaFile } from '../../../../../types/media.js';
3
+ export declare function createImageStyle(mediaFileId: string, style: ImageFieldStyle, buffer?: Buffer, mediaFile?: MediaFile): Promise<ImageStyle>;
@@ -1,11 +1,13 @@
1
1
  import { getCMS } from '../../../../cms.js';
2
- import { generateImageStyle } from '../sharp/generateImageStyle.js';
3
- export async function createImageStyle(mediaFileId, style) {
2
+ import { generateImageStyle, generateImageStyleFromBuffer } from '../sharp/generateImageStyle.js';
3
+ export async function createImageStyle(mediaFileId, style, buffer, mediaFile) {
4
4
  const cms = getCMS();
5
5
  // Check for existing style to clean up old file after upsert
6
6
  const existing = await cms.databaseAdapter.getImageStyle(mediaFileId, style);
7
7
  const oldUrl = existing?.url;
8
- const imageStyleFile = await generateImageStyle(mediaFileId, style);
8
+ const imageStyleFile = buffer && mediaFile
9
+ ? await generateImageStyleFromBuffer(buffer, mediaFile, style)
10
+ : await generateImageStyle(mediaFileId, style);
9
11
  const imageStyle = await cms.databaseAdapter.createImageStyle(mediaFileId, imageStyleFile, style);
10
12
  // Delete old file from disk if URL changed
11
13
  if (oldUrl && oldUrl !== imageStyle.url) {
@@ -1,16 +1,35 @@
1
+ import { getCMS } from '../../../../cms.js';
1
2
  import { defaultStyles, isProcessableImage, expandStyleFormats, getOriginalFormat } from '../../../fields/utils/imageStyles.js';
2
- import { getImageStyle } from './getImageStyle.js';
3
- export async function generateDefaultStyles(mediaFile) {
4
- if (!isProcessableImage(mediaFile))
5
- return;
3
+ import { createImageStyle } from './createMediaStyle.js';
4
+ const CONCURRENCY = 3;
5
+ async function runWithConcurrency(tasks, limit) {
6
+ const results = new Array(tasks.length);
7
+ let i = 0;
8
+ async function next() {
9
+ while (i < tasks.length) {
10
+ const idx = i++;
11
+ results[idx] = await tasks[idx]();
12
+ }
13
+ }
14
+ await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => next()));
15
+ return results;
16
+ }
17
+ async function downloadOriginalBuffer(mediaFile) {
18
+ const file = await getCMS().filesAdapter.downloadFile(mediaFile.url.split('/').pop() || '');
19
+ if (!file)
20
+ throw new Error('Media file not found');
21
+ return Buffer.from(await file.arrayBuffer());
22
+ }
23
+ function collectStyleTasks(mediaFile) {
6
24
  const origFormat = getOriginalFormat(mediaFile);
7
25
  const expanded = expandStyleFormats(defaultStyles, origFormat);
26
+ const tasks = [];
8
27
  for (const style of expanded) {
9
- await getImageStyle(mediaFile.id, style);
28
+ tasks.push(style);
10
29
  if (style.srcset && mediaFile.width) {
11
30
  const widths = style.srcset.filter((w) => w <= mediaFile.width);
12
31
  for (const w of widths) {
13
- await getImageStyle(mediaFile.id, {
32
+ tasks.push({
14
33
  ...style,
15
34
  name: `${style.name}_${w}w`,
16
35
  width: w,
@@ -20,6 +39,14 @@ export async function generateDefaultStyles(mediaFile) {
20
39
  }
21
40
  }
22
41
  }
42
+ return tasks;
43
+ }
44
+ export async function generateDefaultStyles(mediaFile) {
45
+ if (!isProcessableImage(mediaFile))
46
+ return;
47
+ const buffer = await downloadOriginalBuffer(mediaFile);
48
+ const styles = collectStyleTasks(mediaFile);
49
+ await runWithConcurrency(styles.map((style) => () => createImageStyle(mediaFile.id, style, buffer, mediaFile)), CONCURRENCY);
23
50
  }
24
51
  export function generateDefaultStylesInBackground(mediaFile) {
25
52
  generateDefaultStyles(mediaFile).catch((e) => console.warn('Background style generation failed:', e));
@@ -1,3 +1,4 @@
1
1
  import type { ImageFieldStyle } from '../../../../../types/fields.js';
2
2
  import type { ImageStyle } from '../../../../../types/media.js';
3
3
  export declare function getImageStyle(mediaFileId: string, style: ImageFieldStyle): Promise<ImageStyle>;
4
+ export declare function getImageStyleIfExists(mediaFileId: string, style: ImageFieldStyle): Promise<ImageStyle | null>;
@@ -6,3 +6,6 @@ export async function getImageStyle(mediaFileId, style) {
6
6
  return imageStyle;
7
7
  return createImageStyle(mediaFileId, style);
8
8
  }
9
+ export async function getImageStyleIfExists(mediaFileId, style) {
10
+ return getCMS().databaseAdapter.getImageStyle(mediaFileId, style);
11
+ }
@@ -1,3 +1,4 @@
1
1
  import type { ImageFieldStyle } from '../../../../../types/fields.js';
2
- import type { UploadedMediaFile } from '../../../../../types/media.js';
2
+ import type { MediaFile, UploadedMediaFile } from '../../../../../types/media.js';
3
3
  export declare function generateImageStyle(mediaFileId: string, style: ImageFieldStyle): Promise<UploadedMediaFile>;
4
+ export declare function generateImageStyleFromBuffer(buf: Buffer, mediaFile: MediaFile, style: ImageFieldStyle): Promise<UploadedMediaFile>;
@@ -14,8 +14,10 @@ export async function generateImageStyle(mediaFileId, style) {
14
14
  if (!file) {
15
15
  throw new Error('Media file not found');
16
16
  }
17
- const imageBuffer = await file.arrayBuffer();
18
- const buf = Buffer.from(imageBuffer);
17
+ const buf = Buffer.from(await file.arrayBuffer());
18
+ return generateImageStyleFromBuffer(buf, mediaFile, style);
19
+ }
20
+ export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
19
21
  // Read EXIF orientation before processing
20
22
  const metadata = await sharp(buf).metadata();
21
23
  // .rotate() applies EXIF orientation to pixels AND strips the tag from output.
@@ -51,7 +53,7 @@ export async function generateImageStyle(mediaFileId, style) {
51
53
  const format = style.format ?? originalExt ?? 'jpeg';
52
54
  sharpInstance = sharpInstance.toFormat(format, style.quality != null ? { quality: Math.max(1, Math.min(100, style.quality)) } : undefined);
53
55
  const outputBuffer = await sharpInstance.toBuffer();
54
- return getCMS().filesAdapter.uploadFile(new File([new Uint8Array(outputBuffer)], `${mediaFileId}_${style.name}_${Date.now().toString(36)}.${format}`, {
56
+ return getCMS().filesAdapter.uploadFile(new File([new Uint8Array(outputBuffer)], `${mediaFile.id}_${style.name}_${Date.now().toString(36)}.${format}`, {
55
57
  type: `image/${format}`
56
58
  }));
57
59
  }
@@ -0,0 +1,2 @@
1
+ export declare function parseAsBytes(value: string): number;
2
+ export declare function getMaxUploadSize(): number;
@@ -0,0 +1,26 @@
1
+ const SUFFIX_MULTIPLIERS = {
2
+ K: 1024,
3
+ M: 1024 * 1024,
4
+ G: 1024 * 1024 * 1024
5
+ };
6
+ const DEFAULT_LIMIT = '50M';
7
+ export function parseAsBytes(value) {
8
+ const trimmed = value.trim();
9
+ if (!trimmed)
10
+ return 0;
11
+ const suffix = trimmed.at(-1).toUpperCase();
12
+ const multiplier = SUFFIX_MULTIPLIERS[suffix];
13
+ if (multiplier) {
14
+ const num = Number(trimmed.slice(0, -1));
15
+ if (isNaN(num) || num < 0)
16
+ return 0;
17
+ return Math.floor(num * multiplier);
18
+ }
19
+ const num = Number(trimmed);
20
+ if (isNaN(num) || num < 0)
21
+ return 0;
22
+ return Math.floor(num);
23
+ }
24
+ export function getMaxUploadSize() {
25
+ return parseAsBytes(process.env.BODY_SIZE_LIMIT || DEFAULT_LIMIT);
26
+ }
@@ -41,6 +41,7 @@ export type Entry = {
41
41
  _type: EntryType;
42
42
  _publishedAt?: Date | null;
43
43
  _url?: string;
44
+ _sortOrder?: number | null;
44
45
  } & Record<string, unknown>;
45
46
  export interface DbEntryVersion {
46
47
  id: string;
@@ -0,0 +1,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -0,0 +1,20 @@
1
+ export const update = {
2
+ version: '0.13.2',
3
+ date: '2026-03-20',
4
+ description: 'Upload limits, stable reordering, relation filters, image style optimization',
5
+ features: [
6
+ 'Configurable upload size limit via BODY_SIZE_LIMIT env var (supports K/M/G suffixes, default 50M)',
7
+ 'File upload pre-validation — rejects oversized files before upload with localized error messages',
8
+ 'Stable slot reordering — preserves positions of filtered-out entries during DnD reorder',
9
+ 'Collection relation field filtering — filter entries by relation field values',
10
+ 'Expose _sortOrder on entries for orderable collections',
11
+ 'Codegen: _sortOrder field in generated TypeScript interfaces for orderable collections',
12
+ 'getImageStyleIfExists — non-blocking image style lookup (returns null if missing)'
13
+ ],
14
+ fixes: [
15
+ 'Concurrent image style generation with buffer reuse — download original once, generate styles in parallel (limit 3)',
16
+ 'Image style resolution gracefully skips missing styles instead of blocking',
17
+ 'Upload endpoints use configurable size limit instead of hardcoded 50MB'
18
+ ],
19
+ breakingChanges: []
20
+ };
@@ -34,7 +34,8 @@ import { update as update0110 } from './0.11.0/index.js';
34
34
  import { update as update0120 } from './0.12.0/index.js';
35
35
  import { update as update0130 } from './0.13.0/index.js';
36
36
  import { update as update0131 } from './0.13.1/index.js';
37
- export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131];
37
+ import { update as update0132 } from './0.13.2/index.js';
38
+ export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132];
38
39
  export const getUpdatesFrom = (fromVersion) => {
39
40
  const fromParts = fromVersion.split('.').map(Number);
40
41
  return updates.filter((update) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",