decap-cms-core 3.13.0 → 3.15.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 (86) hide show
  1. package/dist/decap-cms-core.js +9 -9
  2. package/dist/decap-cms-core.js.map +1 -1
  3. package/dist/esm/actions/config.js +14 -1
  4. package/dist/esm/actions/entries.js +15 -4
  5. package/dist/esm/backend.js +2 -0
  6. package/dist/esm/bootstrap.js +2 -2
  7. package/dist/esm/components/App/App.js +15 -8
  8. package/dist/esm/components/App/Header.js +18 -18
  9. package/dist/esm/components/Collection/Collection.js +7 -7
  10. package/dist/esm/components/Collection/CollectionControls.js +1 -1
  11. package/dist/esm/components/Collection/CollectionTop.js +8 -8
  12. package/dist/esm/components/Collection/ControlButton.js +2 -2
  13. package/dist/esm/components/Collection/Entries/Entries.js +2 -2
  14. package/dist/esm/components/Collection/Entries/EntryCard.js +30 -15
  15. package/dist/esm/components/Collection/NestedCollection.js +20 -11
  16. package/dist/esm/components/Collection/Sidebar.js +6 -6
  17. package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +22 -6
  18. package/dist/esm/components/MediaLibrary/MediaLibraryButtons.js +48 -9
  19. package/dist/esm/components/MediaLibrary/MediaLibraryCard.js +7 -9
  20. package/dist/esm/components/MediaLibrary/MediaLibraryCardGrid.js +5 -5
  21. package/dist/esm/components/MediaLibrary/MediaLibraryHeader.js +15 -3
  22. package/dist/esm/components/MediaLibrary/MediaLibraryModal.js +4 -1
  23. package/dist/esm/components/MediaLibrary/MediaLibrarySearch.js +6 -6
  24. package/dist/esm/components/MediaLibrary/MediaLibraryTop.js +14 -24
  25. package/dist/esm/components/UI/ErrorBoundary.js +2 -2
  26. package/dist/esm/components/UI/Modal.js +11 -4
  27. package/dist/esm/components/UI/SettingsDropdown.js +25 -27
  28. package/dist/esm/components/Workflow/Workflow.js +5 -5
  29. package/dist/esm/components/Workflow/WorkflowCard.js +14 -14
  30. package/dist/esm/components/Workflow/WorkflowList.js +18 -42
  31. package/dist/esm/constants/configSchema.js +2 -3
  32. package/dist/esm/lib/registry.js +4 -1
  33. package/dist/esm/reducers/entries.js +9 -7
  34. package/dist/esm/reducers/entryDraft.js +39 -7
  35. package/dist/esm/reducers/mediaLibrary.js +2 -3
  36. package/dist/esm/reducers/notifications.js +1 -2
  37. package/index.d.ts +19 -3
  38. package/package.json +7 -8
  39. package/src/__tests__/backend.spec.js +214 -0
  40. package/src/actions/__tests__/config.spec.js +14 -0
  41. package/src/actions/__tests__/editorialWorkflow.spec.js +1 -3
  42. package/src/actions/__tests__/entries.spec.js +36 -1
  43. package/src/actions/config.ts +13 -1
  44. package/src/actions/entries.ts +22 -7
  45. package/src/backend.ts +2 -0
  46. package/src/components/App/App.js +22 -14
  47. package/src/components/App/Header.js +36 -11
  48. package/src/components/Collection/Collection.js +10 -3
  49. package/src/components/Collection/CollectionControls.js +4 -3
  50. package/src/components/Collection/CollectionTop.js +3 -2
  51. package/src/components/Collection/ControlButton.js +1 -0
  52. package/src/components/Collection/Entries/Entries.js +1 -0
  53. package/src/components/Collection/Entries/EntryCard.js +13 -3
  54. package/src/components/Collection/NestedCollection.js +14 -7
  55. package/src/components/Collection/Sidebar.js +8 -7
  56. package/src/components/Collection/__tests__/NestedCollection.spec.js +1 -1
  57. package/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap +76 -12
  58. package/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap +0 -68
  59. package/src/components/Collection/__tests__/__snapshots__/Sidebar.spec.js.snap +76 -16
  60. package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -5
  61. package/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap +104 -72
  62. package/src/components/MediaLibrary/MediaLibraryButtons.js +39 -7
  63. package/src/components/MediaLibrary/MediaLibraryCard.js +1 -3
  64. package/src/components/MediaLibrary/MediaLibraryCardGrid.js +2 -2
  65. package/src/components/MediaLibrary/MediaLibraryHeader.js +32 -17
  66. package/src/components/MediaLibrary/MediaLibraryModal.js +8 -4
  67. package/src/components/MediaLibrary/MediaLibrarySearch.js +3 -1
  68. package/src/components/MediaLibrary/MediaLibraryTop.js +19 -1
  69. package/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap +6 -6
  70. package/src/components/UI/Modal.js +10 -3
  71. package/src/components/UI/SettingsDropdown.js +36 -9
  72. package/src/components/Workflow/Workflow.js +7 -1
  73. package/src/components/Workflow/WorkflowCard.js +52 -14
  74. package/src/components/Workflow/WorkflowList.js +50 -31
  75. package/src/constants/__tests__/configSchema.spec.js +9 -6
  76. package/src/constants/configSchema.js +2 -3
  77. package/src/lib/__tests__/formatters.spec.js +16 -4
  78. package/src/lib/__tests__/registry.spec.js +3 -3
  79. package/src/lib/registry.js +4 -1
  80. package/src/reducers/__tests__/entryDraft.spec.js +118 -1
  81. package/src/reducers/__tests__/mediaLibrary.spec.js +0 -1
  82. package/src/reducers/entries.ts +9 -7
  83. package/src/reducers/entryDraft.js +46 -7
  84. package/src/reducers/mediaLibrary.ts +2 -3
  85. package/src/reducers/notifications.ts +1 -2
  86. package/src/types/redux.ts +22 -5
