decap-cms-core 3.11.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.
@@ -255,15 +255,20 @@ export async function getI18nEntry(collection, extension, path, slug, getEntryVa
255
255
  if (structure === I18N_STRUCTURE.SINGLE_FILE) {
256
256
  entryValue = mergeSingleFileValue(await getEntryValue(path), defaultLocale, locales);
257
257
  } else {
258
- const entryValues = await Promise.all(locales.map(async locale => {
258
+ const entryValuesResults = await Promise.allSettled(locales.map(async locale => {
259
259
  const entryPath = getFilePath(structure, extension, path, slug, locale);
260
- const value = await getEntryValue(entryPath).catch(() => null);
260
+ const value = await getEntryValue(entryPath);
261
261
  return {
262
262
  value,
263
263
  locale
264
264
  };
265
265
  }));
266
- const nonNullValues = entryValues.filter(e => e.value !== null);
266
+ const nonNullValues = entryValuesResults.map(e => e.status === 'fulfilled' ? e.value : undefined).filter(e => e !== undefined);
267
+ if (nonNullValues.length === 0) {
268
+ // mergeValues will throw on an empty list, and show the error messages.
269
+ const [error = new Error('No entry values found for any locale')] = entryValuesResults.map(e => e.status === 'rejected' ? e.reason : undefined).filter(e => e !== undefined);
270
+ throw error;
271
+ }
267
272
  entryValue = mergeValues(collection, structure, defaultLocale, nonNullValues);
268
273
  }
269
274
  return entryValue;
@@ -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;
@@ -364,6 +365,7 @@ declare module 'decap-cms-core' {
364
365
  auth_type?: 'implicit' | 'pkce';
365
366
  cms_label_prefix?: string;
366
367
  squash_merges?: boolean;
368
+ signoff_commits?: boolean;
367
369
  proxy_url?: string;
368
370
  commit_messages?: {
369
371
  create?: string;
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.11.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",
@@ -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
- "copy-text-to-clipboard": "^3.0.0",
32
31
  "dayjs": "^1.11.10",
33
32
  "deepmerge": "^4.2.2",
34
33
  "diacritics": "^1.3.0",
@@ -99,5 +98,5 @@
99
98
  "browser": {
100
99
  "path": "path-browserify"
101
100
  },
102
- "gitHead": "af84ddd0532948c38a9da26026404518a59d0903"
101
+ "gitHead": "02e3fe3e42ffac8cf7ac50fde1984ef3bd4d788c"
103
102
  }
package/src/backend.ts CHANGED
@@ -1178,6 +1178,7 @@ export class Backend {
1178
1178
  path,
1179
1179
  authorLogin: user.login,
1180
1180
  authorName: user.name,
1181
+ authorEmail: user.email,
1181
1182
  },
1182
1183
  user.useOpenAuthoring,
1183
1184
  );
@@ -1253,6 +1254,7 @@ export class Backend {
1253
1254
  path: file.path,
1254
1255
  authorLogin: user.login,
1255
1256
  authorName: user.name,
1257
+ authorEmail: user.email,
1256
1258
  },
1257
1259
  user.useOpenAuthoring,
1258
1260
  ),
@@ -1279,6 +1281,7 @@ export class Backend {
1279
1281
  path,
1280
1282
  authorLogin: user.login,
1281
1283
  authorName: user.name,
1284
+ authorEmail: user.email,
1282
1285
  },
1283
1286
  user.useOpenAuthoring,
1284
1287
  );
@@ -1303,6 +1306,7 @@ export class Backend {
1303
1306
  path,
1304
1307
  authorLogin: user.login,
1305
1308
  authorName: user.name,
1309
+ authorEmail: user.email,
1306
1310
  },
1307
1311
  user.useOpenAuthoring,
1308
1312
  );
@@ -67,6 +67,7 @@ class PreviewContent extends React.Component {
67
67
 
68
68
  PreviewContent.propTypes = {
69
69
  previewComponent: PropTypes.func.isRequired,
70
+ getEditorComponents: PropTypes.func,
70
71
  previewProps: PropTypes.object,
71
72
  onFieldClick: PropTypes.func,
72
73
  };
@@ -13,6 +13,7 @@ import {
13
13
  getPreviewTemplate,
14
14
  getPreviewStyles,
15
15
  getRemarkPlugins,
16
+ getEditorComponents,
16
17
  } from '../../../lib/registry';
17
18
  import { getAllEntries, tryLoadEntry } from '../../../actions/entries';
18
19
  import { ErrorBoundary } from '../../UI';
@@ -57,6 +58,7 @@ export class PreviewPane extends React.Component {
57
58
  fieldsMetaData={metadata}
58
59
  resolveWidget={resolveWidget}
59
60
  getRemarkPlugins={getRemarkPlugins}
61
+ getEditorComponents={getEditorComponents}
60
62
  />
61
63
  );
62
64
  };
@@ -261,6 +263,7 @@ export class PreviewPane extends React.Component {
261
263
  this.widgetFor(name, fields, values, fieldsMetaData),
262
264
  widgetsFor: this.widgetsFor,
263
265
  getCollection: this.getCollection,
266
+ getEditorComponents,
264
267
  };
265
268
 
266
269
  const styleEls = getPreviewStyles().map((style, i) => {
@@ -2,7 +2,6 @@ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { css } from '@emotion/react';
4
4
  import styled from '@emotion/styled';
5
- import copyToClipboard from 'copy-text-to-clipboard';
6
5
  import { isAbsolutePath } from 'decap-cms-lib-util';
7
6
  import { buttons, shadows, zIndex } from 'decap-cms-ui-default';
8
7
 
@@ -87,10 +86,10 @@ export class CopyToClipBoardButton extends React.Component {
87
86
  this.mounted = false;
88
87
  }
89
88
 
90
- handleCopy = () => {
89
+ handleCopy = async () => {
91
90
  clearTimeout(this.timeout);
92
91
  const { path, draft, name } = this.props;
93
- copyToClipboard(isAbsolutePath(path) || !draft ? path : name);
92
+ await navigator.clipboard.writeText(isAbsolutePath(path) || !draft ? path : name);
94
93
  this.setState({ copied: true });
95
94
  this.timeout = setTimeout(() => this.mounted && this.setState({ copied: false }), 1500);
96
95
  };
@@ -4,7 +4,6 @@ import { translate } from 'react-polyglot';
4
4
  import styled from '@emotion/styled';
5
5
  import yaml from 'yaml';
6
6
  import truncate from 'lodash/truncate';
7
- import copyToClipboard from 'copy-text-to-clipboard';
8
7
  import { localForage } from 'decap-cms-lib-util';
9
8
  import { buttons, colors } from 'decap-cms-ui-default';
10
9
 
@@ -117,7 +116,7 @@ function RecoveredEntry({ entry, t }) {
117
116
  <hr />
118
117
  <h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
119
118
  <strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
120
- <CopyButton onClick={() => copyToClipboard(entry)}>
119
+ <CopyButton onClick={() => navigator.clipboard.writeText(entry)}>
121
120
  {t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
122
121
  </CopyButton>
123
122
  <pre>
@@ -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' },
@@ -72,6 +72,27 @@ describe('Frontmatter', () => {
72
72
  });
73
73
  });
74
74
 
75
+ it('should throw on duplicate frontmatter keys', () => {
76
+ expect(() =>
77
+ FrontmatterInfer.fromFile('---\ntitle: Hello\ntitle: World\n---\nContent'),
78
+ ).toThrow(/Map keys must be unique/);
79
+ });
80
+
81
+ it('should throw on duplicate frontmatter keys with explicit YAML format', () => {
82
+ expect(() =>
83
+ frontmatterYAML().fromFile('---\ntitle: Hello\ntitle: World\n---\nContent'),
84
+ ).toThrow(/Map keys must be unique/);
85
+ });
86
+
87
+ it('should not throw when body contains YAML-like patterns', () => {
88
+ expect(
89
+ FrontmatterInfer.fromFile('---\ntitle: Hello\n---\ntitle: this is not a duplicate'),
90
+ ).toEqual({
91
+ title: 'Hello',
92
+ body: 'title: this is not a duplicate',
93
+ });
94
+ });
95
+
75
96
  it('should stringify YAML with --- delimiters', () => {
76
97
  expect(
77
98
  FrontmatterInfer.toFile({
@@ -85,6 +85,33 @@ describe('yaml', () => {
85
85
  time: '10:05',
86
86
  });
87
87
  });
88
+
89
+ test('throws on duplicate keys', () => {
90
+ expect(() => yaml.fromFile('title: Hello\ntitle: World')).toThrow(
91
+ /Map keys must be unique; "title" is repeated/,
92
+ );
93
+ });
94
+
95
+ test('throws on duplicate nested keys', () => {
96
+ expect(() => yaml.fromFile('nested:\n a: 1\n a: 2')).toThrow(
97
+ /Map keys must be unique; "a" is repeated/,
98
+ );
99
+ });
100
+
101
+ test('does not throw when same key appears in different nested objects', () => {
102
+ expect(yaml.fromFile('obj1:\n name: foo\nobj2:\n name: bar')).toEqual({
103
+ obj1: { name: 'foo' },
104
+ obj2: { name: 'bar' },
105
+ });
106
+ });
107
+
108
+ test('logs warnings to console.warn', () => {
109
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
110
+ // Valid YAML should produce no warnings
111
+ yaml.fromFile('title: Hello');
112
+ expect(warnSpy).not.toHaveBeenCalled();
113
+ warnSpy.mockRestore();
114
+ });
88
115
  });
89
116
  describe('toFile', () => {
90
117
  test('outputs valid yaml', () => {
@@ -41,7 +41,22 @@ export default {
41
41
  if (content && content.trim().endsWith('---')) {
42
42
  content = content.trim().slice(0, -3);
43
43
  }
44
- return yaml.parse(content, { customTags: [timestampTag] });
44
+
45
+ const doc = yaml.parseDocument(content, {
46
+ customTags: [timestampTag],
47
+ prettyErrors: true,
48
+ });
49
+
50
+ for (const warn of doc.warnings) {
51
+ console.warn(`YAML warning: ${warn.message}`);
52
+ }
53
+
54
+ if (doc.errors.length > 0) {
55
+ const messages = doc.errors.map(e => e.message).join('\n');
56
+ throw new Error(`YAML parsing error:\n${messages}`);
57
+ }
58
+
59
+ return doc.toJSON();
45
60
  },
46
61
 
47
62
  toFile(data: object, sortedKeys: string[] = [], comments: Record<string, string> = {}) {
@@ -247,6 +247,27 @@ describe('formatters', () => {
247
247
  'Ignoring unknown variable “author-email” in open authoring message template.',
248
248
  );
249
249
  });
250
+
251
+ it('should return commit with trailer when signoff_commits is enabled', () => {
252
+ const collection = Map({ label_singular: 'Collection' });
253
+ const config = {
254
+ backend: {
255
+ signoff_commits: true,
256
+ },
257
+ };
258
+
259
+ expect(
260
+ commitMessageFormatter('create', config, {
261
+ slug: 'doc-slug',
262
+ path: 'file-path',
263
+ collection,
264
+ authorName: 'Test User',
265
+ authorEmail: 'test-user@example.org',
266
+ }),
267
+ ).toEqual(
268
+ 'Create Collection “doc-slug”\n\nSigned-off-by: Test User <test-user@example.org>\n',
269
+ );
270
+ });
250
271
  });
251
272
 
252
273
  describe('prepareSlug', () => {
@@ -566,6 +587,66 @@ describe('formatters', () => {
566
587
  'Collection "posts" configuration error:\n `preview_path_date_field` must be a field with a valid date. Ignoring `preview_path`.',
567
588
  );
568
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
+ });
569
650
  });
570
651
 
571
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', () => {
@@ -44,16 +44,28 @@ type Options = {
44
44
  collection?: Collection;
45
45
  authorLogin?: string;
46
46
  authorName?: string;
47
+ authorEmail?: string;
47
48
  };
48
49
 
49
50
  export function commitMessageFormatter(
50
51
  type: keyof typeof commitMessageTemplates,
51
52
  config: CmsConfig,
52
- { slug, path, collection, authorLogin, authorName }: Options,
53
+ { slug, path, collection, authorLogin, authorName, authorEmail }: Options,
53
54
  isOpenAuthoring?: boolean,
54
55
  ) {
55
56
  const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) };
56
57
 
58
+ let trailers = '';
59
+ if (config.backend.signoff_commits) {
60
+ if (!authorName) {
61
+ console.warn('Option signoff_commits is enabled, but author name is unknown');
62
+ } else if (!authorEmail) {
63
+ console.warn('Option signoff_commits is enabled, but author email is unknown');
64
+ } else {
65
+ trailers = `\n\nSigned-off-by: ${authorName} <${authorEmail}>\n`;
66
+ }
67
+ }
68
+
57
69
  const commitMessage = templates[type].replace(variableRegex, (_, variable) => {
58
70
  switch (variable) {
59
71
  case 'slug':
@@ -73,7 +85,7 @@ export function commitMessageFormatter(
73
85
  });
74
86
 
75
87
  if (!isOpenAuthoring) {
76
- return commitMessage;
88
+ return commitMessage + trailers;
77
89
  }
78
90
 
79
91
  const message = templates.openAuthoring.replace(variableRegex, (_, variable) => {
@@ -90,7 +102,7 @@ export function commitMessageFormatter(
90
102
  }
91
103
  });
92
104
 
93
- return message;
105
+ return message + trailers;
94
106
  }
95
107
 
96
108
  export function prepareSlug(slug: string) {
@@ -108,11 +120,19 @@ export function prepareSlug(slug: string) {
108
120
  );
109
121
  }
110
122
 
111
- export function getProcessSegment(slugConfig?: CmsSlug, ignoreValues?: string[]) {
123
+ export function getProcessSegment(
124
+ slugConfig?: CmsSlug,
125
+ ignoreValues?: string[],
126
+ preserveSlashes?: boolean,
127
+ ) {
112
128
  return (value: string) =>
113
129
  ignoreValues && ignoreValues.includes(value)
114
130
  ? value
115
- : flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)])(value);
131
+ : flow([
132
+ value => String(value),
133
+ prepareSlug,
134
+ partialRight(sanitizeSlug, slugConfig, preserveSlashes),
135
+ ])(value);
116
136
  }
117
137
 
118
138
  export function slugFormatter(
@@ -193,19 +213,21 @@ export function previewUrlFormatter(
193
213
  fields = addFileTemplateFields(entry.get('path'), fields, collection.get('folder'));
194
214
  const dateFieldName = getDateField() || selectInferredField(collection, 'date');
195
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'));
196
218
 
197
219
  // Prepare and sanitize slug variables only, leave the rest of the
198
220
  // `preview_path` template as is.
199
- const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')]);
221
+ const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')], preserveSlashes);
200
222
  let compiledPath;
201
223
 
202
224
  try {
203
225
  compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
204
- } catch (err) {
226
+ } catch (err: unknown) {
205
227
  // Print an error and ignore `preview_path` if both:
206
228
  // 1. Date is invalid (according to DayJs), and
207
229
  // 2. A date expression (eg. `{{year}}`) is used in `preview_path`
208
- if (err.name === SLUG_MISSING_REQUIRED_DATE) {
230
+ if (err instanceof Error && err.name === SLUG_MISSING_REQUIRED_DATE) {
209
231
  console.error(stripIndent`
210
232
  Collection "${collection.get('name')}" configuration error:
211
233
  \`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
package/src/lib/i18n.ts CHANGED
@@ -308,18 +308,26 @@ export async function getI18nEntry(
308
308
  if (structure === I18N_STRUCTURE.SINGLE_FILE) {
309
309
  entryValue = mergeSingleFileValue(await getEntryValue(path), defaultLocale, locales);
310
310
  } else {
311
- const entryValues = await Promise.all(
311
+ const entryValuesResults = await Promise.allSettled(
312
312
  locales.map(async locale => {
313
313
  const entryPath = getFilePath(structure, extension, path, slug, locale);
314
- const value = await getEntryValue(entryPath).catch(() => null);
314
+ const value = await getEntryValue(entryPath);
315
315
  return { value, locale };
316
316
  }),
317
317
  );
318
318
 
319
- const nonNullValues = entryValues.filter(e => e.value !== null) as {
320
- value: EntryValue;
321
- locale: string;
322
- }[];
319
+ const nonNullValues = entryValuesResults
320
+ .map(e => (e.status === 'fulfilled' ? e.value : undefined))
321
+ .filter((e): e is { value: EntryValue; locale: string } => e !== undefined);
322
+
323
+ if (nonNullValues.length === 0) {
324
+ // mergeValues will throw on an empty list, and show the error messages.
325
+ const [error = new Error('No entry values found for any locale')] = entryValuesResults
326
+ .map(e => (e.status === 'rejected' ? e.reason : undefined))
327
+ .filter(e => e !== undefined);
328
+
329
+ throw error;
330
+ }
323
331
 
324
332
  entryValue = mergeValues(collection, structure, defaultLocale, nonNullValues);
325
333
  }
@@ -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).