decap-cms-core 3.9.0 → 3.10.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.
Files changed (33) hide show
  1. package/dist/decap-cms-core.js +23 -23
  2. package/dist/decap-cms-core.js.LICENSE.txt +10 -0
  3. package/dist/decap-cms-core.js.map +1 -1
  4. package/dist/esm/actions/config.js +19 -0
  5. package/dist/esm/actions/entries.js +10 -1
  6. package/dist/esm/bootstrap.js +2 -2
  7. package/dist/esm/components/App/StatusBar.js +41 -0
  8. package/dist/esm/components/Collection/Entries/Pagination.js +132 -0
  9. package/dist/esm/components/Editor/EditorNotesPane/AddNoteForm.js +17 -14
  10. package/dist/esm/components/UI/ErrorBoundary.js +6 -9
  11. package/dist/esm/constants/configSchema.js +41 -23
  12. package/dist/esm/lib/entryCache.js +145 -0
  13. package/dist/esm/lib/entryHelpers.js +102 -0
  14. package/dist/esm/lib/formatters.js +2 -1
  15. package/dist/esm/lib/immutableHelpers.js +21 -0
  16. package/dist/esm/lib/indexFileHelper.js +36 -0
  17. package/dist/esm/lib/pagination.js +68 -0
  18. package/dist/esm/reducers/collections.js +54 -5
  19. package/dist/esm/reducers/entries.js +8 -2
  20. package/index.d.ts +8 -2
  21. package/package.json +2 -3
  22. package/src/actions/__tests__/config.spec.js +4 -4
  23. package/src/actions/config.ts +22 -0
  24. package/src/actions/entries.ts +11 -1
  25. package/src/components/UI/ErrorBoundary.js +1 -2
  26. package/src/constants/__tests__/configSchema.spec.js +84 -0
  27. package/src/constants/configSchema.js +34 -1
  28. package/src/lib/__tests__/formatters.spec.js +30 -2
  29. package/src/lib/formatters.ts +6 -1
  30. package/src/reducers/__tests__/collections.spec.js +39 -0
  31. package/src/reducers/collections.ts +52 -5
  32. package/src/reducers/entries.ts +12 -2
  33. package/src/types/redux.ts +9 -3
@@ -1,24 +1,3 @@
1
- function _extendableBuiltin(cls) {
2
- function ExtendableBuiltin() {
3
- var instance = Reflect.construct(cls, Array.from(arguments));
4
- Object.setPrototypeOf(instance, Object.getPrototypeOf(this));
5
- return instance;
6
- }
7
- ExtendableBuiltin.prototype = Object.create(cls.prototype, {
8
- constructor: {
9
- value: cls,
10
- enumerable: false,
11
- writable: true,
12
- configurable: true
13
- }
14
- });
15
- if (Object.setPrototypeOf) {
16
- Object.setPrototypeOf(ExtendableBuiltin, cls);
17
- } else {
18
- ExtendableBuiltin.__proto__ = cls;
19
- }
20
- return ExtendableBuiltin;
21
- }
22
1
  import AJV from 'ajv';
23
2
  import { select, uniqueItemProperties, instanceof as instanceOf, prohibited } from 'ajv-keywords/dist/keywords';
24
3
  import ajvErrors from 'ajv-errors';
@@ -410,7 +389,29 @@ function getConfigSchema() {
410
389
  sortable_fields: {
411
390
  type: 'array',
412
391
  items: {
413
- type: 'string'
392
+ oneOf: [{
393
+ type: 'string'
394
+ }, {
395
+ type: 'object',
396
+ properties: {
397
+ field: {
398
+ type: 'string'
399
+ },
400
+ label: {
401
+ type: 'string'
402
+ },
403
+ default_sort: {
404
+ oneOf: [{
405
+ type: 'boolean'
406
+ }, {
407
+ type: 'string',
408
+ enum: ['asc', 'desc']
409
+ }]
410
+ }
411
+ },
412
+ required: ['field'],
413
+ additionalProperties: false
414
+ }]
414
415
  }
415
416
  },
416
417
  sortableFields: {
@@ -523,7 +524,7 @@ function getWidgetSchemas() {
523
524
  }));