@@ -1,7 +1,6 @@
1
1
  import AJV from 'ajv';
2
2
  import { select, uniqueItemProperties, instanceof as instanceOf, prohibited } from 'ajv-keywords/dist/keywords';
3
3
  import ajvErrors from 'ajv-errors';
4
- import { v4 as uuid } from 'uuid';
5
4
  import { frontmatterFormats, extensionFormatters } from '../formats/formats';
6
5
  import { getWidgets } from '../lib/registry';
7
6
  import { I18N_STRUCTURE, I18N_FIELD } from '../lib/i18n';
@@ -49,7 +48,7 @@ const i18nField = {
49
48
  * Config for fields in both file and folder collections.
50
49
  */
51
50
  function fieldsConfig() {
52
- const id = uuid();
51
+ const id = crypto.randomUUID();
53
52
  return {
54
53
  $id: `fields_${id}`,
55
54
  type: 'array',
@@ -470,7 +469,7 @@ function getConfigSchema() {
470
469
  type: 'string'
471
470
  }
472
471
  },
473
- required: ['label', 'widget', 'index_file']
472
+ required: ['label', 'widget']
474
473
  }
475
474
  },
476
475
  additionalProperties: false,
@@ -266,7 +266,10 @@ export async function invokeEvent({
266
266
  };
267
267
  }
268
268
  }
269
- return _data.entry.get('data');
269
+ // Return the full entry object with all metadata (slug, path, meta, etc.)
270
+ // rather than just the data payload. Callers like invokePreSaveEvent expect
271
+ // the complete entry object to be preserved through the event handler chain.
272
+ return _data.entry;
270
273
  }
