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.
- package/dist/decap-cms-core.js +18 -18
- package/dist/decap-cms-core.js.map +1 -1
- package/dist/esm/backend.js +8 -4
- package/dist/esm/bootstrap.js +2 -2
- package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewContent.js +1 -0
- package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -4
- package/dist/esm/components/MediaLibrary/MediaLibraryButtons.js +7 -8
- package/dist/esm/components/UI/ErrorBoundary.js +6 -7
- package/dist/esm/constants/configSchema.js +6 -0
- package/dist/esm/formats/yaml.js +11 -2
- package/dist/esm/lib/formatters.js +20 -7
- package/dist/esm/lib/i18n.js +8 -3
- package/dist/esm/lib/urlHelper.js +26 -8
- package/index.d.ts +2 -0
- package/package.json +2 -3
- package/src/backend.ts +4 -0
- package/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js +1 -0
- package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +3 -0
- package/src/components/MediaLibrary/MediaLibraryButtons.js +2 -3
- package/src/components/UI/ErrorBoundary.js +1 -2
- package/src/constants/configSchema.js +2 -0
- package/src/formats/__tests__/frontmatter.spec.js +21 -0
- package/src/formats/__tests__/yaml.spec.js +27 -0
- package/src/formats/yaml.ts +16 -1
- package/src/lib/__tests__/formatters.spec.js +81 -0
- package/src/lib/__tests__/urlHelper.spec.js +7 -0
- package/src/lib/formatters.ts +30 -8
- package/src/lib/i18n.ts +14 -6
- package/src/lib/urlHelper.ts +33 -9
- package/src/types/redux.ts +4 -0
package/dist/esm/lib/i18n.js
CHANGED
|
@@ -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
|
|
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)
|
|
260
|
+
const value = await getEntryValue(entryPath);
|
|
261
261
|
return {
|
|
262
262
|
value,
|
|
263
263
|
locale
|
|
264
264
|
};
|
|
265
265
|
}));
|
|
266
|
-
const nonNullValues =
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
);
|
|
@@ -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
|
-
|
|
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={() =>
|
|
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', () => {
|
package/src/formats/yaml.ts
CHANGED
|
@@ -41,7 +41,22 @@ export default {
|
|
|
41
41
|
if (content && content.trim().endsWith('---')) {
|
|
42
42
|
content = content.trim().slice(0, -3);
|
|
43
43
|
}
|
|
44
|
-
|
|
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', () => {
|
package/src/lib/formatters.ts
CHANGED
|
@@ -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(
|
|
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([
|
|
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
|
|
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)
|
|
314
|
+
const value = await getEntryValue(entryPath);
|
|
315
315
|
return { value, locale };
|
|
316
316
|
}),
|
|
317
317
|
);
|
|
318
318
|
|
|
319
|
-
const nonNullValues =
|
|
320
|
-
value:
|
|
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
|
}
|
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).
|