decap-cms-core 3.12.0 → 3.14.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.
- package/dist/decap-cms-core.js +18 -18
- package/dist/decap-cms-core.js.map +1 -1
- package/dist/esm/actions/config.js +14 -1
- package/dist/esm/actions/entries.js +15 -4
- package/dist/esm/backend.js +2 -0
- package/dist/esm/bootstrap.js +2 -2
- package/dist/esm/components/App/App.js +12 -5
- package/dist/esm/components/App/Header.js +18 -18
- package/dist/esm/components/Collection/Entries/EntryCard.js +30 -15
- package/dist/esm/components/Collection/NestedCollection.js +20 -11
- package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +22 -6
- package/dist/esm/components/UI/ErrorBoundary.js +2 -2
- package/dist/esm/components/UI/SettingsDropdown.js +25 -27
- package/dist/esm/constants/configSchema.js +7 -1
- package/dist/esm/lib/formatters.js +6 -4
- package/dist/esm/lib/registry.js +4 -1
- package/dist/esm/lib/urlHelper.js +26 -8
- package/dist/esm/reducers/entryDraft.js +36 -3
- package/index.d.ts +18 -1
- package/package.json +2 -2
- package/src/__tests__/backend.spec.js +214 -0
- package/src/actions/__tests__/config.spec.js +14 -0
- package/src/actions/__tests__/entries.spec.js +36 -1
- package/src/actions/config.ts +13 -1
- package/src/actions/entries.ts +22 -7
- package/src/backend.ts +2 -0
- package/src/components/App/App.js +22 -13
- package/src/components/App/Header.js +36 -11
- package/src/components/Collection/Entries/EntryCard.js +13 -3
- package/src/components/Collection/NestedCollection.js +14 -7
- package/src/components/Collection/__tests__/NestedCollection.spec.js +1 -1
- package/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap +0 -68
- package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -5
- package/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap +104 -72
- package/src/components/UI/SettingsDropdown.js +36 -9
- package/src/constants/__tests__/configSchema.spec.js +9 -6
- package/src/constants/configSchema.js +3 -1
- package/src/lib/__tests__/formatters.spec.js +76 -4
- package/src/lib/__tests__/registry.spec.js +3 -3
- package/src/lib/__tests__/urlHelper.spec.js +7 -0
- package/src/lib/formatters.ts +15 -5
- package/src/lib/registry.js +4 -1
- package/src/lib/urlHelper.ts +33 -9
- package/src/reducers/__tests__/entryDraft.spec.js +117 -0
- package/src/reducers/entryDraft.js +43 -3
- package/src/types/redux.ts +22 -2
|
@@ -295,8 +295,8 @@ describe('formatters', () => {
|
|
|
295
295
|
};
|
|
296
296
|
|
|
297
297
|
describe('slugFormatter', () => {
|
|
298
|
-
const date = new Date('2020-01-
|
|
299
|
-
|
|
298
|
+
const date = new Date('2020-01-01T13:28:27.679Z').valueOf();
|
|
299
|
+
jest.spyOn(Date, 'now').mockImplementation(() => date);
|
|
300
300
|
|
|
301
301
|
const { selectIdentifier } = require('../../reducers/collections');
|
|
302
302
|
|
|
@@ -333,10 +333,22 @@ describe('formatters', () => {
|
|
|
333
333
|
).toBe('entry-slug');
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
+
it('should allow filters in slug templates', () => {
|
|
337
|
+
selectIdentifier.mockReturnValueOnce('published');
|
|
338
|
+
|
|
339
|
+
expect(
|
|
340
|
+
slugFormatter(
|
|
341
|
+
Map({ slug: "{{published | date('MM-DD')}}" }),
|
|
342
|
+
Map({ title: 'Post Title', published: new Date(date) }),
|
|
343
|
+
slugConfig,
|
|
344
|
+
),
|
|
345
|
+
).toBe('01-01');
|
|
346
|
+
});
|
|
347
|
+
|
|
336
348
|
it('should see date filters applied to date from entry if it exists', () => {
|
|
337
349
|
const { selectInferredField } = require('../../reducers/collections');
|
|
338
350
|
selectInferredField.mockReturnValue('date');
|
|
339
|
-
const entryDate = new Date('2026-10-
|
|
351
|
+
const entryDate = new Date('2026-10-20T13:28:27.679Z');
|
|
340
352
|
|
|
341
353
|
expect(
|
|
342
354
|
slugFormatter(
|
|
@@ -350,7 +362,7 @@ describe('formatters', () => {
|
|
|
350
362
|
it('should see date filters applied to publishDate from entry if it exists', () => {
|
|
351
363
|
const { selectInferredField } = require('../../reducers/collections');
|
|
352
364
|
selectInferredField.mockReturnValue('publishDate');
|
|
353
|
-
const entryDate = new Date('2026-10-
|
|
365
|
+
const entryDate = new Date('2026-10-20T13:28:27.679Z');
|
|
354
366
|
|
|
355
367
|
expect(
|
|
356
368
|
slugFormatter(
|
|
@@ -587,6 +599,66 @@ describe('formatters', () => {
|
|
|
587
599
|
'Collection "posts" configuration error:\n `preview_path_date_field` must be a field with a valid date. Ignoring `preview_path`.',
|
|
588
600
|
);
|
|
589
601
|
});
|
|
602
|
+
|
|
603
|
+
it('should preserve slashes in value when configured', () => {
|
|
604
|
+
expect(
|
|
605
|
+
previewUrlFormatter(
|
|
606
|
+
'https://www.example.com',
|
|
607
|
+
Map({
|
|
608
|
+
preview_path: 'prefix/{{value}}',
|
|
609
|
+
preview_path_preserve_slashes: true,
|
|
610
|
+
}),
|
|
611
|
+
'backendSlug',
|
|
612
|
+
Map({ data: Map({ value: 'nested/value' }) }),
|
|
613
|
+
slugConfig,
|
|
614
|
+
),
|
|
615
|
+
).toBe('https://www.example.com/prefix/nested/value');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('should sanitize slashes in value when not configured', () => {
|
|
619
|
+
expect(
|
|
620
|
+
previewUrlFormatter(
|
|
621
|
+
'https://www.example.com',
|
|
622
|
+
Map({
|
|
623
|
+
preview_path: 'prefix/{{value}}',
|
|
624
|
+
}),
|
|
625
|
+
'backendSlug',
|
|
626
|
+
Map({ data: Map({ value: 'nested/value' }) }),
|
|
627
|
+
slugConfig,
|
|
628
|
+
),
|
|
629
|
+
).toBe('https://www.example.com/prefix/nested-value');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('should preserve slashes in value for nested collections by default', () => {
|
|
633
|
+
expect(
|
|
634
|
+
previewUrlFormatter(
|
|
635
|
+
'https://www.example.com',
|
|
636
|
+
Map({
|
|
637
|
+
preview_path: 'prefix/{{value}}',
|
|
638
|
+
nested: { depth: 10 },
|
|
639
|
+
}),
|
|
640
|
+
'backendSlug',
|
|
641
|
+
Map({ data: Map({ value: 'nested/value' }) }),
|
|
642
|
+
slugConfig,
|
|
643
|
+
),
|
|
644
|
+
).toBe('https://www.example.com/prefix/nested/value');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should sanitize slashes in value for nested collections when explicitly disabled', () => {
|
|
648
|
+
expect(
|
|
649
|
+
previewUrlFormatter(
|
|
650
|
+
'https://www.example.com',
|
|
651
|
+
Map({
|
|
652
|
+
preview_path: 'prefix/{{value}}',
|
|
653
|
+
nested: { depth: 10 },
|
|
654
|
+
preview_path_preserve_slashes: false,
|
|
655
|
+
}),
|
|
656
|
+
'backendSlug',
|
|
657
|
+
Map({ data: Map({ value: 'nested/value' }) }),
|
|
658
|
+
slugConfig,
|
|
659
|
+
),
|
|
660
|
+
).toBe('https://www.example.com/prefix/nested-value');
|
|
661
|
+
});
|
|
590
662
|
});
|
|
591
663
|
|
|
592
664
|
describe('summaryFormatter', () => {
|
|
@@ -200,7 +200,7 @@ describe('registry', () => {
|
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
-
it(`should return
|
|
203
|
+
it(`should return the complete updated entry object`, async () => {
|
|
204
204
|
const { registerEventListener, invokeEvent } = require('../registry');
|
|
205
205
|
|
|
206
206
|
const event = 'preSave';
|
|
@@ -233,7 +233,7 @@ describe('registry', () => {
|
|
|
233
233
|
expect(handler1).toHaveBeenCalledWith(data, options);
|
|
234
234
|
expect(handler2).toHaveBeenCalledWith(dataAfterFirstHandlerExecution, options);
|
|
235
235
|
|
|
236
|
-
expect(result).toEqual(dataAfterSecondHandlerExecution.entry
|
|
236
|
+
expect(result).toEqual(dataAfterSecondHandlerExecution.entry);
|
|
237
237
|
});
|
|
238
238
|
|
|
239
239
|
it('should allow multiple events to not return a value', async () => {
|
|
@@ -254,7 +254,7 @@ describe('registry', () => {
|
|
|
254
254
|
|
|
255
255
|
expect(handler1).toHaveBeenCalledWith(data, options);
|
|
256
256
|
expect(handler2).toHaveBeenCalledWith(data, options);
|
|
257
|
-
expect(result).toEqual(data.entry
|
|
257
|
+
expect(result).toEqual(data.entry);
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
260
|
});
|
|
@@ -125,6 +125,13 @@ describe('sanitizeSlug', () => {
|
|
|
125
125
|
'test_test',
|
|
126
126
|
);
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
it('preserves slashes when requested', () => {
|
|
130
|
+
const input = '/this-is-a/nested/page';
|
|
131
|
+
|
|
132
|
+
expect(sanitizeSlug(input, slugConfig, false)).toEqual('this-is-a-nested-page');
|
|
133
|
+
expect(sanitizeSlug(input, slugConfig, true)).toEqual('this-is-a/nested/page');
|
|
134
|
+
});
|
|
128
135
|
});
|
|
129
136
|
|
|
130
137
|
describe('sanitizeChar', () => {
|
package/src/lib/formatters.ts
CHANGED
|
@@ -120,11 +120,19 @@ export function prepareSlug(slug: string) {
|
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
export function getProcessSegment(
|
|
123
|
+
export function getProcessSegment(
|
|
124
|
+
slugConfig?: CmsSlug,
|
|
125
|
+
ignoreValues?: string[],
|
|
126
|
+
preserveSlashes?: boolean,
|
|
127
|
+
) {
|
|
124
128
|
return (value: string) =>
|
|
125
129
|
ignoreValues && ignoreValues.includes(value)
|
|
126
130
|
? value
|
|
127
|
-
: flow([
|
|
131
|
+
: flow([
|
|
132
|
+
value => String(value),
|
|
133
|
+
prepareSlug,
|
|
134
|
+
partialRight(sanitizeSlug, slugConfig, preserveSlashes),
|
|
135
|
+
])(value);
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
export function slugFormatter(
|
|
@@ -205,19 +213,21 @@ export function previewUrlFormatter(
|
|
|
205
213
|
fields = addFileTemplateFields(entry.get('path'), fields, collection.get('folder'));
|
|
206
214
|
const dateFieldName = getDateField() || selectInferredField(collection, 'date');
|
|
207
215
|
const date = parseDateFromEntry(entry as unknown as Map<string, unknown>, dateFieldName);
|
|
216
|
+
const previewPathPreserveSlashes = collection.get('preview_path_preserve_slashes');
|
|
217
|
+
const preserveSlashes = !!(previewPathPreserveSlashes ?? collection.has('nested'));
|
|
208
218
|
|
|
209
219
|
// Prepare and sanitize slug variables only, leave the rest of the
|
|
210
220
|
// `preview_path` template as is.
|
|
211
|
-
const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')]);
|
|
221
|
+
const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')], preserveSlashes);
|
|
212
222
|
let compiledPath;
|
|
213
223
|
|
|
214
224
|
try {
|
|
215
225
|
compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
|
|
216
|
-
} catch (err) {
|
|
226
|
+
} catch (err: unknown) {
|
|
217
227
|
// Print an error and ignore `preview_path` if both:
|
|
218
228
|
// 1. Date is invalid (according to DayJs), and
|
|
219
229
|
// 2. A date expression (eg. `{{year}}`) is used in `preview_path`
|
|
220
|
-
if (err.name === SLUG_MISSING_REQUIRED_DATE) {
|
|
230
|
+
if (err instanceof Error && err.name === SLUG_MISSING_REQUIRED_DATE) {
|
|
221
231
|
console.error(stripIndent`
|
|
222
232
|
Collection "${collection.get('name')}" configuration error:
|
|
223
233
|
\`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
|
package/src/lib/registry.js
CHANGED
|
@@ -257,7 +257,10 @@ export async function invokeEvent({ name, data }) {
|
|
|
257
257
|
_data = { ...data, entry };
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
-
|
|
260
|
+
// Return the full entry object with all metadata (slug, path, meta, etc.)
|
|
261
|
+
// rather than just the data payload. Callers like invokePreSaveEvent expect
|
|
262
|
+
// the complete entry object to be preserved through the event handler chain.
|
|
263
|
+
return _data.entry;
|
|
261
264
|
}
|
|
262
265
|
|
|
263
266
|
export function removeEventListener({ name, handler }) {
|
package/src/lib/urlHelper.ts
CHANGED
|
@@ -51,7 +51,14 @@ function validIRIChar(char: string) {
|
|
|
51
51
|
return uriChars.test(char) || ucsChars.test(char);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function getCharReplacer(
|
|
54
|
+
export function getCharReplacer(
|
|
55
|
+
encoding: string,
|
|
56
|
+
options: {
|
|
57
|
+
replacement: NonNullable<CmsSlug['sanitize_replacement']>;
|
|
58
|
+
preserveSlashes?: boolean;
|
|
59
|
+
},
|
|
60
|
+
) {
|
|
61
|
+
const { replacement, preserveSlashes } = options;
|
|
55
62
|
let validChar: (char: string) => boolean;
|
|
56
63
|
|
|
57
64
|
if (encoding === 'unicode') {
|
|
@@ -67,14 +74,24 @@ export function getCharReplacer(encoding: string, replacement: string) {
|
|
|
67
74
|
throw new Error('The replacement character(s) (options.replacement) is itself unsafe.');
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
return (char: string
|
|
77
|
+
return (char: string, i = 0, arr: string[] = [char]) => {
|
|
78
|
+
if (preserveSlashes && char === '/' && i !== 0 && i !== arr.length - 1) {
|
|
79
|
+
return char;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return validChar(char) ? char : replacement;
|
|
83
|
+
};
|
|
71
84
|
}
|
|
72
85
|
// `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed.
|
|
73
86
|
export function sanitizeURI(
|
|
74
87
|
str: string,
|
|
75
|
-
options?: {
|
|
88
|
+
options?: {
|
|
89
|
+
replacement: CmsSlug['sanitize_replacement'];
|
|
90
|
+
encoding: CmsSlug['encoding'];
|
|
91
|
+
preserveSlashes?: boolean;
|
|
92
|
+
},
|
|
76
93
|
) {
|
|
77
|
-
const { replacement = '', encoding = 'unicode' } = options || {};
|
|
94
|
+
const { replacement = '', encoding = 'unicode', preserveSlashes } = options || {};
|
|
78
95
|
|
|
79
96
|
if (!isString(str)) {
|
|
80
97
|
throw new Error('The input slug must be a string.');
|
|
@@ -85,15 +102,15 @@ export function sanitizeURI(
|
|
|
85
102
|
|
|
86
103
|
// `Array.from` must be used instead of `String.split` because
|
|
87
104
|
// `split` converts things like emojis into UTF-16 surrogate pairs.
|
|
88
|
-
return Array.from(str).map(getCharReplacer(encoding, replacement)).join('');
|
|
105
|
+
return Array.from(str).map(getCharReplacer(encoding, { replacement, preserveSlashes })).join('');
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
export function sanitizeChar(char: string, options?: CmsSlug) {
|
|
92
109
|
const { encoding = 'unicode', sanitize_replacement: replacement = '' } = options || {};
|
|
93
|
-
return getCharReplacer(encoding, replacement)(char);
|
|
110
|
+
return getCharReplacer(encoding, { replacement })(char);
|
|
94
111
|
}
|
|
95
112
|
|
|
96
|
-
export function sanitizeSlug(str: string, options?: CmsSlug) {
|
|
113
|
+
export function sanitizeSlug(str: string, options?: CmsSlug, preserveSlashes?: boolean) {
|
|
97
114
|
if (!isString(str)) {
|
|
98
115
|
throw new Error('The input slug must be a string.');
|
|
99
116
|
}
|
|
@@ -106,8 +123,15 @@ export function sanitizeSlug(str: string, options?: CmsSlug) {
|
|
|
106
123
|
|
|
107
124
|
const sanitizedSlug = flow([
|
|
108
125
|
...(stripDiacritics ? [diacritics.remove] : []),
|
|
109
|
-
partialRight(sanitizeURI, { replacement, encoding }),
|
|
110
|
-
|
|
126
|
+
partialRight(sanitizeURI, { replacement, encoding, preserveSlashes }),
|
|
127
|
+
preserveSlashes
|
|
128
|
+
? (slug: string) =>
|
|
129
|
+
slug
|
|
130
|
+
.split('/')
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.map(part => sanitizeFilename(part, { replacement }))
|
|
133
|
+
.join('/')
|
|
134
|
+
: partialRight(sanitizeFilename, { replacement }),
|
|
111
135
|
])(str);
|
|
112
136
|
|
|
113
137
|
// Remove any doubled or leading/trailing replacement characters (that were added in the sanitizers).
|
|
@@ -195,4 +195,121 @@ describe('entryDraft reducer', () => {
|
|
|
195
195
|
});
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
describe('selectCustomPath', () => {
|
|
200
|
+
let selectCustomPath;
|
|
201
|
+
let selectHasMetaPath;
|
|
202
|
+
let selectFolderEntryExtension;
|
|
203
|
+
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
jest.resetModules();
|
|
206
|
+
selectHasMetaPath = jest.fn(
|
|
207
|
+
collection => collection.has('meta') && collection.get('meta').has('path'),
|
|
208
|
+
);
|
|
209
|
+
selectFolderEntryExtension = jest.fn(collection => collection.get('extension') || 'md');
|
|
210
|
+
|
|
211
|
+
jest.doMock('../collections', () => ({
|
|
212
|
+
selectHasMetaPath,
|
|
213
|
+
selectFolderEntryExtension,
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
const entryDraftModule = require('../entryDraft');
|
|
217
|
+
selectCustomPath = entryDraftModule.selectCustomPath;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
jest.unmock('../collections');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should generate dynamic filename for new entries without index_file', () => {
|
|
225
|
+
const collection = fromJS({
|
|
226
|
+
folder: '_pages',
|
|
227
|
+
extension: 'md',
|
|
228
|
+
meta: { path: { label: 'Path', widget: 'string' } },
|
|
229
|
+
});
|
|
230
|
+
const entryDraft = fromJS({
|
|
231
|
+
entry: {
|
|
232
|
+
newRecord: true,
|
|
233
|
+
data: { title: 'My Great Article' },
|
|
234
|
+
meta: { path: 'blog' },
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const result = selectCustomPath(collection, entryDraft);
|
|
239
|
+
expect(result).toBe('_pages/blog/my-great-article.md');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should preserve filename for existing entries without index_file', () => {
|
|
243
|
+
const collection = fromJS({
|
|
244
|
+
folder: '_pages',
|
|
245
|
+
extension: 'md',
|
|
246
|
+
meta: { path: { label: 'Path', widget: 'string' } },
|
|
247
|
+
});
|
|
248
|
+
const entryDraft = fromJS({
|
|
249
|
+
entry: {
|
|
250
|
+
newRecord: false,
|
|
251
|
+
path: '_pages/old-folder/existing-file.md',
|
|
252
|
+
data: { title: 'Updated Title' },
|
|
253
|
+
meta: { path: 'new-folder' },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const result = selectCustomPath(collection, entryDraft);
|
|
258
|
+
expect(result).toBe('_pages/new-folder/existing-file.md');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should use index_file when specified (backward compatibility)', () => {
|
|
262
|
+
const collection = fromJS({
|
|
263
|
+
folder: '_pages',
|
|
264
|
+
extension: 'md',
|
|
265
|
+
meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } },
|
|
266
|
+
});
|
|
267
|
+
const entryDraft = fromJS({
|
|
268
|
+
entry: {
|
|
269
|
+
newRecord: true,
|
|
270
|
+
data: { title: 'My Article' },
|
|
271
|
+
meta: { path: 'blog' },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const result = selectCustomPath(collection, entryDraft);
|
|
276
|
+
expect(result).toBe('_pages/blog/index.md');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should return undefined when path is not set', () => {
|
|
280
|
+
const collection = fromJS({
|
|
281
|
+
folder: '_pages',
|
|
282
|
+
extension: 'md',
|
|
283
|
+
meta: { path: { label: 'Path', widget: 'string' } },
|
|
284
|
+
});
|
|
285
|
+
const entryDraft = fromJS({
|
|
286
|
+
entry: {
|
|
287
|
+
newRecord: true,
|
|
288
|
+
data: { title: 'My Article' },
|
|
289
|
+
meta: {},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const result = selectCustomPath(collection, entryDraft);
|
|
294
|
+
expect(result).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should preserve non-latin characters in generated filename', () => {
|
|
298
|
+
const collection = fromJS({
|
|
299
|
+
folder: '_pages',
|
|
300
|
+
extension: 'md',
|
|
301
|
+
meta: { path: { label: 'Path', widget: 'string' } },
|
|
302
|
+
});
|
|
303
|
+
const entryDraft = fromJS({
|
|
304
|
+
entry: {
|
|
305
|
+
newRecord: true,
|
|
306
|
+
data: { title: '日本語のタイトル' },
|
|
307
|
+
meta: { path: 'blog' },
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const result = selectCustomPath(collection, entryDraft);
|
|
312
|
+
expect(result).toBe('_pages/blog/日本語のタイトル.md');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
198
315
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Map, List, fromJS } from 'immutable';
|
|
2
2
|
import { v4 as uuid } from 'uuid';
|
|
3
3
|
import get from 'lodash/get';
|
|
4
|
-
import { join } from 'path';
|
|
4
|
+
import { join, basename } from 'path';
|
|
5
5
|
|
|
6
|
+
import { sanitizeSlug } from '../lib/urlHelper';
|
|
6
7
|
import {
|
|
7
8
|
DRAFT_CREATE_FROM_ENTRY,
|
|
8
9
|
DRAFT_CREATE_EMPTY,
|
|
@@ -204,15 +205,54 @@ function entryDraftReducer(state = Map(), action) {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
function cleanTitleForFilename(title) {
|
|
209
|
+
if (!title) return 'untitled';
|
|
210
|
+
|
|
211
|
+
const cleanedTitle = sanitizeSlug(title.toString().toLowerCase().trim(), {
|
|
212
|
+
sanitize_replacement: '-',
|
|
213
|
+
encoding: 'unicode',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return cleanedTitle || 'untitled';
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
export function selectCustomPath(collection, entryDraft) {
|
|
208
220
|
if (!selectHasMetaPath(collection)) {
|
|
209
221
|
return;
|
|
210
222
|
}
|
|
211
223
|
const meta = entryDraft.getIn(['entry', 'meta']);
|
|
212
224
|
const path = meta && meta.get('path');
|
|
213
|
-
|
|
225
|
+
|
|
226
|
+
if (!path) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
214
230
|
const extension = selectFolderEntryExtension(collection);
|
|
215
|
-
const
|
|
231
|
+
const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
|
|
232
|
+
|
|
233
|
+
// If index_file is specified, use the old behavior for backward compatibility
|
|
234
|
+
if (indexFile) {
|
|
235
|
+
const customPath = join(collection.get('folder'), path, `${indexFile}.${extension}`);
|
|
236
|
+
return customPath;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// New behavior: generate filename from entry title
|
|
240
|
+
const isNewEntry = entryDraft.getIn(['entry', 'newRecord']);
|
|
241
|
+
const currentPath = entryDraft.getIn(['entry', 'path']);
|
|
242
|
+
|
|
243
|
+
let filename;
|
|
244
|
+
if (isNewEntry || !currentPath) {
|
|
245
|
+
// For new entries, generate filename from title
|
|
246
|
+
const entryData = entryDraft.getIn(['entry', 'data']);
|
|
247
|
+
const title = entryData && entryData.get('title');
|
|
248
|
+
filename = cleanTitleForFilename(title);
|
|
249
|
+
} else {
|
|
250
|
+
// For existing entries, preserve the current filename
|
|
251
|
+
const currentFilename = basename(currentPath, `.${extension}`);
|
|
252
|
+
filename = currentFilename;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const customPath = join(collection.get('folder'), path, `${filename}.${extension}`);
|
|
216
256
|
return customPath;
|
|
217
257
|
}
|
|
218
258
|
|
package/src/types/redux.ts
CHANGED
|
@@ -185,6 +185,21 @@ export interface CmsFieldMarkdown {
|
|
|
185
185
|
editorComponents?: string[];
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
export interface CmsFieldRichText {
|
|
189
|
+
widget: 'richtext';
|
|
190
|
+
default?: string;
|
|
191
|
+
|
|
192
|
+
minimal?: boolean;
|
|
193
|
+
buttons?: CmsMarkdownWidgetButton[];
|
|
194
|
+
editor_components?: string[];
|
|
195
|
+
modes?: ('raw' | 'rich_text')[];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @deprecated Use editor_components instead
|
|
199
|
+
*/
|
|
200
|
+
editorComponents?: string[];
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
export interface CmsFieldNumber {
|
|
189
204
|
widget: 'number';
|
|
190
205
|
default?: string | number;
|
|
@@ -258,7 +273,7 @@ export interface CmsFieldMeta {
|
|
|
258
273
|
label: string;
|
|
259
274
|
widget: string;
|
|
260
275
|
required: boolean;
|
|
261
|
-
index_file
|
|
276
|
+
index_file?: string;
|
|
262
277
|
meta: boolean;
|
|
263
278
|
}
|
|
264
279
|
|
|
@@ -272,6 +287,7 @@ export type CmsField = CmsFieldBase &
|
|
|
272
287
|
| CmsFieldList
|
|
273
288
|
| CmsFieldMap
|
|
274
289
|
| CmsFieldMarkdown
|
|
290
|
+
| CmsFieldRichText
|
|
275
291
|
| CmsFieldNumber
|
|
276
292
|
| CmsFieldObject
|
|
277
293
|
| CmsFieldRelation
|
|
@@ -290,6 +306,7 @@ export interface CmsCollectionFile {
|
|
|
290
306
|
description?: string;
|
|
291
307
|
preview_path?: string;
|
|
292
308
|
preview_path_date_field?: string;
|
|
309
|
+
preview_path_preserve_slashes?: boolean;
|
|
293
310
|
i18n?: boolean | CmsI18nConfig;
|
|
294
311
|
media_folder?: string;
|
|
295
312
|
public_folder?: string;
|
|
@@ -327,6 +344,7 @@ export interface CmsCollection {
|
|
|
327
344
|
slug?: string;
|
|
328
345
|
preview_path?: string;
|
|
329
346
|
preview_path_date_field?: string;
|
|
347
|
+
preview_path_preserve_slashes?: boolean;
|
|
330
348
|
create?: boolean;
|
|
331
349
|
delete?: boolean;
|
|
332
350
|
editor?: {
|
|
@@ -338,7 +356,7 @@ export interface CmsCollection {
|
|
|
338
356
|
depth: number;
|
|
339
357
|
};
|
|
340
358
|
type: typeof FOLDER | typeof FILES;
|
|
341
|
-
meta?: { path?: { label: string; widget: string; index_file
|
|
359
|
+
meta?: { path?: { label: string; widget: string; index_file?: string } };
|
|
342
360
|
|
|
343
361
|
/**
|
|
344
362
|
* It accepts the following values: yml, yaml, toml, json, md, markdown, html
|
|
@@ -578,6 +596,7 @@ export type EntryField = StaticallyTypedRecord<{
|
|
|
578
596
|
name: string;
|
|
579
597
|
default: string | null | boolean | List<unknown>;
|
|
580
598
|
media_folder?: string;
|
|
599
|
+
multiple?: boolean;
|
|
581
600
|
public_folder?: string;
|
|
582
601
|
comment?: string;
|
|
583
602
|
meta?: boolean;
|
|
@@ -634,6 +653,7 @@ type CollectionObject = {
|
|
|
634
653
|
public_folder?: string;
|
|
635
654
|
preview_path?: string;
|
|
636
655
|
preview_path_date_field?: string;
|
|
656
|
+
preview_path_preserve_slashes?: boolean;
|
|
637
657
|
summary?: string;
|
|
638
658
|
filter?: FilterRule;
|
|
639
659
|
type: 'file_based_collection' | 'folder_based_collection';
|