Skip to content
This repository was archived by the owner on Oct 1, 2024. It is now read-only.

Commit 99906fe

Browse files
mmkaljlabedoerezrokah
authored andcommitted
feat: register custom formats take 2 (decaporg#6205)
* feat: register custom formats * fix: register custom formats validation * fix: change 'format' field validation to string instead of enum Format will have the same behaviour as the widget property * test: custom formats and register function * docs: explain usage and note manual initialization requirement * fix: remove unused imports * use default extension * remove manual init note * PR comments * fix: prettier * revert unnecessary changes? * chore: more revert? * chore: newline * chore: update import --------- Co-authored-by: Jean <[email protected]> Co-authored-by: Misha Kaletsky <[email protected]> Co-authored-by: Erez Rokah <[email protected]>
1 parent 43d5b75 commit 99906fe

File tree

9 files changed

+194
-19
lines changed

9 files changed

+194
-19
lines changed

packages/decap-cms-core/index.d.ts

+10-9
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,7 @@ declare module 'decap-cms-core' {
3636
value: any;
3737
}
3838

39-
export type CmsCollectionFormatType =
40-
| 'yml'
41-
| 'yaml'
42-
| 'toml'
43-
| 'json'
44-
| 'frontmatter'
45-
| 'yaml-frontmatter'
46-
| 'toml-frontmatter'
47-
| 'json-frontmatter';
39+
export type CmsCollectionFormatType = string;
4840

4941
export type CmsAuthScope = 'repo' | 'public_repo';
5042

@@ -501,6 +493,11 @@ declare module 'decap-cms-core' {
501493

502494
export type CmsLocalePhrases = any; // TODO: type properly
503495

496+
export type Formatter = {
497+
fromFile(content: string): unknown;
498+
toFile(data: object, sortedKeys?: string[], comments?: Record<string, string>): string;
499+
};
500+
504501
export interface CmsRegistry {
505502
backends: {
506503
[name: string]: CmsRegistryBackend;
@@ -520,6 +517,9 @@ declare module 'decap-cms-core' {
520517
locales: {
521518
[name: string]: CmsLocalePhrases;
522519
};
520+
formats: {
521+
[name: string]: Formatter;
522+
};
523523
}
524524

525525
type GetAssetFunction = (asset: string) => {
@@ -579,6 +579,7 @@ declare module 'decap-cms-core' {
579579
serializer: CmsWidgetValueSerializer,
580580
) => void;
581581
resolveWidget: (name: string) => CmsWidget | undefined;
582+
registerCustomFormat: (name: string, extension: string, formatter: Formatter) => void;
582583
}
583584

584585
export const DecapCmsCore: CMS;

packages/decap-cms-core/src/constants/configSchema.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import ajvErrors from 'ajv-errors';
99
import uuid from 'uuid/v4';
1010

11-
import { formatExtensions, frontmatterFormats, extensionFormatters } from '../formats/formats';
11+
import { frontmatterFormats, extensionFormatters } from '../formats/formats';
1212
import { getWidgets } from '../lib/registry';
1313
import { I18N_STRUCTURE, I18N_FIELD } from '../lib/i18n';
1414

@@ -231,7 +231,7 @@ function getConfigSchema() {
231231
preview: { type: 'boolean' },
232232
},
233233
},
234-
format: { type: 'string', enum: Object.keys(formatExtensions) },
234+
format: { type: 'string' },
235235
extension: { type: 'string' },
236236
frontmatter_delimiter: {
237237
type: ['string', 'array'],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Map } from 'immutable';
2+
3+
import { extensionFormatters, resolveFormat } from '../formats';
4+
import { registerCustomFormat } from '../../lib/registry';
5+
6+
describe('custom formats', () => {
7+
const testEntry = {
8+
collection: 'testCollection',
9+
data: { x: 1 },
10+
isModification: false,
11+
label: 'testLabel',
12+
mediaFiles: [],
13+
meta: {},
14+
newRecord: true,
15+
partial: false,
16+
path: 'testPath1',
17+
raw: 'testRaw',
18+
slug: 'testSlug',
19+
author: 'testAuthor',
20+
updatedOn: 'testUpdatedOn',
21+
};
22+
it('resolves builtint formats', () => {
23+
const collection = Map({
24+
name: 'posts',
25+
});
26+
expect(resolveFormat(collection, { ...testEntry, path: 'test.yml' })).toEqual(
27+
extensionFormatters.yml,
28+
);
29+
expect(resolveFormat(collection, { ...testEntry, path: 'test.yaml' })).toEqual(
30+
extensionFormatters.yml,
31+
);
32+
expect(resolveFormat(collection, { ...testEntry, path: 'test.toml' })).toEqual(
33+
extensionFormatters.toml,
34+
);
35+
expect(resolveFormat(collection, { ...testEntry, path: 'test.json' })).toEqual(
36+
extensionFormatters.json,
37+
);
38+
expect(resolveFormat(collection, { ...testEntry, path: 'test.md' })).toEqual(
39+
extensionFormatters.md,
40+
);
41+
expect(resolveFormat(collection, { ...testEntry, path: 'test.markdown' })).toEqual(
42+
extensionFormatters.markdown,
43+
);
44+
expect(resolveFormat(collection, { ...testEntry, path: 'test.html' })).toEqual(
45+
extensionFormatters.html,
46+
);
47+
});
48+
49+
it('resolves custom format', () => {
50+
registerCustomFormat('txt-querystring', 'txt', {
51+
fromFile: file => Object.fromEntries(new URLSearchParams(file)),
52+
toFile: value => new URLSearchParams(value).toString(),
53+
});
54+
55+
const collection = Map({
56+
name: 'posts',
57+
format: 'txt-querystring',
58+
});
59+
60+
const formatter = resolveFormat(collection, { ...testEntry, path: 'test.txt' });
61+
62+
expect(formatter.toFile({ foo: 'bar' })).toEqual('foo=bar');
63+
expect(formatter.fromFile('foo=bar')).toEqual({ foo: 'bar' });
64+
});
65+
66+
it('can override existing formatters', () => {
67+
// simplified version of a more realistic use case: using a different yaml library like js-yaml
68+
// to make netlify-cms play nice with other tools that edit content and spit out yaml
69+
registerCustomFormat('bad-yaml', 'yml', {
70+
fromFile: file => Object.fromEntries(file.split('\n').map(line => line.split(': '))),
71+
toFile: value =>
72+
Object.entries(value)
73+
.map(([k, v]) => `${k}: ${v}`)
74+
.join('\n'),
75+
});
76+
77+
const collection = Map({
78+
name: 'posts',
79+
format: 'bad-yaml',
80+
});
81+
82+
const formatter = resolveFormat(collection, { ...testEntry, path: 'test.txt' });
83+
84+
expect(formatter.toFile({ a: 'b', c: 'd' })).toEqual('a: b\nc: d');
85+
expect(formatter.fromFile('a: b\nc: d')).toEqual({ a: 'b', c: 'd' });
86+
});
87+
});

packages/decap-cms-core/src/formats/formats.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import yamlFormatter from './yaml';
55
import tomlFormatter from './toml';
66
import jsonFormatter from './json';
77
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter';
8+
import { getCustomFormatsExtensions, getCustomFormatsFormatters } from '../lib/registry';
89

910
import type { Delimiter } from './frontmatter';
1011
import type { Collection, EntryObject, Format } from '../types/redux';
1112
import type { EntryValue } from '../valueObjects/Entry';
13+
import type { Formatter } from 'decap-cms-core';
1214

1315
export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter'];
1416

@@ -23,6 +25,10 @@ export const formatExtensions = {
2325
'yaml-frontmatter': 'md',
2426
};
2527

28+
export function getFormatExtensions() {
29+
return { ...formatExtensions, ...getCustomFormatsExtensions() };
30+
}
31+
2632
export const extensionFormatters = {
2733
yml: yamlFormatter,
2834
yaml: yamlFormatter,
@@ -33,8 +39,8 @@ export const extensionFormatters = {
3339
html: FrontmatterInfer,
3440
};
3541

36-
function formatByName(name: Format, customDelimiter?: Delimiter) {
37-
return {
42+
function formatByName(name: Format, customDelimiter?: Delimiter): Formatter {
43+
const formatters: Record<string, Formatter> = {
3844
yml: yamlFormatter,
3945
yaml: yamlFormatter,
4046
toml: tomlFormatter,
@@ -43,7 +49,12 @@ function formatByName(name: Format, customDelimiter?: Delimiter) {
4349
'json-frontmatter': frontmatterJSON(customDelimiter),
4450
'toml-frontmatter': frontmatterTOML(customDelimiter),
4551
'yaml-frontmatter': frontmatterYAML(customDelimiter),
46-
}[name];
52+
...getCustomFormatsFormatters(),
53+
};
54+
if (name in formatters) {
55+
return formatters[name];
56+
}
57+
throw new Error(`No formatter available with name: ${name}`);
4758
}
4859

4960
function frontmatterDelimiterIsList(

packages/decap-cms-core/src/lib/__tests__/registry.spec.js

+15
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ describe('registry', () => {
4646
});
4747
});
4848

49+
describe('registerCustomFormat', () => {
50+
it('can register a custom format', () => {
51+
const { getCustomFormats, registerCustomFormat } = require('../registry');
52+
53+
expect(Object.keys(getCustomFormats())).not.toContain('querystring');
54+
55+
registerCustomFormat('querystring', 'qs', {
56+
fromFile: content => Object.fromEntries(new URLSearchParams(content)),
57+
toFile: obj => new URLSearchParams(obj).toString(),
58+
});
59+
60+
expect(Object.keys(getCustomFormats())).toContain('querystring');
61+
});
62+
});
63+
4964
describe('eventHandlers', () => {
5065
const events = [
5166
'prePublish',

packages/decap-cms-core/src/lib/registry.js

+30
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const registry = {
3131
mediaLibraries: [],
3232
locales: {},
3333
eventHandlers,
34+
formats: {},
3435
};
3536

3637
export default {
@@ -58,6 +59,10 @@ export default {
5859
removeEventListener,
5960
getEventListeners,
6061
invokeEvent,
62+
registerCustomFormat,
63+
getCustomFormats,
64+
getCustomFormatsExtensions,
65+
getCustomFormatsFormatters,
6166
};
6267

6368
/**
@@ -280,3 +285,28 @@ export function registerLocale(locale, phrases) {
280285
export function getLocale(locale) {
281286
return registry.locales[locale];
282287
}
288+
289+
export function registerCustomFormat(name, extension, formatter) {
290+
registry.formats[name] = { extension, formatter };
291+
}
292+
293+
export function getCustomFormats() {
294+
return registry.formats;
295+
}
296+
297+
export function getCustomFormatsExtensions() {
298+
return Object.entries(registry.formats).reduce(function (acc, [name, { extension }]) {
299+
return { ...acc, [name]: extension };
300+
}, {});
301+
}
302+
303+
/** @type {() => Record<string, unknown>} */
304+
export function getCustomFormatsFormatters() {
305+
return Object.entries(registry.formats).reduce(function (acc, [name, { formatter }]) {
306+
return { ...acc, [name]: formatter };
307+
}, {});
308+
}
309+
310+
export function getFormatter(name) {
311+
return registry.formats[name]?.formatter;
312+
}

packages/decap-cms-core/src/reducers/collections.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { CONFIG_SUCCESS } from '../actions/config';
77
import { FILES, FOLDER } from '../constants/collectionTypes';
88
import { COMMIT_DATE, COMMIT_AUTHOR } from '../constants/commitProps';
99
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS, SORTABLE_FIELDS } from '../constants/fieldInference';
10-
import { formatExtensions } from '../formats/formats';
10+
import { getFormatExtensions } from '../formats/formats';
1111
import { selectMediaFolder } from './entries';
1212
import { summaryFormatter } from '../lib/formatters';
1313

@@ -46,10 +46,14 @@ function collections(state = defaultState, action: ConfigAction) {
4646
const selectors = {
4747
[FOLDER]: {
4848
entryExtension(collection: Collection) {
49-
return (
49+
const ext =
5050
collection.get('extension') ||
51-
get(formatExtensions, collection.get('format') || 'frontmatter')
52-
).replace(/^\./, '');
51+
get(getFormatExtensions(), collection.get('format') || 'frontmatter');
52+
if (!ext) {
53+
throw new Error(`No extension found for format ${collection.get('format')}`);
54+
}
55+
56+
return ext.replace(/^\./, '');
5357
},
5458
fields(collection: Collection) {
5559
return collection.get('fields');

packages/decap-cms-core/src/types/redux.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ type i18n = StaticallyTypedRecord<{
599599
default_locale: string;
600600
}>;
601601

602-
export type Format = keyof typeof formatExtensions;
602+
export type Format = keyof typeof formatExtensions | string;
603603

604604
type CollectionObject = {
605605
name: string;

website/content/docs/beta-features.md

+27
Original file line numberDiff line numberDiff line change
@@ -684,3 +684,30 @@ CMS.registerRemarkPlugin({ settings: { bullet: '-' } });
684684
```
685685
686686
Note that `netlify-widget-markdown` currently uses `remark@10`, so you should check a plugin's compatibility first.
687+
688+
## Custom formatters
689+
690+
To manage content with other file formats than the built in ones, you can register a custom formatter:
691+
692+
```js
693+
const JSON5 = require('json5');
694+
695+
CMS.registerCustomFormat('json5', 'json5', {
696+
fromFile: text => JSON5.parse(text),
697+
toFile: value => JSON5.stringify(value, null, 2),
698+
});
699+
```
700+
701+
Then include `format: json5` in your collection configuration. See the [Collection docs](https://www.netlifycms.org/docs/configuration-options/#collections) for more details.
702+
703+
You can also override the in-built formatters. For example, to change the YAML serialization method from [`yaml`](https://npmjs.com/package/yaml) to [`js-yaml`](https://npmjs.com/package/js-yaml):
704+
705+
```js
706+
const jsYaml = require('js-yaml');
707+
708+
CMS.registerCustomFormat('yml', 'yml', {
709+
fromFile: text => jsYaml.load(text),
710+
toFile: value => jsYaml.dump(value),
711+
});
712+
```
713+

0 commit comments

Comments
 (0)