feat(widget-relation): string templates support (#3659)
This commit is contained in:
11
packages/netlify-cms-lib-widgets/README.md
Normal file
11
packages/netlify-cms-lib-widgets/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Docs coming soon!
|
||||
|
||||
Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages.
|
||||
That's over 20 Readme's! We haven't created one for this package yet, but we will soon.
|
||||
|
||||
In the meantime, you can:
|
||||
|
||||
1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation
|
||||
site](https://www.netlifycms.org) for more info.
|
||||
2. Reach out to the [community chat](https://netlifycms.org/chat/) if you need help.
|
||||
3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-lib-widgets/README.md)!
|
24
packages/netlify-cms-lib-widgets/package.json
Normal file
24
packages/netlify-cms-lib-widgets/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "netlify-cms-lib-widgets",
|
||||
"description": "Shared utilities for Netlify CMS.",
|
||||
"version": "1.0.0",
|
||||
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-lib-widgets",
|
||||
"bugs": "https://github.com/netlify/netlify-cms/issues",
|
||||
"module": "dist/esm/index.js",
|
||||
"main": "dist/netlify-cms-lib-widgets.js",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"netlify-cms"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"develop": "yarn build:esm --watch",
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
||||
},
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"immutable": "^3.7.6",
|
||||
"moment": "^2.24.0"
|
||||
}
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
import { fromJS } from 'immutable';
|
||||
import {
|
||||
keyToPathArray,
|
||||
compileStringTemplate,
|
||||
parseDateFromEntry,
|
||||
extractTemplateVars,
|
||||
} from '../stringTemplate';
|
||||
describe('stringTemplate', () => {
|
||||
describe('keyToPathArray', () => {
|
||||
it('should return array of length 1 with simple path', () => {
|
||||
expect(keyToPathArray('category')).toEqual(['category']);
|
||||
});
|
||||
|
||||
it('should return path array for complex path', () => {
|
||||
expect(keyToPathArray('categories[0].title.subtitles[0].welcome[2]')).toEqual([
|
||||
'categories',
|
||||
'0',
|
||||
'title',
|
||||
'subtitles',
|
||||
'0',
|
||||
'welcome',
|
||||
'2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDateFromEntry', () => {
|
||||
it('should return date based on dateFieldName', () => {
|
||||
const date = new Date().toISOString();
|
||||
const dateFieldName = 'dateFieldName';
|
||||
const entry = fromJS({ data: { dateFieldName: date } });
|
||||
expect(parseDateFromEntry(entry, dateFieldName).toISOString()).toBe(date);
|
||||
});
|
||||
|
||||
it('should return undefined on empty dateFieldName', () => {
|
||||
const entry = fromJS({ data: {} });
|
||||
expect(parseDateFromEntry(entry, '')).toBeUndefined();
|
||||
expect(parseDateFromEntry(entry, null)).toBeUndefined();
|
||||
expect(parseDateFromEntry(entry, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined on invalid date', () => {
|
||||
const entry = fromJS({ data: { date: '' } });
|
||||
const dateFieldName = 'date';
|
||||
expect(parseDateFromEntry(entry, dateFieldName)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTemplateVars', () => {
|
||||
it('should extract template variables', () => {
|
||||
expect(extractTemplateVars('{{slug}}-hello-{{date}}-world-{{fields.id}}')).toEqual([
|
||||
'slug',
|
||||
'date',
|
||||
'fields.id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array on no matches', () => {
|
||||
expect(extractTemplateVars('hello-world')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compileStringTemplate', () => {
|
||||
const date = new Date('2020-01-02T13:28:27.679Z');
|
||||
it('should compile year variable', () => {
|
||||
expect(compileStringTemplate('{{year}}', date)).toBe('2020');
|
||||
});
|
||||
|
||||
it('should compile month variable', () => {
|
||||
expect(compileStringTemplate('{{month}}', date)).toBe('01');
|
||||
});
|
||||
|
||||
it('should compile day variable', () => {
|
||||
expect(compileStringTemplate('{{day}}', date)).toBe('02');
|
||||
});
|
||||
|
||||
it('should compile hour variable', () => {
|
||||
expect(compileStringTemplate('{{hour}}', date)).toBe('13');
|
||||
});
|
||||
|
||||
it('should compile minute variable', () => {
|
||||
expect(compileStringTemplate('{{minute}}', date)).toBe('28');
|
||||
});
|
||||
|
||||
it('should compile second variable', () => {
|
||||
expect(compileStringTemplate('{{second}}', date)).toBe('27');
|
||||
});
|
||||
|
||||
it('should error on missing date', () => {
|
||||
expect(() => compileStringTemplate('{{year}}')).toThrowError();
|
||||
});
|
||||
|
||||
it('return compiled template', () => {
|
||||
expect(
|
||||
compileStringTemplate(
|
||||
'{{slug}}-{{year}}-{{fields.slug}}-{{title}}-{{date}}',
|
||||
date,
|
||||
'backendSlug',
|
||||
fromJS({ slug: 'entrySlug', title: 'title', date }),
|
||||
),
|
||||
).toBe('backendSlug-2020-entrySlug-title-' + date.toString());
|
||||
});
|
||||
|
||||
it('return apply processor to values', () => {
|
||||
expect(
|
||||
compileStringTemplate('{{slug}}', date, 'slug', fromJS({}), value => value.toUpperCase()),
|
||||
).toBe('SLUG');
|
||||
});
|
||||
});
|
||||
});
|
6
packages/netlify-cms-lib-widgets/src/index.ts
Normal file
6
packages/netlify-cms-lib-widgets/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import * as stringTemplate from './stringTemplate';
|
||||
|
||||
export const NetlifyCmsLibWidgets = {
|
||||
stringTemplate,
|
||||
};
|
||||
export { stringTemplate };
|
149
packages/netlify-cms-lib-widgets/src/stringTemplate.ts
Normal file
149
packages/netlify-cms-lib-widgets/src/stringTemplate.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import moment from 'moment';
|
||||
import { Map } from 'immutable';
|
||||
import { basename, extname } from 'path';
|
||||
|
||||
const FIELD_PREFIX = 'fields.';
|
||||
const templateContentPattern = '[^}{]+';
|
||||
const templateVariablePattern = `{{(${templateContentPattern})}}`;
|
||||
|
||||
// prepends a Zero if the date has only 1 digit
|
||||
function formatDate(date: number) {
|
||||
return `0${date}`.slice(-2);
|
||||
}
|
||||
|
||||
export const dateParsers: Record<string, (date: Date) => string> = {
|
||||
year: (date: Date) => `${date.getUTCFullYear()}`,
|
||||
month: (date: Date) => formatDate(date.getUTCMonth() + 1),
|
||||
day: (date: Date) => formatDate(date.getUTCDate()),
|
||||
hour: (date: Date) => formatDate(date.getUTCHours()),
|
||||
minute: (date: Date) => formatDate(date.getUTCMinutes()),
|
||||
second: (date: Date) => formatDate(date.getUTCSeconds()),
|
||||
};
|
||||
|
||||
export function parseDateFromEntry(entry: Map<string, unknown>, dateFieldName?: string | null) {
|
||||
if (!dateFieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateValue = entry.getIn(['data', dateFieldName]);
|
||||
const dateMoment = dateValue && moment(dateValue);
|
||||
if (dateMoment && dateMoment.isValid()) {
|
||||
return dateMoment.toDate();
|
||||
}
|
||||
}
|
||||
|
||||
export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE';
|
||||
|
||||
export const keyToPathArray = (key?: string) => {
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
const parts = [];
|
||||
const separator = '';
|
||||
const chars = key.split(separator);
|
||||
|
||||
let currentChar;
|
||||
let currentStr = [];
|
||||
while ((currentChar = chars.shift())) {
|
||||
if (['[', ']', '.'].includes(currentChar)) {
|
||||
if (currentStr.length > 0) {
|
||||
parts.push(currentStr.join(separator));
|
||||
}
|
||||
currentStr = [];
|
||||
} else {
|
||||
currentStr.push(currentChar);
|
||||
}
|
||||
}
|
||||
if (currentStr.length > 0) {
|
||||
parts.push(currentStr.join(separator));
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
// Allow `fields.` prefix in placeholder to override built in replacements
|
||||
// like "slug" and "year" with values from fields of the same name.
|
||||
function getExplicitFieldReplacement(key: string, data: Map<string, unknown>) {
|
||||
if (!key.startsWith(FIELD_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
const fieldName = key.substring(FIELD_PREFIX.length);
|
||||
const value = data.getIn(keyToPathArray(fieldName));
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function compileStringTemplate(
|
||||
template: string,
|
||||
date: Date | undefined | null,
|
||||
identifier = '',
|
||||
data = Map<string, unknown>(),
|
||||
processor?: (value: string) => string,
|
||||
) {
|
||||
let missingRequiredDate;
|
||||
|
||||
// Turn off date processing (support for replacements like `{{year}}`), by passing in
|
||||
// `null` as the date arg.
|
||||
const useDate = date !== null;
|
||||
|
||||
const compiledString = template.replace(
|
||||
RegExp(templateVariablePattern, 'g'),
|
||||
(_, key: string) => {
|
||||
let replacement;
|
||||
const explicitFieldReplacement = getExplicitFieldReplacement(key, data);
|
||||
|
||||
if (explicitFieldReplacement) {
|
||||
replacement = explicitFieldReplacement;
|
||||
} else if (dateParsers[key] && !date) {
|
||||
missingRequiredDate = true;
|
||||
return '';
|
||||
} else if (dateParsers[key]) {
|
||||
replacement = dateParsers[key](date as Date);
|
||||
} else if (key === 'slug') {
|
||||
replacement = identifier;
|
||||
} else {
|
||||
replacement = data.getIn(keyToPathArray(key), '') as string;
|
||||
}
|
||||
|
||||
if (processor) {
|
||||
return processor(replacement);
|
||||
}
|
||||
|
||||
return replacement;
|
||||
},
|
||||
);
|
||||
|
||||
if (useDate && missingRequiredDate) {
|
||||
const err = new Error();
|
||||
err.name = SLUG_MISSING_REQUIRED_DATE;
|
||||
throw err;
|
||||
} else {
|
||||
return compiledString;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTemplateVars(template: string) {
|
||||
const regexp = RegExp(templateVariablePattern, 'g');
|
||||
const contentRegexp = RegExp(templateContentPattern, 'g');
|
||||
const matches = template.match(regexp) || [];
|
||||
return matches.map(elem => {
|
||||
const match = elem.match(contentRegexp);
|
||||
return match ? match[0] : '';
|
||||
});
|
||||
}
|
||||
|
||||
export const addFileTemplateFields = (entryPath: string, fields: Map<string, string>) => {
|
||||
if (!entryPath) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
const extension = extname(entryPath);
|
||||
const filename = basename(entryPath, extension);
|
||||
fields = fields.withMutations(map => {
|
||||
map.set('filename', filename);
|
||||
map.set('extension', extension === '' ? extension : extension.substr(1));
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
3
packages/netlify-cms-lib-widgets/webpack.config.js
Normal file
3
packages/netlify-cms-lib-widgets/webpack.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
const { getConfig } = require('../../scripts/webpack.js');
|
||||
|
||||
module.exports = getConfig();
|
Reference in New Issue
Block a user