diff --git a/dev-test/config.yml b/dev-test/config.yml index 172985ac..3da9ab1f 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -15,6 +15,7 @@ collections: # A list of collections the CMS should be able to edit guidelines that are specific to a collection. folder: '_posts' slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' create: true # Allow users to create new documents in this collection fields: # The fields each document in this collection have - { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' } diff --git a/packages/netlify-cms-core/src/backend.js b/packages/netlify-cms-core/src/backend.js index 24aee663..9652d7b5 100644 --- a/packages/netlify-cms-core/src/backend.js +++ b/packages/netlify-cms-core/src/backend.js @@ -1,7 +1,6 @@ -import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight } from 'lodash'; +import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight, uniq } from 'lodash'; import { Map } from 'immutable'; import { stripIndent } from 'common-tags'; -import moment from 'moment'; import fuzzy from 'fuzzy'; import { resolveFormat } from 'Formats/formats'; import { selectIntegration } from 'Reducers/integrations'; @@ -20,6 +19,13 @@ import { sanitizeSlug } from 'Lib/urlHelper'; import { getBackend } from 'Lib/registry'; import { localForage, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util'; import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes'; +import { + SLUG_MISSING_REQUIRED_DATE, + compileStringTemplate, + extractTemplateVars, + parseDateFromEntry, + dateParsers, +} from 'Lib/stringTemplate'; class LocalStorageAuthStore { storageKey = 'netlify-cms-user'; @@ -53,35 +59,13 @@ function prepareSlug(slug) { ); } -const dateParsers = { - year: date => date.getFullYear(), - month: date => `0${date.getMonth() + 1}`.slice(-2), - day: date => `0${date.getDate()}`.slice(-2), - hour: date => `0${date.getHours()}`.slice(-2), - minute: date => `0${date.getMinutes()}`.slice(-2), - second: date => `0${date.getSeconds()}`.slice(-2), -}; - -const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE'; -const USE_FIELD_PREFIX = 'fields.'; - -// 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, data) { - if (!key.startsWith(USE_FIELD_PREFIX)) { - return; - } - const fieldName = key.substring(USE_FIELD_PREFIX.length); - return data.get(fieldName, ''); -} - function getEntryBackupKey(collectionName, slug) { const baseKey = 'backup'; if (!collectionName) { return baseKey; } const suffix = slug ? `.${slug}` : ''; - return `backup.${collectionName}${suffix}`; + return `${baseKey}.${collectionName}${suffix}`; } function getLabelForFileCollectionEntry(collection, path) { @@ -89,42 +73,6 @@ function getLabelForFileCollectionEntry(collection, path) { return files && files.find(f => f.get('file') === path).get('label'); } -function compileSlug(template, date, identifier = '', data = Map(), processor) { - let missingRequiredDate; - - const slug = template.replace(/\{\{([^}]+)\}\}/g, (_, key) => { - 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); - } else if (key === 'slug') { - replacement = identifier; - } else { - replacement = data.get(key, ''); - } - - if (processor) { - return processor(replacement); - } - - return replacement; - }); - - if (missingRequiredDate) { - const err = new Error(); - err.name = SLUG_MISSING_REQUIRED_DATE; - throw err; - } else { - return slug; - } -} - function slugFormatter(collection, entryData, slugConfig) { const template = collection.get('slug') || '{{slug}}'; @@ -138,7 +86,11 @@ function slugFormatter(collection, entryData, slugConfig) { // Pass entire slug through `prepareSlug` and `sanitizeSlug`. // TODO: only pass slug replacements through sanitizers, static portions of // the slug template should not be sanitized. (breaking change) - const processSlug = flow([compileSlug, prepareSlug, partialRight(sanitizeSlug, slugConfig)]); + const processSlug = flow([ + compileStringTemplate, + prepareSlug, + partialRight(sanitizeSlug, slugConfig), + ]); return processSlug(template, new Date(), identifier, entryData); } @@ -183,20 +135,6 @@ const sortByScore = (a, b) => { return 0; }; -function parsePreviewPathDate(collection, entry) { - const dateField = - collection.get('preview_path_date_field') || selectInferedField(collection, 'date'); - if (!dateField) { - return; - } - - const dateValue = entry.getIn(['data', dateField]); - const dateMoment = dateValue && moment(dateValue); - if (dateMoment && dateMoment.isValid()) { - return dateMoment.toDate(); - } -} - function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) { /** * Preview URL can't be created without `baseUrl`. This makes preview URLs @@ -221,7 +159,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) { const basePath = trimEnd(baseUrl, '/'); const pathTemplate = collection.get('preview_path'); const fields = entry.get('data'); - const date = parsePreviewPathDate(collection, entry); + const date = parseDateFromEntry(entry, collection, collection.get('preview_path_date_field')); // Prepare and sanitize slug variables only, leave the rest of the // `preview_path` template as is. @@ -233,7 +171,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) { let compiledPath; try { - compiledPath = compileSlug(pathTemplate, date, slug, fields, processSegment); + compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment); } catch (err) { // Print an error and ignore `preview_path` if both: // 1. Date is invalid (according to Moment), and @@ -384,15 +322,24 @@ class Backend { const errors = []; const collectionEntriesRequests = collections .map(async collection => { + const summary = collection.get('summary', ''); + const summaryFields = extractTemplateVars(summary); + // TODO: pass search fields in as an argument const searchFields = [ selectInferedField(collection, 'title'), selectInferedField(collection, 'shortTitle'), selectInferedField(collection, 'author'), + ...summaryFields.map(elem => { + if (dateParsers[elem]) { + return selectInferedField(collection, 'date'); + } + return elem; + }), ]; const collectionEntries = await this.listAllEntries(collection); return fuzzy.filter(searchTerm, collectionEntries, { - extract: extractSearchFields(searchFields), + extract: extractSearchFields(uniq(searchFields)), }); }) .map(p => p.catch(err => errors.push(err) && [])); diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js index a243a70c..51f16c07 100644 --- a/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js +++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js @@ -4,6 +4,8 @@ import { Link } from 'react-router-dom'; import { resolvePath } from 'netlify-cms-lib-util'; import { colors, colorsRaw, components, lengths } from 'netlify-cms-ui-default'; import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews'; +import { compileStringTemplate, parseDateFromEntry } from 'Lib/stringTemplate'; +import { selectIdentifier } from 'Reducers/collections'; const ListCard = styled.li` ${components.card}; @@ -89,9 +91,17 @@ const EntryCard = ({ viewStyle = VIEW_STYLE_LIST, }) => { const label = entry.get('label'); - const title = label || entry.getIn(['data', inferedFields.titleField]); + const entryData = entry.get('data'); + const defaultTitle = label || entryData.get(inferedFields.titleField); const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`; - let image = entry.getIn(['data', inferedFields.imageField]); + const summary = collection.get('summary'); + const date = parseDateFromEntry(entry, collection) || null; + const identifier = entryData.get(selectIdentifier(collection)); + const title = summary + ? compileStringTemplate(summary, date, identifier, entryData) + : defaultTitle; + + let image = entryData.get(inferedFields.imageField); image = resolvePath(image, publicFolder); if (image) { image = encodeURI(image); diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 2229f18b..456703f1 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -89,6 +89,7 @@ const getConfigSchema = () => ({ }, }, identifier_field: { type: 'string' }, + summary: { type: 'string' }, slug: { type: 'string' }, preview_path: { type: 'string' }, preview_path_date_field: { type: 'string' }, diff --git a/packages/netlify-cms-core/src/lib/stringTemplate.js b/packages/netlify-cms-core/src/lib/stringTemplate.js new file mode 100644 index 00000000..7abb37ed --- /dev/null +++ b/packages/netlify-cms-core/src/lib/stringTemplate.js @@ -0,0 +1,92 @@ +import moment from 'moment'; +import { selectInferedField } from 'Reducers/collections'; + +// prepends a Zero if the date has only 1 digit +function formatDate(date) { + return `0${date}`.slice(-2); +} + +export const dateParsers = { + year: date => date.getFullYear(), + month: date => formatDate(date.getMonth() + 1), + day: date => formatDate(date.getDate()), + hour: date => formatDate(date.getHours()), + minute: date => formatDate(date.getMinutes()), + second: date => formatDate(date.getSeconds()), +}; + +export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE'; + +const FIELD_PREFIX = 'fields.'; +const templateContentPattern = '[^}{]+'; +const templateVariablePattern = `{{(${templateContentPattern})}}`; + +// 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, data) { + if (!key.startsWith(FIELD_PREFIX)) { + return; + } + const fieldName = key.substring(FIELD_PREFIX.length); + return data.get(fieldName, ''); +} + +export function parseDateFromEntry(entry, collection, fieldName) { + const dateFieldName = fieldName || selectInferedField(collection, 'date'); + if (!dateFieldName) { + return; + } + + const dateValue = entry.getIn(['data', dateFieldName]); + const dateMoment = dateValue && moment(dateValue); + if (dateMoment && dateMoment.isValid()) { + return dateMoment.toDate(); + } +} + +export function compileStringTemplate(template, date, identifier = '', data = Map(), processor) { + let missingRequiredDate; + + // Turn off date processing (support for replacements like `{{year}}`), by passing in + // `null` as the date arg. + const useDate = date !== null; + + const slug = template.replace(RegExp(templateVariablePattern, 'g'), (_, key) => { + 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); + } else if (key === 'slug') { + replacement = identifier; + } else { + replacement = data.get(key, ''); + } + + if (processor) { + return processor(replacement); + } + + return replacement; + }); + + if (useDate && missingRequiredDate) { + const err = new Error(); + err.name = SLUG_MISSING_REQUIRED_DATE; + throw err; + } else { + return slug; + } +} + +export function extractTemplateVars(template) { + const regexp = RegExp(templateVariablePattern, 'g'); + const contentRegexp = RegExp(templateContentPattern, 'g'); + const matches = template.match(regexp) || []; + return matches.map(elem => elem.match(contentRegexp)[0]); +} diff --git a/packages/netlify-cms-core/src/reducers/collections.js b/packages/netlify-cms-core/src/reducers/collections.js index 68f0f59c..9296dafd 100644 --- a/packages/netlify-cms-core/src/reducers/collections.js +++ b/packages/netlify-cms-core/src/reducers/collections.js @@ -115,7 +115,7 @@ export const selectTemplateName = (collection, slug) => export const selectIdentifier = collection => { const identifier = collection.get('identifier_field'); const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS; - const fieldNames = collection.get('fields').map(field => field.get('name')); + const fieldNames = collection.get('fields', []).map(field => field.get('name')); return identifierFields.find(id => fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()), ); @@ -128,7 +128,7 @@ export const selectInferedField = (collection, fieldName) => { const fields = collection.get('fields'); let field; - // If colllection has no fields or fieldName is not defined within inferables list, return null + // If collection has no fields or fieldName is not defined within inferables list, return null if (!fields || !inferableField) return null; // Try to return a field of the specified type with one of the synonyms const mainTypeFields = fields diff --git a/website/content/docs/configuration-options.md b/website/content/docs/configuration-options.md index 971aa81e..5e371095 100644 --- a/website/content/docs/configuration-options.md +++ b/website/content/docs/configuration-options.md @@ -171,6 +171,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it * `preview_path`: see detailed description below * `fields` (required): see detailed description below * `editor`: see detailed description below +* `summary`: see detailed description below The last few options require more detailed information. @@ -320,3 +321,14 @@ This setting changes options for the editor view of the collection. It has one o editor: preview: false ``` + + +### `summary` + +This setting allows the customisation of the collection list view. Similar to the `slug` field, a string with templates can be used to include values of different fields, e.g. `{{title}}`. +This option over-rides the default of `title` field and `identifier_field`. + +**Example** +```yaml + summary: "Version: {{version}} - {{title}}" +```