decap-cms-core 3.11.0 → 3.12.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/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>
@@ -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', () => {
@@ -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) {
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
  }
@@ -377,6 +377,7 @@ export interface CmsBackend {
377
377
  auth_endpoint?: string;
378
378
  cms_label_prefix?: string;
379
379
  squash_merges?: boolean;
380
+ signoff_commits?: boolean;
380
381
  proxy_url?: string;
381
382
  commit_messages?: {
382
383
  create?: string;