271
274
  export function removeEventListener({
272
275
  name,
@@ -379,14 +379,16 @@ function getGroup(entry, selectedGroup) {
379
379
  if (selectedGroup.has('pattern')) {
380
380
  const pattern = selectedGroup.get('pattern');
381
381
  let value = '';
382
- try {
383
- const regex = new RegExp(pattern);
384
- const matched = dataAsString.match(regex);
385
- if (matched) {
386
- value = matched[0];
382
+ if (pattern !== undefined) {
383
+ try {
384
+ const regex = new RegExp(pattern);
385
+ const matched = dataAsString.match(regex);
386
+ if (matched) {
387
+ value = matched[0];
388
+ }
389
+ } catch (e) {
390
+ console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
387
391
  }
388
- } catch (e) {
389
- console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
390
392
  }
391
393
  return {
392
394
  id: `${label}${value}`,
@@ -1,7 +1,7 @@
1
1
  import { Map, List, fromJS } from 'immutable';
2
- import { v4 as uuid } from 'uuid';
3
2
  import get from 'lodash/get';
4
- import { join } from 'path';
3
+ import { join, basename } from 'path';
4
+ import { sanitizeSlug } from '../lib/urlHelper';
5
5
  import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, DRAFT_CHANGE_FIELD, DRAFT_VALIDATION_ERRORS, DRAFT_CLEAR_ERRORS, DRAFT_LOCAL_BACKUP_RETRIEVED, DRAFT_CREATE_FROM_LOCAL_BACKUP, DRAFT_CREATE_DUPLICATE_FROM_ENTRY, ENTRY_PERSIST_REQUEST, ENTRY_PERSIST_SUCCESS, ENTRY_PERSIST_FAILURE, ENTRY_DELETE_SUCCESS, ADD_DRAFT_ENTRY_MEDIA_FILE, REMOVE_DRAFT_ENTRY_MEDIA_FILE } from '../actions/entries';
6
6
  import { UNPUBLISHED_ENTRY_PERSIST_REQUEST, UNPUBLISHED_ENTRY_PERSIST_SUCCESS, UNPUBLISHED_ENTRY_PERSIST_FAILURE, UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE, UNPUBLISHED_ENTRY_PUBLISH_REQUEST, UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, UNPUBLISHED_ENTRY_PUBLISH_FAILURE } from '../actions/editorialWorkflow';
7
7
  import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
@@ -23,7 +23,7 @@ function entryDraftReducer(state = Map(), action) {
23
23
  state.set('fieldsMetaData', Map());
24
24
  state.set('fieldsErrors', Map());
25
25
  state.set('hasChanged', false);
26
- state.set('key', uuid());
26
+ state.set('key', crypto.randomUUID());
27
27
  });
28
28
  case DRAFT_CREATE_EMPTY:
29
29
  // New Entry
@@ -33,7 +33,7 @@ function entryDraftReducer(state = Map(), action) {
33
33
  state.set('fieldsMetaData', Map());
34
34
  state.set('fieldsErrors', Map());
35
35
  state.set('hasChanged', false);
36
- state.set('key', uuid());
36
+ state.set('key', crypto.randomUUID());
37
37
  });
38
38
  case DRAFT_CREATE_FROM_LOCAL_BACKUP:
39
39
  // Local Backup
@@ -46,7 +46,7 @@ function entryDraftReducer(state = Map(), action) {
46
46
  state.set('fieldsMetaData', Map());
47
47
  state.set('fieldsErrors', Map());
48
48
  state.set('hasChanged', true);
49
- state.set('key', uuid());
49
+ state.set('key', crypto.randomUUID());
50
50
  });
51
51
  case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
52
52
  // Duplicate Entry
@@ -164,15 +164,47 @@ function entryDraftReducer(state = Map(), action) {
164
164
  return state;
165
165
  }
166
166
  }
167
+ function cleanTitleForFilename(title) {
168
+ if (!title) return 'untitled';
169
+ const cleanedTitle = sanitizeSlug(title.toString().toLowerCase().trim(), {
170
+ sanitize_replacement: '-',
171
+ encoding: 'unicode'
172
+ });
173
+ return cleanedTitle || 'untitled';
174
+ }
167
175
  export function selectCustomPath(collection, entryDraft) {
168
176
  if (!selectHasMetaPath(collection)) {
169
177
  return;
170
178
  }
171
179
  const meta = entryDraft.getIn(['entry', 'meta']);
172
180
  const path = meta && meta.get('path');
173
- const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
181
+ if (!path) {
182
+ return;
183
+ }
174
184
  const extension = selectFolderEntryExtension(collection);
175
- const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`);
185
+ const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
186
+
187
+ // If index_file is specified, use the old behavior for backward compatibility
188
+ if (indexFile) {
189
+ const customPath = join(collection.get('folder'), path, `${indexFile}.${extension}`);
190
+ return customPath;
191
+ }
192
+
193
+ // New behavior: generate filename from entry title
194
+ const isNewEntry = entryDraft.getIn(['entry', 'newRecord']);
195
+ const currentPath = entryDraft.getIn(['entry', 'path']);
196
+ let filename;
197
+ if (isNewEntry || !currentPath) {
198
+ // For new entries, generate filename from title
199
+ const entryData = entryDraft.getIn(['entry', 'data']);
200
+ const title = entryData && entryData.get('title');
201
+ filename = cleanTitleForFilename(title);
202
+ } else {
203
+ // For existing entries, preserve the current filename
204
+ const currentFilename = basename(currentPath, `.${extension}`);
205
+ filename = currentFilename;
206
+ }
207
+ const customPath = join(collection.get('folder'), path, `${filename}.${extension}`);
176
208
  return customPath;
177
209
  }
178
210
  export default entryDraftReducer;
@@ -1,5 +1,4 @@
1
1
  import { Map, List } from 'immutable';
2
- import { v4 as uuid } from 'uuid';
3
2
  import { dirname } from 'path';
4
3
  import { MEDIA_LIBRARY_OPEN, MEDIA_LIBRARY_CLOSE, MEDIA_LIBRARY_CREATE, MEDIA_INSERT, MEDIA_REMOVE_INSERTED, MEDIA_LOAD_REQUEST, MEDIA_LOAD_SUCCESS, MEDIA_LOAD_FAILURE, MEDIA_PERSIST_REQUEST, MEDIA_PERSIST_SUCCESS, MEDIA_PERSIST_FAILURE, MEDIA_DELETE_REQUEST, MEDIA_DELETE_SUCCESS, MEDIA_DELETE_FAILURE, MEDIA_DISPLAY_URL_REQUEST, MEDIA_DISPLAY_URL_SUCCESS, MEDIA_DISPLAY_URL_FAILURE } from '../actions/mediaLibrary';
5
4
  import { selectEditingDraft, selectMediaFolder } from './entries';
@@ -110,7 +109,7 @@ function mediaLibrary(state = Map(defaultState), action) {
110
109
  }
111
110
  const filesWithKeys = files.map(file => ({
112
111
  ...file,
113
- key: uuid()
112
+ key: crypto.randomUUID()
114
113
  }));
115
114
  return state.withMutations(map => {
116
115
  map.set('isLoading', false);
@@ -151,7 +150,7 @@ function mediaLibrary(state = Map(defaultState), action) {
151
150
  return state.withMutations(map => {
152
151
  const fileWithKey = {
153
152
  ...file,
154
- key: uuid()
153
+ key: crypto.randomUUID()
155
154
  };
156
155
  const files = map.get('files');
157
156
  const updatedFiles = [fileWithKey, ...files];
@@ -1,5 +1,4 @@
1
1
  import { produce } from 'immer';
2
- import { v4 as uuid } from 'uuid';
3
2
  import { NOTIFICATION_SEND, NOTIFICATION_DISMISS, NOTIFICATIONS_CLEAR } from '../actions/notifications';
4
3
  const defaultState = {
5
4
  notifications: []
@@ -14,7 +13,7 @@ const notifications = produce((state, action) => {
14
13
  break;
15
14
  case NOTIFICATION_SEND:
16
15
  state.notifications = [...state.notifications, {
17
- id: uuid(),
16
+ id: crypto.randomUUID(),
18
17
  ...action.payload
19
18
  }];
20
19
  break;
package/index.d.ts CHANGED
@@ -56,7 +56,7 @@ declare module 'decap-cms-core' {
56
56
  label?: string;
57
57
  required?: boolean;
58
58
  hint?: string;
59
- pattern?: [string, string];
59
+ pattern?: [string | RegExp, string];
60
60
  i18n?: boolean | 'translate' | 'duplicate' | 'none';
61
61
  media_folder?: string;
62
62
  public_folder?: string;
@@ -170,6 +170,21 @@ declare module 'decap-cms-core' {
170
170
  editorComponents?: string[];
171
171
  }
172
172
 
173
+ export interface CmsFieldRichText {
174
+ widget: 'richtext';
175
+ default?: string;
176
+
177
+ minimal?: boolean;
178
+ buttons?: CmsMarkdownWidgetButton[];
179
+ editor_components?: string[];
180
+ modes?: ('raw' | 'rich_text')[];
181
+
182
+ /**
183
+ * @deprecated Use editor_components instead
184
+ */
185
+ editorComponents?: string[];
186
+ }
187
+
173
188
  export interface CmsFieldNumber {
174
189
  widget: 'number';
175
190
  default?: string | number;
@@ -257,6 +272,7 @@ declare module 'decap-cms-core' {
257
272
  | CmsFieldList
258
273
  | CmsFieldMap
259
274
  | CmsFieldMarkdown
275
+ | CmsFieldRichText
260
276
  | CmsFieldNumber
261
277
  | CmsFieldObject
262
278
  | CmsFieldRelation
@@ -283,7 +299,7 @@ declare module 'decap-cms-core' {
283
299
  export interface ViewFilter {
284
300
  label: string;
285
301
  field: string;
286
- pattern: string;
302
+ pattern: string | boolean;
287
303
  }
288
304
 
289
305
  export interface ViewGroup {
@@ -323,7 +339,7 @@ declare module 'decap-cms-core' {
323
339
  depth: number;
324
340
  subfolders?: boolean;
325
341
  };
326
- meta?: { path?: { label: string; widget: string; index_file: string } };
342
+ meta?: { path?: { label: string; widget: string; index_file?: string } };
327
343
 
328
344
  /**
329
345
  * It accepts the following values: yml, yaml, toml, json, md, markdown, html
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.13.0",
4
+ "version": "3.15.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",
@@ -25,19 +25,19 @@
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
27
  "@iarna/toml": "2.2.5",
28
- "@reduxjs/toolkit": "^1.9.1",
29
28
  "@vercel/stega": "^0.1.2",
29
+ "ajv": "^8.17.1",
30
+ "ajv-errors": "^3.0.0",
31
+ "ajv-keywords": "^5.1.0",
30
32
  "buffer": "^6.0.3",
33
+ "common-tags": "^1.8.0",
31
34
  "dayjs": "^1.11.10",
32
35
  "deepmerge": "^4.2.2",
33
36
  "diacritics": "^1.3.0",
34
37
  "fuzzy": "^0.1.1",
35
- "gotrue-js": "^0.9.24",
36
38
  "gray-matter": "^4.0.2",
37
39
  "history": "^4.7.2",
38
40
  "immer": "^9.0.0",
39
- "js-base64": "^3.0.0",
40
- "jwt-decode": "^3.0.0",
41
41
  "node-polyglot": "^2.3.0",
42
42
  "path-browserify": "^1.0.1",
43
43
  "prop-types": "^15.7.2",
@@ -62,11 +62,9 @@
62
62
  "react-window": "^1.8.5",
63
63
  "redux": "^4.0.5",
64
64
  "redux-devtools-extension": "^2.13.8",
65
- "redux-notifications": "^4.0.1",
66
65
  "redux-thunk": "^2.3.0",
67
66
  "remark-gfm": "1.0.0",
68
67
  "sanitize-filename": "^1.6.1",
69
- "semaphore": "^1.0.5",
70
68
  "tomlify-j0.4": "^3.0.0-alpha.0",
71
69
  "url": "^0.11.0",
72
70
  "url-join": "^4.0.1",
@@ -93,10 +91,11 @@
93
91
  "@types/iarna__toml": "^2.0.5",
94
92
  "@types/redux-mock-store": "^1.0.2",
95
93
  "@types/url-join": "^4.0.0",
94
+ "jest-mock": "^27.5.1",
96
95
  "redux-mock-store": "^1.5.3"
97
96
  },
98
97
  "browser": {
99
98
  "path": "path-browserify"
100
99
  },
101
- "gitHead": "02e3fe3e42ffac8cf7ac50fde1984ef3bd4d788c"
100
+ "gitHead": "567a80101f4846853701ad7d8abdc29b5e4fab56"
102
101
  }
@@ -391,6 +391,68 @@ describe('Backend', () => {
391
391
  expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
392
392
  expect(backend.entryToRaw).toHaveBeenCalledWith(collection, newEntry);
393
393
  });
394
+
395
+ it('should preserve slug when preSave event handler modifies file collection entry', async () => {
396
+ const implementation = {
397
+ init: jest.fn(() => implementation),
398
+ persistEntry: jest.fn(() => implementation),
399
+ };
400
+
401
+ const config = {
402
+ backend: {
403
+ commit_messages: 'commit-messages',
404
+ },
405
+ };
406
+
407
+ // File collection with a single file
408
+ const collection = Map({
409
+ name: 'settings',
410
+ type: FILES,
411
+ files: List([
412
+ Map({
413
+ name: 'config',
414
+ file: 'data/config.json',
415
+ fields: List([Map({ name: 'title', widget: 'string' })]),
416
+ }),
417
+ ]),
418
+ });
419
+
420
+ const originalEntry = Map({
421
+ slug: 'config',
422
+ path: 'data/config.json',
423
+ data: Map({ title: 'original' }),
424
+ meta: Map({ path: 'data/config.json' }),
425
+ });
426
+
427
+ const entryDraft = Map({
428
+ entry: originalEntry,
429
+ });
430
+
431
+ const user = { login: 'login', name: 'name' };
432
+ const backend = new Backend(implementation, { config, backendName: 'github' });
433
+
434
+ backend.currentUser = jest.fn().mockResolvedValue(user);
435
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
436
+
437
+ // Mock invokePreSaveEvent to simulate a preSave handler that modifies data
438
+ // This is what happens when custom widgets or event handlers modify entry data
439
+ // The key is that it returns the FULL entry with slug, not just the data
440
+ backend.invokePreSaveEvent = jest.fn().mockImplementation(async entry => {
441
+ // Simulate a preSave handler modifying the data field
442
+ return entry.setIn(['data', 'title'], 'modified');
443
+ });
444
+
445
+ await backend.persistEntry({ config, collection, entryDraft });
446
+
447
+ // Verify entryToRaw was called with an entry that has the slug
448
+ expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
449
+ const entryPassedToRaw = backend.entryToRaw.mock.calls[0][1];
450
+
451
+ // Critical assertion: slug must be preserved
452
+ expect(entryPassedToRaw.get('slug')).toBe('config');
453
+ expect(entryPassedToRaw.get('path')).toBe('data/config.json');
454
+ expect(entryPassedToRaw.getIn(['data', 'title'])).toBe('modified');
455
+ });
394
456
  });
395
457
 
396
458
  describe('persistMedia', () => {
@@ -1006,4 +1068,156 @@ describe('Backend', () => {
1006
1068
  ]);
1007
1069
  });
1008
1070
  });
1071
+
1072
+ describe('persistEntry with nested collections', () => {
1073
+ it('should pass hasSubfolders=true when subfolders is true (default)', async () => {
1074
+ const implementation = {
1075
+ init: jest.fn(() => implementation),
1076
+ persistEntry: jest.fn(),
1077
+ };
1078
+
1079
+ const config = {
1080
+ backend: { commit_messages: {} },
1081
+ };
1082
+ const collection = Map({
1083
+ name: 'pages',
1084
+ type: FOLDER,
1085
+ folder: '_pages',
1086
+ create: true,
1087
+ fields: List([Map({ name: 'title', widget: 'string' })]),
1088
+ nested: Map({ depth: 10, subfolders: true }),
1089
+ meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
1090
+ });
1091
+ const entryDraft = Map({
1092
+ entry: Map({
1093
+ data: Map({ title: 'Test' }),
1094
+ meta: Map({ path: 'blog' }),
1095
+ newRecord: true,
1096
+ }),
1097
+ });
1098
+ const user = { login: 'user', name: 'User' };
1099
+ const backend = new Backend(implementation, { config, backendName: 'test' });
1100
+
1101
+ backend.currentUser = jest.fn().mockResolvedValue(user);
1102
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
1103
+ backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
1104
+ backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
1105
+ backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
1106
+
1107
+ await backend.persistEntry({
1108
+ config,
1109
+ collection,
1110
+ entryDraft,
1111
+ assetProxies: [],
1112
+ usedSlugs: List(),
1113
+ });
1114
+
1115
+ expect(implementation.persistEntry).toHaveBeenCalledWith(
1116
+ expect.anything(),
1117
+ expect.objectContaining({
1118
+ hasSubfolders: true,
1119
+ }),
1120
+ );
1121
+ });
1122
+
1123
+ it('should pass hasSubfolders=false when subfolders is false', async () => {
1124
+ const implementation = {
1125
+ init: jest.fn(() => implementation),
1126
+ persistEntry: jest.fn(),
1127
+ };
1128
+
1129
+ const config = {
1130
+ backend: { commit_messages: {} },
1131
+ };
1132
+ const collection = Map({
1133
+ name: 'pages',
1134
+ type: FOLDER,
1135
+ folder: '_pages',
1136
+ create: true,
1137
+ fields: List([Map({ name: 'title', widget: 'string' })]),
1138
+ nested: Map({ depth: 10, subfolders: false }),
1139
+ meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
1140
+ });
1141
+ const entryDraft = Map({
1142
+ entry: Map({
1143
+ data: Map({ title: 'Test' }),
1144
+ meta: Map({ path: 'blog' }),
1145
+ newRecord: true,
1146
+ }),
1147
+ });
1148
+ const user = { login: 'user', name: 'User' };
1149
+ const backend = new Backend(implementation, { config, backendName: 'test' });
1150
+
1151
+ backend.currentUser = jest.fn().mockResolvedValue(user);
1152
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
1153
+ backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
1154
+ backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
1155
+ backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
1156
+
1157
+ await backend.persistEntry({
1158
+ config,
1159
+ collection,
1160
+ entryDraft,
1161
+ assetProxies: [],
1162
+ usedSlugs: List(),
1163
+ });
1164
+
1165
+ expect(implementation.persistEntry).toHaveBeenCalledWith(
1166
+ expect.anything(),
1167
+ expect.objectContaining({
1168
+ hasSubfolders: false,
1169
+ }),
1170
+ );
1171
+ });
1172
+
1173
+ it('should default to hasSubfolders=true when subfolders is not specified', async () => {
1174
+ const implementation = {
1175
+ init: jest.fn(() => implementation),
1176
+ persistEntry: jest.fn(),
1177
+ };
1178
+
1179
+ const config = {
1180
+ backend: { commit_messages: {} },
1181
+ };
1182
+ const collection = Map({
1183
+ name: 'pages',
1184
+ type: FOLDER,
1185
+ folder: '_pages',
1186
+ create: true,
1187
+ fields: List([Map({ name: 'title', widget: 'string' })]),
1188
+ nested: Map({ depth: 10 }),
1189
+ meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
1190
+ });
1191
+ const entryDraft = Map({
1192
+ entry: Map({
1193
+ data: Map({ title: 'Test' }),
1194
+ meta: Map({ path: 'blog' }),
1195
+ newRecord: true,
1196
+ }),
1197
+ });
1198
+ const user = { login: 'user', name: 'User' };
1199
+ const backend = new Backend(implementation, { config, backendName: 'test' });
1200
+
1201
+ backend.currentUser = jest.fn().mockResolvedValue(user);
1202
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
1203
+ backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
1204
+ backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
1205
+ backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
1206
+
1207
+ await backend.persistEntry({
1208
+ config,
1209
+ collection,
1210
+ entryDraft,
1211
+ assetProxies: [],
1212
+ usedSlugs: List(),
1213
+ });
1214
+
1215
+ expect(implementation.persistEntry).toHaveBeenCalledWith(
1216
+ expect.anything(),
1217
+ expect.objectContaining({
1218
+ hasSubfolders: true,
1219
+ }),
1220
+ );
1221
+ });
1222
+ });
1009
1223
  });
@@ -862,6 +862,20 @@ describe('config', () => {
862
862
 
863
863
  assetFetchCalled('http://192.168.0.1:8081/api/v1');
864
864
  });
865
+
866
+ it('should return empty object when local_backend url has an unsafe scheme', async () => {
867
+ window.location = { hostname: 'localhost' };
868
+ global.fetch = jest.fn();
869
+ await expect(detectProxyServer({ url: 'file:///etc/passwd' })).resolves.toEqual({});
870
+ expect(global.fetch).not.toHaveBeenCalled();
871
+ });
872
+
873
+ it('should return empty object when local_backend url is not a valid URL', async () => {
874
+ window.location = { hostname: 'localhost' };
875
+ global.fetch = jest.fn();
876
+ await expect(detectProxyServer({ url: 'not a url' })).resolves.toEqual({});
877
+ expect(global.fetch).not.toHaveBeenCalled();
878
+ });
865
879
  });
866
880
 
867
881
  describe('handleLocalBackend', () => {
@@ -8,9 +8,7 @@ import * as actions from '../editorialWorkflow';
8
8
  jest.mock('../../backend');
9
9
  jest.mock('../../valueObjects/AssetProxy');
10
10
  jest.mock('decap-cms-lib-util');
11
- jest.mock('uuid', () => {
12
- return { v4: jest.fn().mockReturnValue('000000000000000000000') };
13
- });
11
+ global.crypto.randomUUID = jest.fn().mockReturnValue('000000000000000000000');
14
12
 
15
13
  const middlewares = [thunk];
16
14
  const mockStore = configureMockStore(middlewares);
@@ -1,4 +1,4 @@
1
- import { fromJS, Map } from 'immutable';
1
+ import { fromJS, List, Map } from 'immutable';
2
2
  import configureMockStore from 'redux-mock-store';
3
3
  import thunk from 'redux-thunk';
4
4
 
@@ -99,6 +99,41 @@ describe('entries', () => {
99
99
  });
100
100
  });
101
101
 
102
+ it('should populate draft entry from repeated URL param', () => {
103
+ const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
104
+
105
+ const collection = fromJS({
106
+ fields: [{ name: 'post', multiple: true }],
107
+ });
108
+
109
+ return store
110
+ .dispatch(createEmptyDraft(collection, '?post=2026-05-07-test&post=2026-05-08-test'))
111
+ .then(() => {
112
+ const actions = store.getActions();
113
+ expect(actions).toHaveLength(1);
114
+
115
+ expect(actions[0]).toEqual({
116
+ payload: {
117
+ author: '',
118
+ collection: undefined,
119
+ data: { post: List(['2026-05-07-test', '2026-05-08-test']) },
120
+ meta: {},
121
+ i18n: {},
122
+ isModification: null,
123
+ label: null,
124
+ mediaFiles: [],
125
+ partial: false,
126
+ path: '',
127
+ raw: '',
128
+ slug: '',
129
+ status: '',
130
+ updatedOn: '',
131
+ },
132
+ type: 'DRAFT_CREATE_EMPTY',
133
+ });
134
+ });
135
+ });
136
+
102
137
  it('should html escape URL params', () => {
103
138
  const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
104
139
 
@@ -308,11 +308,12 @@ export function applyDefaults(originalConfig: CmsConfig) {
308
308
  collection.folder = trim(folder, '/');
309
309
 
310
310
  if (meta && meta.path) {
311
+ const metaPath = meta.path;
311
312
  const metaField = {
312
313
  name: 'path',
313
314
  meta: true,
314
315
  required: true,
315
- ...meta.path,
316
+ ...metaPath,
316
317
  };
317
318
  collection.fields = [metaField, ...(collection.fields || [])];
318
319
  }
@@ -458,6 +459,17 @@ export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend
458
459
  ? defaultUrl
459
460
  : localBackend.url || defaultUrl.replace('localhost', location.hostname);
460
461
 
462
+ try {
463
+ const { protocol } = new URL(proxyUrl);
464
+ if (protocol !== 'http:' && protocol !== 'https:') {
465
+ console.log(`Decap CMS local_backend url must use http or https, ignoring '${proxyUrl}'`);
466
+ return {};
467
+ }
468
+ } catch {
469
+ console.log(`Decap CMS local_backend url '${proxyUrl}' is not a valid URL`);
470
+ return {};
471
+ }
472
+
461
473
  try {
462
474
  console.log(`Looking for Decap CMS Proxy Server at '${proxyUrl}'`);
463
475
  const res = await fetch(`${proxyUrl}`, {