decap-cms-core 3.12.0 → 3.13.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.
@@ -49,8 +49,8 @@ function bootstrap(opts = {}) {
49
49
  /**
50
50
  * Log the version number.
51
51
  */
52
- if (typeof "3.12.0" === 'string') {
53
- console.log(`decap-cms-core ${"3.12.0"}`);
52
+ if (typeof "3.13.0" === 'string') {
53
+ console.log(`decap-cms-core ${"3.13.0"}`);
54
54
  }
55
55
 
56
56
  /**
@@ -42,8 +42,8 @@ function buildIssueTemplate({
42
42
  let version = '';
43
43
  if (typeof DECAP_CMS_VERSION === 'string') {
44
44
  version = `decap-cms@${DECAP_CMS_VERSION}`;
45
- } else if (typeof "3.12.0" === 'string') {
46
- version = `decap-cms-app@${"3.12.0"}`;
45
+ } else if (typeof "3.12.2" === 'string') {
46
+ version = `decap-cms-app@${"3.12.2"}`;
47
47
  }
48
48
  const template = getIssueTemplate({
49
49
  version,
@@ -339,6 +339,9 @@ function getConfigSchema() {
339
339
  preview_path_date_field: {
340
340
  type: 'string'
341
341
  },
342
+ preview_path_preserve_slashes: {
343
+ type: 'boolean'
344
+ },
342
345
  fields: fieldsConfig()
343
346
  },
344
347
  required: ['name', 'label', 'file', 'fields']
@@ -363,6 +366,9 @@ function getConfigSchema() {
363
366
  preview_path_date_field: {
364
367
  type: 'string'
365
368
  },
369
+ preview_path_preserve_slashes: {
370
+ type: 'boolean'
371
+ },
366
372
  create: {
367
373
  type: 'boolean'
368
374
  },
@@ -93,8 +93,8 @@ export function prepareSlug(slug) {
93
93
  // Replace periods with dashes.
94
94
  .replace(/[.]/g, '-');
95
95
  }
96
- export function getProcessSegment(slugConfig, ignoreValues) {
97
- return value => ignoreValues && ignoreValues.includes(value) ? value : flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)])(value);
96
+ export function getProcessSegment(slugConfig, ignoreValues, preserveSlashes) {
97
+ return value => ignoreValues && ignoreValues.includes(value) ? value : flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig, preserveSlashes)])(value);
98
98
  }
99
99
  export function slugFormatter(collection, entryData, slugConfig) {
100
100
  const slugTemplate = collection.get('slug') || '{{slug}}';
@@ -147,10 +147,12 @@ export function previewUrlFormatter(baseUrl, collection, slug, entry, slugConfig
147
147
  fields = addFileTemplateFields(entry.get('path'), fields, collection.get('folder'));
148
148
  const dateFieldName = getDateField() || selectInferredField(collection, 'date');
149
149
  const date = parseDateFromEntry(entry, dateFieldName);
150
+ const previewPathPreserveSlashes = collection.get('preview_path_preserve_slashes');
151
+ const preserveSlashes = !!(previewPathPreserveSlashes ?? collection.has('nested'));
150
152
 
151
153
  // Prepare and sanitize slug variables only, leave the rest of the
152
154
  // `preview_path` template as is.
153
- const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')]);
155
+ const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')], preserveSlashes);
154
156
  let compiledPath;
155
157
  try {
156
158
  compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
@@ -158,7 +160,7 @@ export function previewUrlFormatter(baseUrl, collection, slug, entry, slugConfig
158
160
  // Print an error and ignore `preview_path` if both:
159
161
  // 1. Date is invalid (according to DayJs), and
160
162
  // 2. A date expression (eg. `{{year}}`) is used in `preview_path`
161
- if (err.name === SLUG_MISSING_REQUIRED_DATE) {
163
+ if (err instanceof Error && err.name === SLUG_MISSING_REQUIRED_DATE) {
162
164
  console.error(stripIndent`
163
165
  Collection "${collection.get('name')}" configuration error:
164
166
  \`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
@@ -43,7 +43,11 @@ function validURIChar(char) {
43
43
  function validIRIChar(char) {
44
44
  return uriChars.test(char) || ucsChars.test(char);
45
45
  }
46
- export function getCharReplacer(encoding, replacement) {
46
+ export function getCharReplacer(encoding, options) {
47
+ const {
48
+ replacement,
49
+ preserveSlashes
50
+ } = options;
47
51
  let validChar;
48
52
  if (encoding === 'unicode') {
49
53
  validChar = validIRIChar;
@@ -57,13 +61,19 @@ export function getCharReplacer(encoding, replacement) {
57
61
  if (!Array.from(replacement).every(validChar)) {
58
62
  throw new Error('The replacement character(s) (options.replacement) is itself unsafe.');
59
63
  }
60
- return char => validChar(char) ? char : replacement;
64
+ return (char, i = 0, arr = [char]) => {
65
+ if (preserveSlashes && char === '/' && i !== 0 && i !== arr.length - 1) {
66
+ return char;
67
+ }
68
+ return validChar(char) ? char : replacement;
69
+ };
61
70
  }
62
71
  // `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.
63
72
  export function sanitizeURI(str, options) {
64
73
  const {
65
74
  replacement = '',
66
- encoding = 'unicode'
75
+ encoding = 'unicode',
76
+ preserveSlashes
67
77
  } = options || {};
68
78
  if (!isString(str)) {
69
79
  throw new Error('The input slug must be a string.');
@@ -74,16 +84,21 @@ export function sanitizeURI(str, options) {
74
84
 
75
85
  // `Array.from` must be used instead of `String.split` because
76
86
  // `split` converts things like emojis into UTF-16 surrogate pairs.
77
- return Array.from(str).map(getCharReplacer(encoding, replacement)).join('');
87
+ return Array.from(str).map(getCharReplacer(encoding, {
88
+ replacement,
89
+ preserveSlashes
90
+ })).join('');
78
91
  }
79
92
  export function sanitizeChar(char, options) {
80
93
  const {
81
94
  encoding = 'unicode',
82
95
  sanitize_replacement: replacement = ''
83
96
  } = options || {};
84
- return getCharReplacer(encoding, replacement)(char);
97
+ return getCharReplacer(encoding, {
98
+ replacement
99
+ })(char);
85
100
  }
86
- export function sanitizeSlug(str, options) {
101
+ export function sanitizeSlug(str, options, preserveSlashes) {
87
102
  if (!isString(str)) {
88
103
  throw new Error('The input slug must be a string.');
89
104
  }
@@ -94,8 +109,11 @@ export function sanitizeSlug(str, options) {
94
109
  } = options || {};
95
110
  const sanitizedSlug = flow([...(stripDiacritics ? [diacritics.remove] : []), partialRight(sanitizeURI, {
96
111
  replacement,
97
- encoding
98
- }), partialRight(sanitizeFilename, {
112
+ encoding,
113
+ preserveSlashes
114
+ }), preserveSlashes ? slug => slug.split('/').filter(Boolean).map(part => sanitizeFilename(part, {
115
+ replacement
116
+ })).join('/') : partialRight(sanitizeFilename, {
99
117
  replacement
100
118
  })])(str);
101
119
 
package/index.d.ts CHANGED
@@ -310,6 +310,7 @@ declare module 'decap-cms-core' {
310
310
  slug?: string;
311
311
  preview_path?: string;
312
312
  preview_path_date_field?: string;
313
+ preview_path_preserve_slashes?: boolean;
313
314
  create?: boolean;
314
315
  delete?: boolean;
315
316
  hide?: boolean;
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.12.0",
4
+ "version": "3.13.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",
@@ -98,5 +98,5 @@
98
98
  "browser": {
99
99
  "path": "path-browserify"
100
100
  },
101
- "gitHead": "45c9f5b9a1a12f74321ce4658b71ec88d6365ec1"
101
+ "gitHead": "02e3fe3e42ffac8cf7ac50fde1984ef3bd4d788c"
102
102
  }
@@ -224,6 +224,7 @@ function getConfigSchema() {
224
224
  file: { type: 'string' },
225
225
  preview_path: { type: 'string' },
226
226
  preview_path_date_field: { type: 'string' },
227
+ preview_path_preserve_slashes: { type: 'boolean' },
227
228
  fields: fieldsConfig(),
228
229
  },
229
230
  required: ['name', 'label', 'file', 'fields'],
@@ -236,6 +237,7 @@ function getConfigSchema() {
236
237
  path: { type: 'string' },
237
238
  preview_path: { type: 'string' },
238
239
  preview_path_date_field: { type: 'string' },
240
+ preview_path_preserve_slashes: { type: 'boolean' },
239
241
  create: { type: 'boolean' },
240
242
  publish: { type: 'boolean' },
241
243
  hide: { type: 'boolean' },
@@ -587,6 +587,66 @@ describe('formatters', () => {
587
587
  'Collection "posts" configuration error:\n `preview_path_date_field` must be a field with a valid date. Ignoring `preview_path`.',
588
588
  );
589
589
  });
590
+
591
+ it('should preserve slashes in value when configured', () => {
592
+ expect(
593
+ previewUrlFormatter(
594
+ 'https://www.example.com',
595
+ Map({
596
+ preview_path: 'prefix/{{value}}',
597
+ preview_path_preserve_slashes: true,
598
+ }),
599
+ 'backendSlug',
600
+ Map({ data: Map({ value: 'nested/value' }) }),
601
+ slugConfig,
602
+ ),
603
+ ).toBe('https://www.example.com/prefix/nested/value');
604
+ });
605
+
606
+ it('should sanitize slashes in value when not configured', () => {
607
+ expect(
608
+ previewUrlFormatter(
609
+ 'https://www.example.com',
610
+ Map({
611
+ preview_path: 'prefix/{{value}}',
612
+ }),
613
+ 'backendSlug',
614
+ Map({ data: Map({ value: 'nested/value' }) }),
615
+ slugConfig,
616
+ ),
617
+ ).toBe('https://www.example.com/prefix/nested-value');
618
+ });
619
+
620
+ it('should preserve slashes in value for nested collections by default', () => {
621
+ expect(
622
+ previewUrlFormatter(
623
+ 'https://www.example.com',
624
+ Map({
625
+ preview_path: 'prefix/{{value}}',
626
+ nested: { depth: 10 },
627
+ }),
628
+ 'backendSlug',
629
+ Map({ data: Map({ value: 'nested/value' }) }),
630
+ slugConfig,
631
+ ),
632
+ ).toBe('https://www.example.com/prefix/nested/value');
633
+ });
634
+
635
+ it('should sanitize slashes in value for nested collections when explicitly disabled', () => {
636
+ expect(
637
+ previewUrlFormatter(
638
+ 'https://www.example.com',
639
+ Map({
640
+ preview_path: 'prefix/{{value}}',
641
+ nested: { depth: 10 },
642
+ preview_path_preserve_slashes: false,
643
+ }),
644
+ 'backendSlug',
645
+ Map({ data: Map({ value: 'nested/value' }) }),
646
+ slugConfig,
647
+ ),
648
+ ).toBe('https://www.example.com/prefix/nested-value');
649
+ });
590
650
  });
591
651
 
592
652
  describe('summaryFormatter', () => {
@@ -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', () => {
@@ -120,11 +120,19 @@ export function prepareSlug(slug: string) {
120
120
  );
121
121
  }
122
122
 
123
- export function getProcessSegment(slugConfig?: CmsSlug, ignoreValues?: string[]) {
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([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)])(value);
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\`.
@@ -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(encoding: string, replacement: string) {
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) => (validChar(char) ? char : replacement);
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?: { replacement: CmsSlug['sanitize_replacement']; encoding: CmsSlug['encoding'] },
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
- partialRight(sanitizeFilename, { replacement }),
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).
@@ -290,6 +290,7 @@ export interface CmsCollectionFile {
290
290
  description?: string;
291
291
  preview_path?: string;
292
292
  preview_path_date_field?: string;
293
+ preview_path_preserve_slashes?: boolean;
293
294
  i18n?: boolean | CmsI18nConfig;
294
295
  media_folder?: string;
295
296
  public_folder?: string;
@@ -327,6 +328,7 @@ export interface CmsCollection {
327
328
  slug?: string;
328
329
  preview_path?: string;
329
330
  preview_path_date_field?: string;
331
+ preview_path_preserve_slashes?: boolean;
330
332
  create?: boolean;
331
333
  delete?: boolean;
332
334
  editor?: {
@@ -634,6 +636,7 @@ type CollectionObject = {
634
636
  public_folder?: string;
635
637
  preview_path?: string;
636
638
  preview_path_date_field?: string;
639
+ preview_path_preserve_slashes?: boolean;
637
640
  summary?: string;
638
641
  filter?: FilterRule;
639
642
  type: 'file_based_collection' | 'folder_based_collection';