feat(workflow): add deploy preview links (#2028)
This commit is contained in:
parent
f0553c720a
commit
15d221d4a4
@ -1,7 +1,7 @@
|
||||
backend:
|
||||
name: test-repo
|
||||
|
||||
display_url: https://example.com
|
||||
site_url: https://example.com
|
||||
|
||||
publish_mode: editorial_workflow
|
||||
media_folder: assets/uploads
|
||||
|
@ -208,6 +208,11 @@ export default class GitGateway {
|
||||
deleteFile(path, commitMessage, options) {
|
||||
return this.backend.deleteFile(path, commitMessage, options);
|
||||
}
|
||||
getDeployPreview(collection, slug) {
|
||||
if (this.backend.getDeployPreview) {
|
||||
return this.backend.getDeployPreview(collection, slug);
|
||||
}
|
||||
}
|
||||
unpublishedEntries() {
|
||||
return this.backend.unpublishedEntries();
|
||||
}
|
||||
|
@ -280,6 +280,15 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve statuses for a given SHA. Unrelated to the editorial workflow
|
||||
* concept of entry "status". Useful for things like deploy preview links.
|
||||
*/
|
||||
async getStatuses(sha) {
|
||||
const resp = await this.request(`${this.repoURL}/commits/${sha}/status`);
|
||||
return resp.statuses;
|
||||
}
|
||||
|
||||
composeFileTree(files) {
|
||||
let filename;
|
||||
let part;
|
||||
|
@ -6,6 +6,34 @@ import API from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
/**
|
||||
* Keywords for inferring a status that will provide a deploy preview URL.
|
||||
*/
|
||||
const PREVIEW_CONTEXT_KEYWORDS = ['deploy'];
|
||||
|
||||
/**
|
||||
* Check a given status context string to determine if it provides a link to a
|
||||
* deploy preview. Checks for an exact match against `previewContext` if given,
|
||||
* otherwise checks for inclusion of a value from `PREVIEW_CONTEXT_KEYWORDS`.
|
||||
*/
|
||||
function isPreviewContext(context, previewContext) {
|
||||
if (previewContext) {
|
||||
return context === previewContext;
|
||||
}
|
||||
return PREVIEW_CONTEXT_KEYWORDS.some(keyword => context.includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a deploy preview URL from an array of statuses. By default, a
|
||||
* matching status is inferred via `isPreviewContext`.
|
||||
*/
|
||||
function getPreviewStatus(statuses, config) {
|
||||
const previewContext = config.getIn(['backend', 'preview_context']);
|
||||
return statuses.find(({ context }) => {
|
||||
return isPreviewContext(context, previewContext);
|
||||
});
|
||||
}
|
||||
|
||||
export default class GitHub {
|
||||
constructor(config, options = {}) {
|
||||
this.config = config;
|
||||
@ -222,6 +250,28 @@ export default class GitHub {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses GitHub's Statuses API to retrieve statuses, infers which is for a
|
||||
* deploy preview via `getPreviewStatus`. Returns the url provided by the
|
||||
* status, as well as the status state, which should be one of 'success',
|
||||
* 'pending', and 'failure'.
|
||||
*/
|
||||
async getDeployPreview(collection, slug) {
|
||||
const data = await this.api.retrieveMetadata(slug);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statuses = await this.api.getStatuses(data.pr.head);
|
||||
const deployStatus = getPreviewStatus(statuses, this.config);
|
||||
|
||||
if (deployStatus) {
|
||||
const { target_url, state } = deployStatus;
|
||||
return { url: target_url, status: state };
|
||||
}
|
||||
}
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) {
|
||||
return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus);
|
||||
}
|
||||
|
@ -30,6 +30,12 @@ export function applyDefaults(config) {
|
||||
return Map(defaults)
|
||||
.mergeDeep(config)
|
||||
.withMutations(map => {
|
||||
/**
|
||||
* Use `site_url` as default `display_url`.
|
||||
*/
|
||||
if (!map.get('display_url') && map.get('site_url')) {
|
||||
map.set('display_url', map.get('site_url'));
|
||||
}
|
||||
/**
|
||||
* Use media_folder as default public_folder.
|
||||
*/
|
||||
|
86
packages/netlify-cms-core/src/actions/deploys.js
Normal file
86
packages/netlify-cms-core/src/actions/deploys.js
Normal file
@ -0,0 +1,86 @@
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { currentBackend } from 'src/backend';
|
||||
import { selectDeployPreview } from 'Reducers';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
export const DEPLOY_PREVIEW_REQUEST = 'DEPLOY_PREVIEW_REQUEST';
|
||||
export const DEPLOY_PREVIEW_SUCCESS = 'DEPLOY_PREVIEW_SUCCESS';
|
||||
export const DEPLOY_PREVIEW_FAILURE = 'DEPLOY_PREVIEW_FAILURE';
|
||||
|
||||
export function deployPreviewLoading(collection, slug) {
|
||||
return {
|
||||
type: DEPLOY_PREVIEW_REQUEST,
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
slug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function deployPreviewLoaded(collection, slug, { url, status }) {
|
||||
return {
|
||||
type: DEPLOY_PREVIEW_SUCCESS,
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
slug,
|
||||
url,
|
||||
status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function deployPreviewError(collection, slug) {
|
||||
return {
|
||||
type: DEPLOY_PREVIEW_FAILURE,
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
slug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a deploy preview object from the registered backend.
|
||||
*/
|
||||
export function loadDeployPreview(collection, slug, entry, published, opts) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
|
||||
// Exit if currently fetching
|
||||
const deployState = selectDeployPreview(state, collection, slug);
|
||||
if (deployState && deployState.get('isFetching')) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(deployPreviewLoading(collection, slug));
|
||||
|
||||
try {
|
||||
/**
|
||||
* `getDeploy` is for published entries, while `getDeployPreview` is for
|
||||
* unpublished entries.
|
||||
*/
|
||||
const deploy = published
|
||||
? backend.getDeploy(collection, slug, entry)
|
||||
: await backend.getDeployPreview(collection, slug, entry, opts);
|
||||
if (deploy) {
|
||||
return dispatch(deployPreviewLoaded(collection, slug, deploy));
|
||||
}
|
||||
return dispatch(deployPreviewError(collection, slug));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
details: error.message,
|
||||
key: 'ui.toast.onFailToLoadDeployPreview',
|
||||
},
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(deployPreviewError(collection, slug));
|
||||
}
|
||||
};
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import { attempt, flatten, isError } from 'lodash';
|
||||
import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight } 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';
|
||||
@ -36,9 +38,67 @@ class LocalStorageAuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
const slugFormatter = (collection, entryData, slugConfig) => {
|
||||
function prepareSlug(slug) {
|
||||
return (
|
||||
slug
|
||||
// Convert slug to lower-case
|
||||
.toLocaleLowerCase()
|
||||
|
||||
// Remove single quotes.
|
||||
.replace(/[']/g, '')
|
||||
|
||||
// Replace periods with dashes.
|
||||
.replace(/[.]/g, '-')
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
function compileSlug(template, date, identifier = '', data = Map(), processor) {
|
||||
let missingRequiredDate;
|
||||
|
||||
const slug = template.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
|
||||
let replacement;
|
||||
if (dateParsers[key] && !date) {
|
||||
missingRequiredDate = true;
|
||||
return '';
|
||||
}
|
||||
|
||||
if (dateParsers[key]) {
|
||||
replacement = dateParsers[key](date);
|
||||
} else if (key === 'slug') {
|
||||
replacement = identifier.trim();
|
||||
} else {
|
||||
replacement = data.get(key, '').trim();
|
||||
}
|
||||
|
||||
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}}';
|
||||
const date = new Date();
|
||||
|
||||
const identifier = entryData.get(selectIdentifier(collection));
|
||||
if (!identifier) {
|
||||
@ -47,38 +107,13 @@ const slugFormatter = (collection, entryData, slugConfig) => {
|
||||
);
|
||||
}
|
||||
|
||||
const slug = template
|
||||
.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
|
||||
switch (field) {
|
||||
case 'year':
|
||||
return date.getFullYear();
|
||||
case 'month':
|
||||
return `0${date.getMonth() + 1}`.slice(-2);
|
||||
case 'day':
|
||||
return `0${date.getDate()}`.slice(-2);
|
||||
case 'hour':
|
||||
return `0${date.getHours()}`.slice(-2);
|
||||
case 'minute':
|
||||
return `0${date.getMinutes()}`.slice(-2);
|
||||
case 'second':
|
||||
return `0${date.getSeconds()}`.slice(-2);
|
||||
case 'slug':
|
||||
return identifier.trim();
|
||||
default:
|
||||
return entryData.get(field, '').trim();
|
||||
}
|
||||
})
|
||||
// Convert slug to lower-case
|
||||
.toLocaleLowerCase()
|
||||
// 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)]);
|
||||
|
||||
// Remove single quotes.
|
||||
.replace(/[']/g, '')
|
||||
|
||||
// Replace periods with dashes.
|
||||
.replace(/[.]/g, '-');
|
||||
|
||||
return sanitizeSlug(slug, slugConfig);
|
||||
};
|
||||
return processSlug(template, new Date(), identifier, entryData);
|
||||
}
|
||||
|
||||
const commitMessageTemplates = Map({
|
||||
create: 'Create {{collection}} “{{slug}}”',
|
||||
@ -120,8 +155,78 @@ 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
|
||||
* optional for backends that don't support them.
|
||||
*/
|
||||
if (!baseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a `previewPath` for the collection (via config), the preview URL
|
||||
* will be the URL provided by the backend.
|
||||
*/
|
||||
if (!collection.get('preview_path')) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a `previewPath` is provided for the collection, use it to construct the
|
||||
* URL path.
|
||||
*/
|
||||
const basePath = trimEnd(baseUrl, '/');
|
||||
const pathTemplate = collection.get('preview_path');
|
||||
const fields = entry.get('data');
|
||||
const date = parsePreviewPathDate(collection, entry);
|
||||
|
||||
// Prepare and sanitize slug variables only, leave the rest of the
|
||||
// `preview_path` template as is.
|
||||
const processSegment = flow([
|
||||
value => String(value),
|
||||
prepareSlug,
|
||||
partialRight(sanitizeSlug, slugConfig),
|
||||
]);
|
||||
let compiledPath;
|
||||
|
||||
try {
|
||||
compiledPath = compileSlug(pathTemplate, date, slug, fields, processSegment);
|
||||
} catch (err) {
|
||||
// Print an error and ignore `preview_path` if both:
|
||||
// 1. Date is invalid (according to Moment), and
|
||||
// 2. A date expression (eg. `{{year}}`) is used in `preview_path`
|
||||
if (err.name === SLUG_MISSING_REQUIRED_DATE) {
|
||||
console.error(stripIndent`
|
||||
Collection "${collection.get('name')}" configuration error:
|
||||
\`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
|
||||
`);
|
||||
return basePath;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const previewPath = trimStart(compiledPath, ' /');
|
||||
return `${basePath}/${previewPath}`;
|
||||
}
|
||||
|
||||
class Backend {
|
||||
constructor(implementation, { backendName, authStore = null, config } = {}) {
|
||||
this.config = config;
|
||||
this.implementation = implementation.init(config, {
|
||||
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW,
|
||||
updateUserCredentials: this.updateUserCredentials,
|
||||
@ -374,6 +479,75 @@ class Backend {
|
||||
.then(this.entryWithFormat(collection, slug));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a URL using `site_url` from the config and `preview_path` from the
|
||||
* entry's collection. Does not currently make a request through the backend,
|
||||
* but likely will in the future.
|
||||
*/
|
||||
getDeploy(collection, slug, entry) {
|
||||
/**
|
||||
* If `site_url` is undefiend or `show_preview_links` in the config is set to false, do nothing.
|
||||
*/
|
||||
|
||||
const baseUrl = this.config.get('site_url');
|
||||
|
||||
if (!baseUrl || this.config.get('show_preview_links') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
url: createPreviewUrl(baseUrl, collection, slug, this.config.get('slug'), entry),
|
||||
status: 'SUCCESS',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a base URL from the backend for previewing a specific entry.
|
||||
* Supports polling via `maxAttempts` and `interval` options, as there is
|
||||
* often a delay before a preview URL is available.
|
||||
*/
|
||||
async getDeployPreview(collection, slug, entry, { maxAttempts = 1, interval = 5000 } = {}) {
|
||||
/**
|
||||
* If the registered backend does not provide a `getDeployPreview` method, or
|
||||
* `show_preview_links` in the config is set to false, do nothing.
|
||||
*/
|
||||
if (!this.implementation.getDeployPreview || this.config.get('show_preview_links') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for the deploy preview URL (defaults to 1 attempt, so no polling by
|
||||
* default).
|
||||
*/
|
||||
let deployPreview,
|
||||
count = 0;
|
||||
while (!deployPreview && count < maxAttempts) {
|
||||
count++;
|
||||
deployPreview = await this.implementation.getDeployPreview(collection, slug);
|
||||
if (!deployPreview) {
|
||||
await new Promise(resolve => setTimeout(() => resolve(), interval));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If there's no deploy preview, do nothing.
|
||||
*/
|
||||
if (!deployPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Create a URL using the collection `preview_path`, if provided.
|
||||
*/
|
||||
url: createPreviewUrl(deployPreview.url, collection, slug, this.config.get('slug'), entry),
|
||||
/**
|
||||
* Always capitalize the status for consistency.
|
||||
*/
|
||||
status: deployPreview.status ? deployPreview.status.toUpperCase() : '',
|
||||
};
|
||||
}
|
||||
|
||||
persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) {
|
||||
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
|
||||
|
||||
|
@ -22,8 +22,9 @@ import {
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
} from 'Actions/editorialWorkflow';
|
||||
import { loadDeployPreview } from 'Actions/deploys';
|
||||
import { deserializeValues } from 'Lib/serializeEntryValues';
|
||||
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
|
||||
import { selectEntry, selectUnpublishedEntry, selectDeployPreview, getAsset } from 'Reducers';
|
||||
import { selectFields } from 'Reducers/collections';
|
||||
import { status } from 'Constants/publishModes';
|
||||
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
@ -64,6 +65,8 @@ class Editor extends React.Component {
|
||||
deleteUnpublishedEntry: PropTypes.func.isRequired,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
loadEntries: PropTypes.func.isRequired,
|
||||
deployPreview: ImmutablePropTypes.map,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
currentStatus: PropTypes.string,
|
||||
user: ImmutablePropTypes.map.isRequired,
|
||||
location: PropTypes.shape({
|
||||
@ -309,9 +312,14 @@ class Editor extends React.Component {
|
||||
isModification,
|
||||
currentStatus,
|
||||
logoutUser,
|
||||
deployPreview,
|
||||
loadDeployPreview,
|
||||
slug,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const isPublished = !newEntry && !unpublishedEntry;
|
||||
|
||||
if (entry && entry.get('error')) {
|
||||
return (
|
||||
<div>
|
||||
@ -351,6 +359,8 @@ class Editor extends React.Component {
|
||||
isModification={isModification}
|
||||
currentStatus={currentStatus}
|
||||
onLogoutClick={logoutUser}
|
||||
deployPreview={deployPreview}
|
||||
loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -373,6 +383,7 @@ function mapStateToProps(state, ownProps) {
|
||||
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
|
||||
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
|
||||
const deployPreview = selectDeployPreview(state, collectionName, slug);
|
||||
return {
|
||||
collection,
|
||||
collections,
|
||||
@ -389,6 +400,7 @@ function mapStateToProps(state, ownProps) {
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
currentStatus,
|
||||
deployPreview,
|
||||
};
|
||||
}
|
||||
|
||||
@ -399,6 +411,7 @@ export default connect(
|
||||
changeDraftFieldValidation,
|
||||
loadEntry,
|
||||
loadEntries,
|
||||
loadDeployPreview,
|
||||
createDraftFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
|
@ -169,6 +169,8 @@ class EditorInterface extends Component {
|
||||
isModification,
|
||||
currentStatus,
|
||||
onLogoutClick,
|
||||
loadDeployPreview,
|
||||
deployPreview,
|
||||
} = this.props;
|
||||
|
||||
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
|
||||
@ -239,6 +241,8 @@ class EditorInterface extends Component {
|
||||
isModification={isModification}
|
||||
currentStatus={currentStatus}
|
||||
onLogoutClick={onLogoutClick}
|
||||
loadDeployPreview={loadDeployPreview}
|
||||
deployPreview={deployPreview}
|
||||
/>
|
||||
<Editor>
|
||||
<ViewControls>
|
||||
@ -290,6 +294,8 @@ EditorInterface.propTypes = {
|
||||
isModification: PropTypes.bool,
|
||||
currentStatus: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
deployPreview: ImmutablePropTypes.map,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
||||
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled, { css } from 'react-emotion';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { Map } from 'immutable';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Icon,
|
||||
@ -55,6 +56,7 @@ const ToolbarSectionMain = styled.div`
|
||||
|
||||
const ToolbarSubSectionFirst = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
|
||||
@ -160,6 +162,36 @@ const StatusButton = styled(StyledDropdownButton)`
|
||||
color: ${colorsRaw.teal};
|
||||
`;
|
||||
|
||||
const PreviewButtonContainer = styled.div`
|
||||
margin-right: 12px;
|
||||
color: ${colorsRaw.blue};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a,
|
||||
${Icon} {
|
||||
color: ${colorsRaw.blue};
|
||||
}
|
||||
|
||||
${Icon} {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const RefreshPreviewButton = styled.button`
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: ${colorsRaw.blue};
|
||||
|
||||
span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewLink = RefreshPreviewButton.withComponent('a');
|
||||
|
||||
const StatusDropdownItem = styled(DropdownItem)`
|
||||
${Icon} {
|
||||
color: ${colors.infoText};
|
||||
@ -190,9 +222,25 @@ class EditorToolbar extends React.Component {
|
||||
isModification: PropTypes.bool,
|
||||
currentStatus: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
deployPreview: ImmutablePropTypes.map,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { isNewEntry, loadDeployPreview } = this.props;
|
||||
if (!isNewEntry) {
|
||||
loadDeployPreview({ maxAttempts: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { isNewEntry, isPersisting, loadDeployPreview } = this.props;
|
||||
if (!isNewEntry && prevProps.isPersisting && !isPersisting) {
|
||||
loadDeployPreview({ maxAttempts: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
renderSimpleSaveControls = () => {
|
||||
const { showDelete, onDelete, t } = this.props;
|
||||
return (
|
||||
@ -204,6 +252,34 @@ class EditorToolbar extends React.Component {
|
||||
);
|
||||
};
|
||||
|
||||
renderDeployPreviewControls = label => {
|
||||
const { deployPreview = Map(), loadDeployPreview, t } = this.props;
|
||||
const url = deployPreview.get('url');
|
||||
const status = deployPreview.get('status');
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFetching = deployPreview.get('isFetching');
|
||||
const deployPreviewReady = status === 'SUCCESS' && !isFetching;
|
||||
return (
|
||||
<PreviewButtonContainer>
|
||||
{deployPreviewReady ? (
|
||||
<PreviewLink rel="noopener noreferrer" target="_blank" href={url}>
|
||||
<span>{label}</span>
|
||||
<Icon type="new-tab" size="xsmall" />
|
||||
</PreviewLink>
|
||||
) : (
|
||||
<RefreshPreviewButton onClick={loadDeployPreview}>
|
||||
<span>{t('editor.editorToolbar.deployPreviewPendingButtonLabel')}</span>
|
||||
<Icon type="refresh" size="xsmall" />
|
||||
</RefreshPreviewButton>
|
||||
)}
|
||||
</PreviewButtonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
renderSimplePublishControls = () => {
|
||||
const {
|
||||
collection,
|
||||
@ -215,7 +291,12 @@ class EditorToolbar extends React.Component {
|
||||
t,
|
||||
} = this.props;
|
||||
if (!isNewEntry && !hasChanged) {
|
||||
return <StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>;
|
||||
return (
|
||||
<>
|
||||
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
|
||||
<StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
@ -302,6 +383,7 @@ class EditorToolbar extends React.Component {
|
||||
if (currentStatus) {
|
||||
return (
|
||||
<>
|
||||
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'))}
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="120px"
|
||||
@ -358,8 +440,16 @@ class EditorToolbar extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish control for published workflow entry.
|
||||
*/
|
||||
if (!isNewEntry) {
|
||||
return <StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>;
|
||||
return (
|
||||
<>
|
||||
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
|
||||
<StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -34,8 +34,10 @@ const getConfigSchema = () => ({
|
||||
properties: { name: { type: 'string', examples: ['test-repo'] } },
|
||||
required: ['name'],
|
||||
},
|
||||
site_url: { type: 'string', examples: ['https://example.com'] },
|
||||
display_url: { type: 'string', examples: ['https://example.com'] },
|
||||
logo_url: { type: 'string', examples: ['https://example.com/images/logo.svg'] },
|
||||
show_preview_links: { type: 'boolean' },
|
||||
media_folder: { type: 'string', examples: ['assets/uploads'] },
|
||||
public_folder: { type: 'string', examples: ['/uploads'] },
|
||||
media_library: {
|
||||
@ -86,7 +88,10 @@ const getConfigSchema = () => ({
|
||||
required: ['name', 'label', 'file', 'fields'],
|
||||
},
|
||||
},
|
||||
identifier_field: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
preview_path: { type: 'string' },
|
||||
preview_path_date_field: { type: 'string' },
|
||||
create: { type: 'boolean' },
|
||||
editor: {
|
||||
type: 'object',
|
||||
|
@ -78,6 +78,9 @@ export function getPhrases() {
|
||||
inReview: 'In review',
|
||||
ready: 'Ready',
|
||||
publishNow: 'Publish now',
|
||||
deployPreviewPendingButtonLabel: 'Check for Preview',
|
||||
deployPreviewButtonLabel: 'View Preview',
|
||||
deployButtonLabel: 'View Live',
|
||||
},
|
||||
editorWidgets: {
|
||||
unknownControl: {
|
||||
@ -119,6 +122,7 @@ export function getPhrases() {
|
||||
},
|
||||
toast: {
|
||||
onFailToLoadEntries: 'Failed to load entry: %{details}',
|
||||
onFailToLoadDeployPreview: 'Failed to load preview: %{details}',
|
||||
onFailToPersist: 'Failed to persist entry: %{details}',
|
||||
onFailToDelete: 'Failed to delete entry: %{details}',
|
||||
onFailToUpdateStatus: 'Failed to update status: %{details}',
|
||||
|
@ -27,6 +27,14 @@ export const INFERABLE_FIELDS = {
|
||||
fallbackToFirstField: false,
|
||||
showError: false,
|
||||
},
|
||||
date: {
|
||||
type: 'datetime',
|
||||
secondaryTypes: ['date'],
|
||||
synonyms: ['date', 'publishDate', 'publish_date'],
|
||||
defaultPreview: value => value,
|
||||
fallbackToFirstField: false,
|
||||
showError: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
secondaryTypes: ['text', 'markdown'],
|
||||
|
45
packages/netlify-cms-core/src/reducers/deploys.js
Normal file
45
packages/netlify-cms-core/src/reducers/deploys.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import {
|
||||
DEPLOY_PREVIEW_REQUEST,
|
||||
DEPLOY_PREVIEW_SUCCESS,
|
||||
DEPLOY_PREVIEW_FAILURE,
|
||||
} from 'Actions/deploys';
|
||||
|
||||
const deploys = (state = Map({ deploys: Map() }), action) => {
|
||||
switch (action.type) {
|
||||
case DEPLOY_PREVIEW_REQUEST: {
|
||||
const { collection, slug } = action.payload;
|
||||
return state.setIn(['deploys', `${collection}.${slug}`, 'isFetching'], true);
|
||||
}
|
||||
|
||||
case DEPLOY_PREVIEW_SUCCESS: {
|
||||
const { collection, slug, url, status } = action.payload;
|
||||
return state.setIn(
|
||||
['deploys', `${collection}.${slug}`],
|
||||
fromJS({
|
||||
isFetching: false,
|
||||
url,
|
||||
status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
case DEPLOY_PREVIEW_FAILURE: {
|
||||
const { collection, slug } = action.payload;
|
||||
return state.setIn(
|
||||
['deploys', `${collection}.${slug}`],
|
||||
fromJS({
|
||||
isFetching: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const selectDeployPreview = (state, collection, slug) =>
|
||||
state.getIn(['deploys', `${collection}.${slug}`]);
|
||||
|
||||
export default deploys;
|
@ -9,6 +9,7 @@ import collections from './collections';
|
||||
import search from './search';
|
||||
import mediaLibrary from './mediaLibrary';
|
||||
import medias, * as fromMedias from './medias';
|
||||
import deploys, * as fromDeploys from './deploys';
|
||||
import globalUI from './globalUI';
|
||||
|
||||
const reducers = {
|
||||
@ -23,6 +24,7 @@ const reducers = {
|
||||
entryDraft,
|
||||
mediaLibrary,
|
||||
medias,
|
||||
deploys,
|
||||
globalUI,
|
||||
};
|
||||
|
||||
@ -47,6 +49,9 @@ export const selectSearchedEntries = state => {
|
||||
);
|
||||
};
|
||||
|
||||
export const selectDeployPreview = (state, collection, slug) =>
|
||||
fromDeploys.selectDeployPreview(state.deploys, collection, slug);
|
||||
|
||||
export const selectUnpublishedEntry = (state, collection, slug) =>
|
||||
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);
|
||||
|
||||
|
@ -30,10 +30,12 @@ import iconMedia from './media.svg';
|
||||
import iconMediaAlt from './media-alt.svg';
|
||||
import iconNetlify from './netlify.svg';
|
||||
import iconNetlifyCms from './netlify-cms-logo.svg';
|
||||
import iconNewTab from './new-tab.svg';
|
||||
import iconPage from './page.svg';
|
||||
import iconPages from './pages.svg';
|
||||
import iconPagesAlt from './pages-alt.svg';
|
||||
import iconQuote from './quote.svg';
|
||||
import iconRefresh from './refresh.svg';
|
||||
import iconScroll from './scroll.svg';
|
||||
import iconSearch from './search.svg';
|
||||
import iconSettings from './settings.svg';
|
||||
@ -76,10 +78,12 @@ const images = {
|
||||
'media-alt': iconMediaAlt,
|
||||
netlify: iconNetlify,
|
||||
'netlify-cms': iconNetlifyCms,
|
||||
'new-tab': iconNewTab,
|
||||
page: iconPage,
|
||||
pages: iconPages,
|
||||
'pages-alt': iconPagesAlt,
|
||||
quote: iconQuote,
|
||||
refresh: iconRefresh,
|
||||
scroll: iconScroll,
|
||||
search: iconSearch,
|
||||
settings: iconSettings,
|
||||
|
@ -0,0 +1 @@
|
||||
<svg width="21" height="21" viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><g fill="#000" fill-rule="evenodd"><path d="M4.7 20.5H15c2.1 0 3.8-1.7 3.8-3.8v-6.4c0-.5-.4-.9-.8-.9-.5 0-1 .4-1 .9v6.4a2 2 0 0 1-2 2H4.7a2 2 0 0 1-2-2V6.2c0-1.1.8-2 2-2H11c.5 0 .8-.4.8-1 0-.4-.3-.8-.8-.8H4.7C2.6 2.4.9 4.1.9 6.2v10.5c0 2 1.7 3.8 3.8 3.8z"/><path d="M20.9 7.2V1.9v-.2h-.1v-.2l-.1-.1-.2-.2h-.1l-.2-.2H14.5c-.5 0-.8.3-.8.8s.3 1 .8 1H18l-8 8c-.4.3-.4.8 0 1.2.3.3.9.3 1.2 0l8-8v3.2c0 .5.4.9.9.9s.8-.4.9-1z"/></g></svg>
|
After Width: | Height: | Size: 513 B |
@ -0,0 +1 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M13.3 1A9.8 9.8 0 0 0 1.1 7a9.4 9.4 0 0 0 6.2 12c5 1.5 10.3-1 12.1-5.8.2-.6-.1-1.3-.7-1.5-.7-.2-1.4.1-1.6.7a7.3 7.3 0 0 1-9 4.3 7 7 0 0 1-4.7-8.9 7.3 7.3 0 0 1 12-2.8L13 7.4c-.5.5-.3.8.3.8h5.5c.7 0 1.2-.5 1.2-1.1V1.8c0-.7-.4-.8-.9-.4l-2 2c-1-1-2.3-1.9-3.8-2.4z" fill="#000" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 393 B |
65
website/content/blog/deploy-preview-links.md
Normal file
65
website/content/blog/deploy-preview-links.md
Normal file
@ -0,0 +1,65 @@
|
||||
---
|
||||
title: "Netlify CMS 2.4.0: Deploy Preview Links"
|
||||
author: Shawn Erquhart
|
||||
description: >-
|
||||
Deploy preview links from your GitHub repository are now surfaced in Netlify
|
||||
CMS for previewing builds of unpublished content.
|
||||
date: 2019-01-28T19:30:00.000Z
|
||||
twitter_image: /img/preview-link-unpublished.png
|
||||
---
|
||||
|
||||
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
|
||||
|
||||
## Seeing is believing
|
||||
|
||||
The editorial workflow allows editors to create draft content in Netlify CMS, and Netlify can
|
||||
provide deploy previews of draft content, but there hasn't been a way to access links to these
|
||||
preview builds from within Netlify CMS. The preview pane in the editor is a good tool for seeing how
|
||||
content will look on the site, but in the words of Marvin Gaye, "ain't nothing like the real thing!"
|
||||
As Mr. Gaye bemoaned the absence of his beloved, so content creators long for the warm embrace of an
|
||||
actual production build. Their words, not ours.
|
||||
|
||||
## Solution: GitHub Statuses
|
||||
|
||||
![GitHub statuses](/img/github-statuses-deploy-previews.png)
|
||||
|
||||
For sites using the GitHub (or Git Gateway with GitHub) backend, we now have deploy preview links in
|
||||
the CMS using the [GitHub Statuses
|
||||
API](https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref).
|
||||
Many static sites already have continuous deployment and deploy previews configured on their repo,
|
||||
and they often use statuses to provide a link to a deployment directly from a commit or pull
|
||||
request. To retrieve a commit status that provides a deploy preview URL, we check for a status whose
|
||||
"context" contains one of a list of keywords commonly associated with deploy previews.
|
||||
|
||||
If a status is not found, nothing happens in the UI. If a status is found, but the deploy preview
|
||||
isn't ready, we provide a "Check for Preview" link, allowing the content editor to manually check
|
||||
back until the preview is ready:
|
||||
|
||||
![Deploy preview link for unpublished content](/img/preview-link-check.png)
|
||||
|
||||
When the preview is ready, the "Check for Preview" button is replaced with a link to the content:
|
||||
|
||||
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
|
||||
|
||||
## Deep links
|
||||
Deploy preview links generally direct to the root of a site, but Netlify CMS can also link straight
|
||||
to the piece of content being edited. By [providing a string template](/docs/deploy-preview-links)
|
||||
for each collection, you can get links that go right where editors expect them to. More complex
|
||||
URL's can be constructed [using date
|
||||
information](/docs/deploy-preview-links/#dates-in-preview-paths) from your content files.
|
||||
|
||||
## Unpublished vs. published
|
||||
If you're not using the editorial workflow, you may not feel you need this very much. Whenever you
|
||||
save content, it's immediatlely published, so you can navigate to your live site to see the changes.
|
||||
That said, it's at least convenient to have a link direct to your content from the CMS, so deploy
|
||||
preview links can also work for CMS installs that do not use the editorial workflow. Instead of
|
||||
retrieving a URL from a commit status, this functionality requires setting a `site_url` in your
|
||||
config, and that URL is used in place of the deploy preview URL.
|
||||
|
||||
## GitLab and Bitbucket
|
||||
Support is coming soon for these two awesome backends! Stay tuned.
|
||||
|
||||
## Try it out!
|
||||
Deploy preview links are live in Netlify CMS 2.4.0. Please give them a try and let us know if you
|
||||
have any problems by [opening an issue](https://github.com/netlify/netlify-cms/issues/new) or
|
||||
reaching out in our [community chat on Gitter](https://gitter.im/netlify/netlifycms)!
|
@ -6,6 +6,9 @@ group: start
|
||||
|
||||
Netlify CMS stores content in your GitHub, GitLab, or Bitbucket repository. In order for this to work, authenticate with your Git host. In most cases that requires a server. We have a few options for handling this.
|
||||
|
||||
|
||||
**Note:** If you prefer to run your own authentication server, check out the section on [external OAuth clients](#external-oauth-clients).
|
||||
|
||||
**Note:** Some static site generators have plugins for optimized integration with Netlify CMS, and starter templates may utilize these plugins. If you're using a starter template, read the template documentation before proceeding, as their instructions may differ.
|
||||
|
||||
## Git Gateway with Netlify Identity
|
||||
@ -54,7 +57,20 @@ To enable basic GitHub authentication:
|
||||
repo: owner-name/repo-name # Path to your GitHub repository
|
||||
```
|
||||
|
||||
If you prefer to run your own authentication server, check out the section on [external OAuth clients](#external-oauth-clients).
|
||||
### Specifying a status for deploy previews
|
||||
The GitHub backend supports [deploy preview links](../deploy-preview-links). Netlify CMS checks the
|
||||
`context` of a commit's [statuses](https://help.github.com/articles/about-status-checks/) and infers
|
||||
one that seems to represent a deploy preview. If you need to customize this behavior, you can
|
||||
specify which context to look for using `preview_context`:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
name: github
|
||||
repo: my/repo
|
||||
preview_context: my-provider/deployment
|
||||
```
|
||||
|
||||
The above configuration would look for the status who's `"context"` is `"my-provider/deployment"`.
|
||||
|
||||
## GitLab Backend
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Cloudinary
|
||||
group: media
|
||||
weight: '10'
|
||||
weight: 10
|
||||
---
|
||||
Cloudinary is a digital asset management platform with a broad feature set, including support for responsive image generation and url based image transformation. They also provide a powerful media library UI for managing assets, and tools for organizing your assets into a hierarchy.
|
||||
|
||||
|
@ -85,10 +85,24 @@ media_library:
|
||||
publicKey: demopublickey
|
||||
```
|
||||
|
||||
## Site URL
|
||||
|
||||
The `site_url` setting should provide a URL to your published site. May be used by the CMS for
|
||||
various functionality. Used together with a collection's `preview_path` to create links to live
|
||||
content.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
display_url: https://your-site.com
|
||||
```
|
||||
|
||||
## Display URL
|
||||
|
||||
When the `display_url` setting is specified, the CMS UI will include a link in the fixed area at the top of the page, allowing content authors to easily return to your main site. The text of the link consists of the URL less the protocol portion (e.g., `your-site.com`).
|
||||
|
||||
Defaults to `site_url`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
@ -105,6 +119,16 @@ When the `logo_url` setting is specified, the CMS UI will change the logo displa
|
||||
logo_url: https://your-site.com/images/logo.svg
|
||||
```
|
||||
|
||||
## Show Preview Links
|
||||
|
||||
[Deploy preview links](../deploy-preview-links) can be disabled by setting `show_preview_links` to `false`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
show_preview_links: false
|
||||
```
|
||||
|
||||
## Slug Type
|
||||
|
||||
The `slug` option allows you to change how filenames for entries are created and sanitized. For modifying the actual data in a slug, see the per-collection option below.
|
||||
@ -135,6 +159,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
|
||||
`collections` accepts a list of collection objects, each with the following options:
|
||||
|
||||
- `name` (required): unique identifier for the collection, used as the key when referenced in other contexts (like the [relation widget](../widgets/#relation))
|
||||
- `identifier_field`: see detailed description below
|
||||
- `label`: label for the collection in the editor UI; defaults to the value of `name`
|
||||
- `label_singular`: singular label for certain elements in the editor; defaults to the value of `label`
|
||||
- `description`: optional text, displayed below the label when viewing a collection
|
||||
@ -146,11 +171,27 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
|
||||
- `format`: see detailed description below
|
||||
- `frontmatter_delimiter`: see detailed description under `format`
|
||||
- `slug`: see detailed description below
|
||||
- `preview_path`: see detailed description below
|
||||
- `fields` (required): see detailed description below
|
||||
- `editor`: see detailed description below
|
||||
|
||||
The last few options require more detailed information.
|
||||
|
||||
### `identifier_field`
|
||||
|
||||
Netlify CMS expects every entry to provide a field named `"title"` that serves as an identifier for
|
||||
the entry. The identifier field serves as an entry's title when viewing a list of entries, and is
|
||||
used in [slug](#slug) creation. If you would like to use a field other than `"title"` as the
|
||||
identifier, you can set `identifier_field` to the name of the other field.
|
||||
|
||||
**Example**
|
||||
|
||||
``` yaml
|
||||
collections:
|
||||
- name: posts
|
||||
identifier_field: name
|
||||
```
|
||||
|
||||
### `extension` and `format`
|
||||
|
||||
These settings determine how collection files are parsed and saved. Both are optional—Netlify CMS will attempt to infer your settings based on existing items in the collection. If your collection is empty, or you'd like more control, you can set these fields explicitly.
|
||||
@ -174,11 +215,11 @@ You may also specify a custom `extension` not included in the list above, as lon
|
||||
|
||||
### `slug`
|
||||
|
||||
For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field.)
|
||||
For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field (a different field can be used via [`identifier_field`](#identifier_field)).
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
- `{{slug}}`: a url-safe version of the `title` field for the file
|
||||
- `{{slug}}`: a url-safe version of the `title` field (or identifier field) for the file
|
||||
- `{{year}}`: 4-digit year of the file creation date
|
||||
- `{{month}}`: 2-digit month of the file creation date
|
||||
- `{{day}}`: 2-digit day of the month of the file creation date
|
||||
@ -192,6 +233,48 @@ For folder collections where users can create new items, the `slug` option speci
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{slug}}"
|
||||
```
|
||||
|
||||
|
||||
### `preview_path`
|
||||
A string representing the path where content in this collection can be found on the live site. This
|
||||
allows deploy preview links to direct to lead to a specific piece of content rather than the site
|
||||
root of a deploy preview.
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
- Any field can be referenced by wrapping the field name in double curly braces, eg. `{{author}}`
|
||||
- `{{slug}}`: the entire slug for the current entry (not just the url-safe identifier, as is the
|
||||
case with [`slug` configuration](#slug)
|
||||
|
||||
The following date based template tags are pulled from a date field in your entry, and may require additional configuration, see [`preview_path_date_field`](#preview_path_date_field) for details. If a date template tag is used and no date can be found, `preview_path` will be ignored.
|
||||
|
||||
- `{{year}}`: 4-digit year from entry data
|
||||
- `{{month}}`: 2-digit month from entry data
|
||||
- `{{day}}`: 2-digit day of the month from entry data
|
||||
- `{{hour}}`: 2-digit hour from entry data
|
||||
- `{{minute}}`: 2-digit minute from entry data
|
||||
- `{{second}}`: 2-digit second from entry data
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
collections:
|
||||
- name: posts
|
||||
preview_path: "blog/{{year}}/{{month}}/{{slug}}"
|
||||
```
|
||||
|
||||
### `preview_path_date_field`
|
||||
The name of a date field for parsing date-based template tags from `preview_path`. If this field is
|
||||
not provided and `preview_path` contains date-based template tags (eg. `{{year}}`), Netlify CMS will
|
||||
attempt to infer a usable date field by checking for common date field names, such as `date`. If you
|
||||
find that you need to specify a date field, you can use `preview_path_date_field` to tell Netlify
|
||||
CMS which field to use for preview path template tags.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
collections:
|
||||
- name: posts
|
||||
preview_path_date_field: "updated_on"
|
||||
```
|
||||
|
||||
### `fields`
|
||||
|
||||
The `fields` option maps editor UI widgets to field-value pairs in the saved file. The order of the fields in your Netlify CMS `config.yml` file determines their order in the editor UI and in the saved file.
|
||||
|
139
website/content/docs/deploy-preview-links.md
Normal file
139
website/content/docs/deploy-preview-links.md
Normal file
@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Deploy Preview Links
|
||||
group: features
|
||||
weight: 10
|
||||
---
|
||||
|
||||
When using the editorial workflow, content editors can create and save content without publishing it
|
||||
to a live site. Deploy preview links provide a way to view live content when it has not been
|
||||
published, provided that you're using a continuous deployment platform to provide "deploy previews"
|
||||
of your unmerged content.
|
||||
|
||||
**Note:** for the initial release, only the GitHub and Git Gateway (with GitHub) backends will
|
||||
support deploy preview links. Others should follow shortly.
|
||||
|
||||
## Using deploy preview links
|
||||
Deploy preview links will work without configuration when all of the following requirements are met:
|
||||
|
||||
- Netlify CMS version is 2.4.0+
|
||||
- Using the GitHub backend (or Git Gateway with a GitHub repository)
|
||||
- Using editorial workflow
|
||||
- Have a continous deployment platform that builds every commit and provides statuses to your repo
|
||||
|
||||
Any site created using one of the Deploy to Netlify options on our [starters
|
||||
page](../start-with-a-template) will automatically meet these criteria (barring any changes made to
|
||||
your Netlify settings), but you may need to [update](../update-the-cms-version) your Netlify CMS version to get the
|
||||
functionality.
|
||||
|
||||
**Note:** If you're using a custom backend (one that is not included with Netlify CMS), please check the
|
||||
documentation for that backend for more information about enabling deploy preview links.
|
||||
|
||||
Deploy preview links are provided in the editor toolbar, near the publishing controls:
|
||||
|
||||
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
|
||||
|
||||
### Waiting for builds
|
||||
Deploy your site preview may take ten seconds or ten minutes, depending on many factors. For maximum
|
||||
flexibility, Netlify CMS provides a "Check for Preview" refresh button when the deploy preview is
|
||||
pending, which a content editor can use to manually check for a finished preview until it's ready:
|
||||
|
||||
![Deploy preview link for unpublished content](/img/preview-link-check.png)
|
||||
|
||||
## Configuring preview paths
|
||||
Deploy preview links point to the site root by default, but you'll probably want them to point to
|
||||
the specific piece of content that the content editor is viewing. You can do this by providing a
|
||||
`preview_path` string template for each collection.
|
||||
|
||||
Let's say we have a `blog` collection that stores content in our repo under `content/blog`. The path
|
||||
to a post in your repo may look like `content/blog/2018-01-new-post.md`, but the path to that post
|
||||
on your site would look more like: `/blog/2018-01-new-post/`. Here's how you would use
|
||||
`preview_path` in your configuration for this scenario:
|
||||
|
||||
```yml
|
||||
collections:
|
||||
- name: blog
|
||||
folder: content/blog
|
||||
slug: {{year}}-{{month}}-{{slug}}
|
||||
preview_path: blog/{{slug}}
|
||||
```
|
||||
|
||||
With the above configuration, the deploy preview URL from your backend will be combined with your
|
||||
preview path to create a URL to a specific blog post.
|
||||
|
||||
**Note:** `{{slug}}` in `preview_path` is different than `{{slug}}` in `slug`. In the `slug`
|
||||
template, `{{slug}}` is only the url-safe [identifier
|
||||
field](../configuration-options/#identifier_field), while in the `preview_path` template, `{{slug}}`
|
||||
is the entire slug for the entry. For example:
|
||||
|
||||
```yml
|
||||
# for an entry created Jan 1, 2000 with identifier "My New Post!"
|
||||
|
||||
collections:
|
||||
- name: posts
|
||||
slug: {{year}}-{{month}}-{{slug}} # {{slug}} will compile to "my-new-post"
|
||||
preview_path: blog/{{slug}} # {{slug}} will compile to "2000-01-my-new-post"
|
||||
```
|
||||
|
||||
### Dates in preview paths
|
||||
Some static site generators allow URL's to be customized with date parameters - for example, Hugo
|
||||
can be configured to use values like `year` and `month` in a URL. These values are generally derived
|
||||
by the static site generator from a date field in the content file. `preview_path` accepts these
|
||||
parameters as well, similar to the `slug` configuration, except `preview_path` populates date values
|
||||
based on a date value from the entry, just like static site generators do. Netlify CMS will attempt
|
||||
to infer an obvious date field, but you can also specify which date field to use for `preview_path`
|
||||
template tags by using
|
||||
[`preview_path_date_field`](../configuration-options/#preview_path_date_field).
|
||||
|
||||
Together with your other field values, dates can be used to configure most URL schemes available
|
||||
through static site generators.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
# This collection's date field will be inferred because it has a field named `"date"`
|
||||
|
||||
collections:
|
||||
- name: posts
|
||||
preview_path: blog/{{year}}/{{month}}/{{title}}
|
||||
fields:
|
||||
- { name: title, label: Title }
|
||||
{ name: date, label: Date, widget: date }
|
||||
{ name: body, label: Body, widget: markdown }
|
||||
|
||||
# This collection requires `path_preview_date_field` because the no obvious date field is available
|
||||
|
||||
collections:
|
||||
- name: posts
|
||||
preview_path: blog/{{year}}/{{month}}/{{title}}
|
||||
preview_path_date_field: published_at
|
||||
fields:
|
||||
- { name: title, label: Title }
|
||||
{ name: published_at, label: Published At, widget: date }
|
||||
{ name: body, label: Body, widget: markdown }
|
||||
```
|
||||
|
||||
## Preview links for published content
|
||||
You may also want preview links for published content as a convenience. You can do this by providing
|
||||
a `site_url` in your configuration, which will be used in place of the deploy preview URL that a
|
||||
backend would provide for an unpublished entry. Just as for deploy preview links to unpublished
|
||||
content, inks to published content will use any `preview_path` values that are defined in the
|
||||
collection configurations.
|
||||
|
||||
Preview links for published content will also work if you are not using the editorial workflow.
|
||||
|
||||
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
|
||||
|
||||
## Disabling deploy preview links
|
||||
To disable deploy preview links, set `show_preview_links` to false in your CMS configuration.
|
||||
|
||||
## How it works
|
||||
Deploy preview links are provided through your CMS backend, and Netlify CMS is unopinionated about
|
||||
where the links come from or how they're created. That said, the general approach for Git backends
|
||||
like GitHub is powered by "commit statuses". Continuous deployment platforms like Netlify can deploy
|
||||
a version of your site for every commit that is pushed to your remote Git repository, and then send
|
||||
a commit status back to your repository host with the URL.
|
||||
|
||||
The deploy preview URL provided by a backend will lead to the root of the deployed site. Netlify CMS
|
||||
will then use the `preview_path` template in an entry's collection configuration to build a path to
|
||||
a specific piece of content. If a `preview_path` is not provided for an entry's collection, the URL
|
||||
will be used as is.
|
@ -11,6 +11,10 @@ module.exports = {
|
||||
name: 'start',
|
||||
title: 'Quick Start',
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
title: 'Features',
|
||||
},
|
||||
{
|
||||
name: 'reference',
|
||||
title: 'Reference',
|
||||
|
Loading…
x
Reference in New Issue
Block a user