524
525
  return Object.assign(...schemas);
525
526
  }
526
- class ConfigError extends _extendableBuiltin(Error) {
527
+ class ConfigError extends Error {
527
528
  constructor(errors, ...args) {
528
529
  const message = errors.map(({
529
530
  message,
@@ -602,4 +603,21 @@ export function validateConfig(config) {
602
603
  console.error('Config Errors', errors);
603
604
  throw new ConfigError(errors);
604
605
  }
606
+
607
+ // Custom validation: only one sortable field can have default_sort property
608
+ if (config.collections) {
609
+ config.collections.forEach((collection, index) => {
610
+ if (collection.sortable_fields) {
611
+ const defaultFields = collection.sortable_fields.filter(field => typeof field === 'object' && field.default_sort !== undefined);
612
+ if (defaultFields.length > 1) {
613
+ const error = {
614
+ instancePath: `/collections/${index}/sortable_fields`,
615
+ message: 'only one sortable field can have the default_sort property'
616
+ };
617
+ console.error('Config Errors', [error]);
618
+ throw new ConfigError([error]);
619
+ }
620
+ }
621
+ });
622
+ }
605
623
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Entry Cache Module
3
+ *
4
+ * Provides localStorage-based caching for collection entries to improve performance
5
+ * when navigating between pages, sorting, or filtering.
6
+ *
7
+ * Cache Strategy:
8
+ * - Store fetched entries with timestamps
9
+ * - Invalidate on entry changes (persist, delete)
10
+ * - Time-based expiration (default: 5 minutes)
11
+ * - Collection-specific cache keys
12
+ */
13
+
14
+ import localForage from 'decap-cms-lib-util/src/localForage';
15
+ const CACHE_PREFIX = 'decap_entry_cache_';
16
+ const CACHE_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes
17
+
18
+ /**
19
+ * Generate cache key for a collection
20
+ */
21
+ function getCacheKey(collectionName) {
22
+ return `${CACHE_PREFIX}${collectionName}`;
23
+ }
24
+
25
+ /**
26
+ * Check if cached data is still valid
27
+ */
28
+ function isCacheValid(cacheEntry) {
29
+ if (!cacheEntry) {
30
+ return false;
31
+ }
32
+ const now = Date.now();
33
+ const age = now - cacheEntry.timestamp;
34
+ return age < CACHE_EXPIRATION_MS;
35
+ }
36
+
37
+ /**
38
+ * Get cached entries for a collection
39
+ *
40
+ * @param collectionName - Name of the collection
41
+ * @returns Cached entries or null if cache miss/expired
42
+ */
43
+ export async function getCachedEntries(collectionName) {
44
+ try {
45
+ const cacheKey = getCacheKey(collectionName);
46
+ const cached = await localForage.getItem(cacheKey);
47
+ if (cached && isCacheValid(cached)) {
48
+ console.log(`[EntryCache] Cache HIT for collection: ${collectionName}`);
49
+ return cached.entries;
50
+ }
51
+ console.log(`[EntryCache] Cache MISS for collection: ${collectionName}`);
52
+ return null;
53
+ } catch (error) {
54
+ console.warn('[EntryCache] Error reading cache:', error);
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Store entries in cache
61
+ *
62
+ * @param collectionName - Name of the collection
63
+ * @param entries - Entries to cache
64
+ */
65
+ export async function setCachedEntries(collectionName, entries) {
66
+ try {
67
+ const cacheKey = getCacheKey(collectionName);
68
+ const cacheEntry = {
69
+ entries,
70
+ timestamp: Date.now(),
71
+ collectionName,
72
+ version: 1
73
+ };
74
+ await localForage.setItem(cacheKey, cacheEntry);
75
+ console.log(`[EntryCache] Cached ${entries.length} entries for collection: ${collectionName}`);
76
+ } catch (error) {
77
+ console.warn('[EntryCache] Error writing cache:', error);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Invalidate cache for a specific collection
83
+ *
84
+ * Should be called when:
85
+ * - Entry is created
86
+ * - Entry is updated
87
+ * - Entry is deleted
88
+ *
89
+ * @param collectionName - Name of the collection to invalidate
90
+ */
91
+ export async function invalidateCollectionCache(collectionName) {
92
+ try {
93
+ const cacheKey = getCacheKey(collectionName);
94
+ await localForage.removeItem(cacheKey);
95
+ console.log(`[EntryCache] Invalidated cache for collection: ${collectionName}`);
96
+ } catch (error) {
97
+ console.warn('[EntryCache] Error invalidating cache:', error);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Clear all entry caches
103
+ *
104
+ * Useful for logout or manual cache clearing
105
+ */
106
+ export async function clearAllEntryCaches() {
107
+ try {
108
+ const keys = await localForage.keys();
109
+ const cacheKeys = keys.filter(key => key.startsWith(CACHE_PREFIX));
110
+ await Promise.all(cacheKeys.map(key => localForage.removeItem(key)));
111
+ console.log(`[EntryCache] Cleared ${cacheKeys.length} collection caches`);
112
+ } catch (error) {
113
+ console.warn('[EntryCache] Error clearing all caches:', error);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Get cache statistics for debugging
119
+ */
120
+ export async function getCacheStats() {
121
+ try {
122
+ const keys = await localForage.keys();
123
+ const cacheKeys = keys.filter(key => key.startsWith(CACHE_PREFIX));
124
+ const cacheEntries = await Promise.all(cacheKeys.map(async key => {
125
+ const entry = await localForage.getItem(key);
126
+ return entry;
127
+ }));
128
+ const validCaches = cacheEntries.filter(entry => entry !== null);
129
+ const timestamps = validCaches.map(c => c.timestamp);
130
+ return {
131
+ collections: validCaches.map(c => c.collectionName),
132
+ totalEntries: validCaches.reduce((sum, c) => sum + c.entries.length, 0),
133
+ oldestCache: timestamps.length > 0 ? Math.min(...timestamps) : null,
134
+ newestCache: timestamps.length > 0 ? Math.max(...timestamps) : null
135
+ };
136
+ } catch (error) {
137
+ console.warn('[EntryCache] Error getting cache stats:', error);
138
+ return {
139
+ collections: [],
140
+ totalEntries: 0,
141
+ oldestCache: null,
142
+ newestCache: null
143
+ };
144
+ }
145
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Utility functions for working with entries, filters, sorts, and groups
3
+ */
4
+
5
+ /**
6
+ * Check if any filters are active in the Redux state
7
+ */
8
+ export function hasActiveFilters(activeFilters) {
9
+ if (!activeFilters) return false;
10
+
11
+ // Check if it's an Immutable collection with a 'some' method
12
+ if (typeof activeFilters === 'object' &&
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ typeof activeFilters.some === 'function') {
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ return activeFilters.some(f => f.get('active') === true);
17
+ }
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * Check if any groups are active in the Redux state
23
+ */
24
+ export function hasActiveGroups(activeGroups) {
25
+ if (!activeGroups) return false;
26
+
27
+ // Check if it's an Immutable collection with a 'some' method
28
+ if (typeof activeGroups === 'object' &&
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ typeof activeGroups.some === 'function') {
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ return activeGroups.some(g => g.get('active') === true);
33
+ }
34
+ return false;
35
+ }
36
+
37
+ /**
38
+ * Check if any sorts are active in the Redux state
39
+ */
40
+ export function hasActiveSorts(activeSorts) {
41
+ if (!activeSorts) return false;
42
+
43
+ // Check if it's an Immutable collection with a 'size' property
44
+ if (typeof activeSorts === 'object' &&
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ typeof activeSorts.size === 'number') {
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ return activeSorts.size > 0;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Get value from a nested field path (e.g., "data.title" or "data.nested.field")
55
+ */
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ export function getFieldValue(obj, fieldPath) {
58
+ const pathParts = fieldPath.split('.');
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ let value = obj;
61
+ for (const part of pathParts) {
62
+ value = value?.[part];
63
+ }
64
+ return value;
65
+ }
66
+
67
+ /**
68
+ * Extract active filters from Immutable collection into plain array
69
+ */
70
+
71
+ export function extractActiveFilters(activeFilters) {
72
+ const filters = [];
73
+ if (!activeFilters) return filters;
74
+
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ if (typeof activeFilters.forEach === 'function') {
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ activeFilters.forEach(f => {
79
+ if (f.get('active') === true) {
80
+ filters.push({
81
+ pattern: f.get('pattern'),
82
+ field: f.get('field')
83
+ });
84
+ }
85
+ });
86
+ }
87
+ return filters;
88
+ }
89
+
90
+ /**
91
+ * Apply filters to an entry
92
+ */
93
+ export function matchesFilters(entry, filters) {
94
+ return filters.every(({
95
+ pattern,
96
+ field
97
+ }) => {
98
+ const data = ('data' in entry ? entry.data : entry) || {};
99
+ const value = getFieldValue(data, field);
100
+ return value !== undefined && new RegExp(String(pattern)).test(String(value));
101
+ });
102
+ }
@@ -11,6 +11,7 @@ import { COMMIT_AUTHOR, COMMIT_DATE } from '../constants/commitProps';
11
11
  const {
12
12
  compileStringTemplate,
13
13
  parseDateFromEntry,
14
+ parseDateFromEntryData,
14
15
  SLUG_MISSING_REQUIRED_DATE,
15
16
  keyToPathArray,
16
17
  addFileTemplateFields
@@ -91,7 +92,7 @@ export function slugFormatter(collection, entryData, slugConfig) {
91
92
  throw new Error('Collection must have a field name that is a valid entry identifier, or must have `identifier_field` set');
92
93
  }
93
94
  const processSegment = getProcessSegment(slugConfig);
94
- const date = new Date();
95
+ const date = parseDateFromEntryData(entryData, selectInferredField(collection, 'date')) || new Date(Date.now());
95
96
  const slug = compileStringTemplate(slugTemplate, date, identifier, entryData, processSegment);
96
97
  if (!collection.has('path')) {
97
98
  return slug;
@@ -0,0 +1,21 @@
1
+ import { Map } from 'immutable';
2
+ /**
3
+ * Type guard to check if an object is an Immutable.js Map
4
+ */
5
+ export function isImmutableMap(obj) {
6
+ return Map.isMap(obj);
7
+ }
8
+
9
+ /**
10
+ * Helper to safely get a value from either an Immutable Map or plain object
11
+ */
12
+ export function getValue(obj, key) {
13
+ if (!obj) return undefined;
14
+ if (isImmutableMap(obj)) {
15
+ return obj.get(key);
16
+ }
17
+ if (typeof obj === 'object' && obj !== null) {
18
+ return obj[key];
19
+ }
20
+ return undefined;
21
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Determines if a file slug matches the index file pattern for a collection.
3
+ * @param filePath - The file slug or path to check
4
+ * @param pattern - The regex pattern from the collection's index_file config
5
+ * @param nested - Whether the collection is nested (affects how the slug is extracted)
6
+ * @returns True if the file matches the index file pattern
7
+ */
8
+ export function isIndexFile(filePath, pattern, nested) {
9
+ const fileSlug = nested ? filePath?.split('/').pop() : filePath;
10
+ return !!(fileSlug && new RegExp(pattern).test(fileSlug));
11
+ }
12
+
13
+ /**
14
+ * Determines if an entry is an index file entry.
15
+ * Checks both the meta.path_type field (set by the backend) and the slug pattern.
16
+ * @param entry - The entry to check
17
+ * @param collection - The collection configuration containing index_file pattern
18
+ * @returns True if the entry is an index file
19
+ */
20
+ export function isIndexFileEntry(entry, collection) {
21
+ if (!entry) {
22
+ return false;
23
+ }
24
+ const pathType = entry.getIn(['meta', 'path_type']);
25
+ if (pathType === 'index') {
26
+ return true;
27
+ }
28
+ const indexFileConfig = collection.get('index_file');
29
+ if (!indexFileConfig) {
30
+ return false;
31
+ }
32
+ const slug = entry.get('slug');
33
+ const pattern = indexFileConfig.get('pattern');
34
+ const nested = !!collection.get('nested');
35
+ return isIndexFile(slug, pattern, nested);
36
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pagination Utilities
3
+ *
4
+ * This module provides helpers for determining if pagination is enabled for a collection,
5
+ * and for retrieving the effective pagination configuration (page size, enabled flag).
6
+ *
7
+ * Principles:
8
+ * - Pagination can be enabled/disabled per collection or globally via config.
9
+ * - If sorting, filtering, or grouping is active, pagination is handled client-side (all entries loaded).
10
+ * - If none are active, server-side pagination is used (only a page of entries loaded at a time).
11
+ * - The effective page size is determined by collection config, then global config, then a default.
12
+ *
13
+ * Usage:
14
+ * - Use isPaginationEnabled(collection, config) to check if pagination should be active.
15
+ * - Use getPaginationConfig(collection, config) to get the effective page size and enabled flag.
16
+ */
17
+ import { getValue, isImmutableMap } from './immutableHelpers';
18
+ const DEFAULT_PER_PAGE = 100;
19
+ export function isPaginationEnabled(collection, globalConfig) {
20
+ const pagination = isImmutableMap(collection) ? collection.get('pagination') : collection.pagination;
21
+ if (typeof pagination !== 'undefined') {
22
+ if (typeof pagination === 'boolean') return pagination;
23
+ if (pagination && typeof pagination === 'object') {
24
+ const enabled = getValue(pagination, 'enabled');
25
+ return enabled !== false;
26
+ }
27
+ return false;
28
+ }
29
+ if (globalConfig?.pagination) {
30
+ if (typeof globalConfig.pagination === 'boolean') return globalConfig.pagination;
31
+ if (typeof globalConfig.pagination === 'object') {
32
+ const enabled = getValue(globalConfig.pagination, 'enabled');
33
+ return enabled !== false;
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+ export function getPaginationConfig(collection, globalConfig) {
39
+ const defaults = {
40
+ enabled: false,
41
+ per_page: DEFAULT_PER_PAGE
42
+ };
43
+ if (globalConfig?.pagination) {
44
+ if (typeof globalConfig.pagination === 'boolean') {
45
+ defaults.enabled = globalConfig.pagination;
46
+ } else if (typeof globalConfig.pagination === 'object') {
47
+ const enabled = getValue(globalConfig.pagination, 'enabled');
48
+ defaults.enabled = enabled !== false;
49
+ const perPage = getValue(globalConfig.pagination, 'per_page');
50
+ defaults.per_page = typeof perPage === 'number' ? perPage : defaults.per_page;
51
+ }
52
+ }
53
+ const pagination = isImmutableMap(collection) ? collection.get('pagination') : collection.pagination;
54
+ if (pagination === true) {
55
+ return {
56
+ enabled: true,
57
+ per_page: defaults.per_page
58
+ };
59
+ } else if (typeof pagination === 'object' && pagination !== null) {
60
+ const perPage = getValue(pagination, 'per_page');
61
+ const enabled = getValue(pagination, 'enabled');
62
+ return {
63
+ enabled: enabled !== false,
64
+ per_page: typeof perPage === 'number' ? perPage : defaults.per_page
65
+ };
66
+ }
67
+ return defaults;
68
+ }
@@ -305,32 +305,58 @@ export function selectDefaultSortableFields(collection, backend, hasIntegration)
305
305
  // always have commit date by default
306
306
  defaultSortable = [COMMIT_DATE, ...defaultSortable];
307
307
  }
308
- return defaultSortable;
308
+
309
+ // Return as objects with field property
310
+ return defaultSortable.map(field => ({
311
+ field
312
+ }));
309
313
  }
310
314
  export function selectSortableFields(collection, t) {
311
- const fields = collection.get('sortable_fields').toArray().map(key => {
315
+ const fields = collection.get('sortable_fields').toArray().map(sortableField => {
316
+ // Extract the field name and custom label from the sortable field object
317
+ const key = sortableField.get('field');
318
+ const customLabel = sortableField.get('label');
312
319
  if (key === COMMIT_DATE) {
320
+ const label = customLabel || t('collection.defaultFields.updatedOn.label');
313
321
  return {
314
322
  key,
315
323
  field: {
316
324
  name: key,
317
- label: t('collection.defaultFields.updatedOn.label')
325
+ label
318
326
  }
319
327
  };
320
328
  }
321
329
  const field = selectField(collection, key);
322
330
  if (key === COMMIT_AUTHOR && !field) {
331
+ const label = customLabel || t('collection.defaultFields.author.label');
323
332
  return {
324
333
  key,
325
334
  field: {
326
335
  name: key,
327
- label: t('collection.defaultFields.author.label')
336
+ label
328
337
  }
329
338
  };
330
339
  }
340
+ let fieldObj = field?.toJS();
341
+
342
+ // If custom label is provided, override the field's label
343
+ if (fieldObj && customLabel) {
344
+ fieldObj = {
345
+ ...fieldObj,
346
+ label: customLabel
347
+ };
348
+ }
349
+
350
+ // If no label exists at all, use the field name
351
+ if (fieldObj && !fieldObj.label) {
352
+ fieldObj = {
353
+ ...fieldObj,
354
+ label: fieldObj.name || key
355
+ };
356
+ }
331
357
  return {
332
358
  key,
333
- field: field?.toJS()
359
+ field: fieldObj
334
360
  };
335
361
  }).filter(item => !!item.field).map(item => ({
336
362
  ...item.field,
@@ -338,6 +364,29 @@ export function selectSortableFields(collection, t) {
338
364
  }));
339
365
  return fields;
340
366
  }
367
+ export function selectDefaultSortField(collection) {
368
+ const sortableFields = collection.get('sortable_fields').toArray();
369
+ const defaultField = sortableFields.find(field => field.get('default_sort') !== undefined);
370
+ if (!defaultField) {
371
+ return null;
372
+ }
373
+ const fieldName = defaultField.get('field');
374
+ const defaultSortValue = defaultField.get('default_sort');
375
+
376
+ // Determine direction based on default_sort value
377
+ let direction;
378
+ if (defaultSortValue === true || defaultSortValue === 'asc') {
379
+ direction = 'asc';
380
+ } else if (defaultSortValue === 'desc') {
381
+ direction = 'desc';
382
+ } else {
383
+ direction = 'asc'; // fallback
384
+ }
385
+ return {
386
+ field: fieldName,
387
+ direction
388
+ };
389
+ }
341
390
  export function selectSortDataPath(collection, key) {
342
391
  if (key === COMMIT_DATE) {
343
392
  return 'updatedOn';
@@ -25,6 +25,12 @@ let page;
25
25
  let slug;
26
26
  const storageSortKey = 'decap-cms.entries.sort';
27
27
  const viewStyleKey = 'decap-cms.entries.viewStyle';
28
+ function normalizeDoubleSlashes(path) {
29
+ if (!path) {
30
+ return path;
31
+ }
32
+ return path.replace(/([^:]\/)\/+/g, '$1');
33
+ }
28
34
  const loadSort = once(() => {
29
35
  const sortString = localStorage.getItem(storageSortKey);
30
36
  if (sortString) {
@@ -544,10 +550,10 @@ export function selectMediaFilePublicPath(config, collection, mediaPath, entryMa
544
550
  return mediaPath;
545
551
  }
546
552
  const name = 'public_folder';
547
- let publicFolder = config[name];
553
+ let publicFolder = normalizeDoubleSlashes(config[name]);
548
554
  const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
549
555
  if (customFolder) {
550
- publicFolder = evaluateFolder(name, config, collection, entryMap, field);
556
+ publicFolder = normalizeDoubleSlashes(evaluateFolder(name, config, collection, entryMap, field));
551
557
  }
552
558
  if (isAbsolutePath(publicFolder)) {
553
559
  return joinUrlPath(publicFolder, basename(mediaPath));
package/index.d.ts CHANGED
@@ -292,6 +292,12 @@ declare module 'decap-cms-core' {
292
292
  pattern?: string;
293
293
  }
294
294
 
295
+ export interface SortableField {
296
+ field: string;
297
+ label?: string;
298
+ default_sort?: boolean | 'asc' | 'desc';
299
+ }
300
+
295
301
  export interface CmsCollection {
296
302
  name: string;
297
303
  label: string;
@@ -332,7 +338,7 @@ declare module 'decap-cms-core' {
332
338
  path?: string;
333
339
  media_folder?: string;
334
340
  public_folder?: string;
335
- sortable_fields?: string[];
341
+ sortable_fields?: (string | SortableField)[];
336
342
  view_filters?: ViewFilter[];
337
343
  view_groups?: ViewGroup[];
338
344
  i18n?: boolean | CmsI18nConfig;
@@ -340,7 +346,7 @@ declare module 'decap-cms-core' {
340
346
  /**
341
347
  * @deprecated Use sortable_fields instead
342
348
  */
343
- sortableFields?: string[];
349
+ sortableFields?: (string | SortableField)[];
344
350
  }
345
351
 
346
352
  export interface CmsBackend {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decap-cms-core",
3
3
  "description": "Decap CMS core application, see decap-cms package for the main distribution.",
4
- "version": "3.9.0",
4
+ "version": "3.10.0",
5
5
  "repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-core",
6
6
  "bugs": "https://github.com/decaporg/decap-cms/issues",
7
7
  "module": "dist/esm/index.js",
@@ -28,7 +28,6 @@
28
28
  "@reduxjs/toolkit": "^1.9.1",
29
29
  "@vercel/stega": "^0.1.2",
30
30
  "buffer": "^6.0.3",
31
- "clean-stack": "^5.2.0",
32
31
  "copy-text-to-clipboard": "^3.0.0",
33
32
  "dayjs": "^1.11.10",
34
33
  "deepmerge": "^4.2.2",
@@ -100,5 +99,5 @@
100
99
  "browser": {
101
100
  "path": "path-browserify"
102
101
  },
103
- "gitHead": "13afe6686b32f7c03b4819d343d787c63f13664f"
102
+ "gitHead": "0c1b7c7d63ba6ddb3de3692fbb6b16ac6ebb44d5"
104
103
  }
@@ -1,5 +1,5 @@
1
1
  import { stripIndent } from 'common-tags';
2
- import { dump } from 'js-yaml';
2
+ import { stringify } from 'yaml';
3
3
 
4
4
  import {
5
5
  loadConfig,
@@ -480,7 +480,7 @@ describe('config', () => {
480
480
  ).toEqual({
481
481
  collections: [
482
482
  {
483
- sortable_fields: ['title'],
483
+ sortable_fields: [{ field: 'title', default_sort: undefined }],
484
484
  folder: 'src',
485
485
  type: 'folder_based_collection',
486
486
  view_filters: [],
@@ -934,7 +934,7 @@ describe('config', () => {
934
934
 
935
935
  global.fetch.mockResolvedValue({
936
936
  status: 200,
937
- text: () => Promise.resolve(dump({ backend: { repo: 'test-repo' } })),
937
+ text: () => Promise.resolve(stringify({ backend: { repo: 'test-repo' } })),
938
938
  headers: new Headers(),
939
939
  });
940
940
  await loadConfig()(dispatch);
@@ -962,7 +962,7 @@ describe('config', () => {
962
962
  document.querySelector.mockReturnValue({ type: 'text/yaml', href: 'custom-config.yml' });
963
963
  global.fetch.mockResolvedValue({
964
964
  status: 200,
965
- text: () => Promise.resolve(dump({ backend: { repo: 'github' } })),
965
+ text: () => Promise.resolve(stringify({ backend: { repo: 'github' } })),
966
966
  headers: new Headers(),
967
967
  });
968
968
  await loadConfig()(dispatch);