feat: add publish configuration option to collections (#3467)

This commit is contained in:
Erez Rokah 2020-03-23 12:01:37 +02:00 committed by GitHub
parent 2f86d6fc36
commit df33bc64a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 331 additions and 226 deletions

View File

@ -23,6 +23,7 @@ import {
duplicateEntry, duplicateEntry,
} from '../utils/steps'; } from '../utils/steps';
import { setting1, setting2, workflowStatus, editorStatus, publishTypes } from '../utils/constants'; import { setting1, setting2, workflowStatus, editorStatus, publishTypes } from '../utils/constants';
import { fromJS } from 'immutable';
const entry1 = { const entry1 = {
title: 'first title', title: 'first title',
@ -145,4 +146,65 @@ describe('Test Backend Editorial Workflow', () => {
publishEntryInEditor(publishTypes.publishNow); publishEntryInEditor(publishTypes.publishNow);
duplicateEntry(entry1); duplicateEntry(entry1);
}); });
it('cannot publish when "publish" is false', () => {
cy.visit('/', {
onBeforeLoad: window => {
window.CMS_MANUAL_INIT = true;
},
onLoad: window => {
window.CMS.init({
config: fromJS({
backend: {
name: 'test-repo',
},
publish_mode: 'editorial_workflow',
load_config_file: false,
media_folder: 'assets/uploads',
collections: [
{
label: 'Posts',
name: 'post',
folder: '_posts',
label_singular: 'Post',
create: true,
publish: false,
fields: [
{ label: 'Title', name: 'title', widget: 'string', tagname: 'h1' },
{
label: 'Publish Date',
name: 'date',
widget: 'datetime',
dateFormat: 'YYYY-MM-DD',
timeFormat: 'HH:mm',
format: 'YYYY-MM-DD HH:mm',
},
{
label: 'Cover Image',
name: 'image',
widget: 'image',
required: false,
tagname: '',
},
{
label: 'Body',
name: 'body',
widget: 'markdown',
hint: 'Main content goes here.',
},
],
},
],
}),
});
},
});
cy.contains('button', 'Login').click();
createPost(entry1);
cy.contains('span', 'Publish').should('not.exist');
exitEditor();
goToWorkflow();
updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready);
cy.contains('button', 'Publish new entry').should('not.exist');
});
}); });

View File

@ -1,4 +1,4 @@
import { attempt, isError, take, unset } from 'lodash'; import { attempt, isError, take, unset, isEmpty } from 'lodash';
import uuid from 'uuid/v4'; import uuid from 'uuid/v4';
import { import {
EditorialWorkflowError, EditorialWorkflowError,
@ -243,6 +243,7 @@ export default class TestBackend implements Implementation {
}, },
slug, slug,
mediaFiles: assetProxies.map(this.normalizeAsset), mediaFiles: assetProxies.map(this.normalizeAsset),
isModification: !isEmpty(getFile(path)),
}; };
unpubStore.push(unpubEntry); unpubStore.push(unpubEntry);
} }

View File

@ -55,6 +55,41 @@ describe('config', () => {
}); });
}); });
describe('slug', () => {
it('should set default slug config if not set', () => {
expect(applyDefaults(fromJS({ collections: [] })).get('slug')).toEqual(
fromJS({ encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }),
);
});
it('should not overwrite slug encoding if set', () => {
expect(
applyDefaults(fromJS({ collections: [], slug: { encoding: 'ascii' } })).getIn([
'slug',
'encoding',
]),
).toEqual('ascii');
});
it('should not overwrite slug clean_accents if set', () => {
expect(
applyDefaults(fromJS({ collections: [], slug: { clean_accents: true } })).getIn([
'slug',
'clean_accents',
]),
).toEqual(true);
});
it('should not overwrite slug sanitize_replacement if set', () => {
expect(
applyDefaults(fromJS({ collections: [], slug: { sanitize_replacement: '_' } })).getIn([
'slug',
'sanitize_replacement',
]),
).toEqual('_');
});
});
describe('collections', () => { describe('collections', () => {
it('should strip leading slashes from collection folder', () => { it('should strip leading slashes from collection folder', () => {
expect( expect(
@ -62,8 +97,8 @@ describe('config', () => {
fromJS({ fromJS({
collections: [{ folder: '/foo' }], collections: [{ folder: '/foo' }],
}), }),
).get('collections'), ).getIn(['collections', 0, 'folder']),
).toEqual(fromJS([{ folder: 'foo' }])); ).toEqual('foo');
}); });
it('should strip leading slashes from collection files', () => { it('should strip leading slashes from collection files', () => {
@ -72,43 +107,8 @@ describe('config', () => {
fromJS({ fromJS({
collections: [{ files: [{ file: '/foo' }] }], collections: [{ files: [{ file: '/foo' }] }],
}), }),
).get('collections'), ).getIn(['collections', 0, 'files', 0, 'file']),
).toEqual(fromJS([{ files: [{ file: 'foo' }] }])); ).toEqual('foo');
});
describe('slug', () => {
it('should set default slug config if not set', () => {
expect(applyDefaults(fromJS({ collections: [] })).get('slug')).toEqual(
fromJS({ encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }),
);
});
it('should not overwrite slug encoding if set', () => {
expect(
applyDefaults(fromJS({ collections: [], slug: { encoding: 'ascii' } })).getIn([
'slug',
'encoding',
]),
).toEqual('ascii');
});
it('should not overwrite slug clean_accents if set', () => {
expect(
applyDefaults(fromJS({ collections: [], slug: { clean_accents: true } })).getIn([
'slug',
'clean_accents',
]),
).toEqual(true);
});
it('should not overwrite slug sanitize_replacement if set', () => {
expect(
applyDefaults(fromJS({ collections: [], slug: { sanitize_replacement: '_' } })).getIn([
'slug',
'sanitize_replacement',
]),
).toEqual('_');
});
}); });
describe('public_folder and media_folder', () => { describe('public_folder and media_folder', () => {
@ -118,16 +118,8 @@ describe('config', () => {
fromJS({ fromJS({
collections: [{ folder: 'foo', media_folder: 'static/images/docs' }], collections: [{ folder: 'foo', media_folder: 'static/images/docs' }],
}), }),
).get('collections'), ).getIn(['collections', 0, 'public_folder']),
).toEqual( ).toEqual('static/images/docs');
fromJS([
{
folder: 'foo',
media_folder: 'static/images/docs',
public_folder: 'static/images/docs',
},
]),
);
}); });
it('should not overwrite collection public_folder if set', () => { it('should not overwrite collection public_folder if set', () => {
@ -142,30 +134,42 @@ describe('config', () => {
}, },
], ],
}), }),
).get('collections'), ).getIn(['collections', 0, 'public_folder']),
).toEqual( ).toEqual('images/docs');
fromJS([
{
folder: 'foo',
media_folder: 'static/images/docs',
public_folder: 'images/docs',
},
]),
);
}); });
it("should set collection media_folder and public_folder to an empty string when collection path exists, but collection media_folder doesn't", () => { it("should set collection media_folder and public_folder to an empty string when collection path exists, but collection media_folder doesn't", () => {
const result = applyDefaults(
fromJS({
collections: [{ folder: 'foo', path: '{{slug}}/index' }],
}),
);
expect(result.getIn(['collections', 0, 'media_folder'])).toEqual('');
expect(result.getIn(['collections', 0, 'public_folder'])).toEqual('');
});
});
describe('publish', () => {
it('should set publish to true if not set', () => {
expect( expect(
applyDefaults( applyDefaults(
fromJS({ fromJS({
collections: [{ folder: 'foo', path: '{{slug}}/index' }], collections: [{ folder: 'foo', media_folder: 'static/images/docs' }],
}), }),
).get('collections'), ).getIn(['collections', 0, 'publish']),
).toEqual( ).toEqual(true);
fromJS([ });
{ folder: 'foo', path: '{{slug}}/index', media_folder: '', public_folder: '' },
]), it('should not override existing publish config', () => {
); expect(
applyDefaults(
fromJS({
collections: [
{ folder: 'foo', media_folder: 'static/images/docs', publish: false },
],
}),
).getIn(['collections', 0, 'publish']),
).toEqual(false);
}); });
}); });
}); });

View File

@ -58,6 +58,10 @@ export function applyDefaults(config) {
map.set( map.set(
'collections', 'collections',
map.get('collections').map(collection => { map.get('collections').map(collection => {
if (!collection.has('publish')) {
collection = collection.set('publish', true);
}
const folder = collection.get('folder'); const folder = collection.get('folder');
if (folder) { if (folder) {
if (collection.has('path') && !collection.has('media_folder')) { if (collection.has('path') && !collection.has('media_folder')) {

View File

@ -301,48 +301,146 @@ class EditorToolbar extends React.Component {
); );
}; };
renderSimplePublishControls = () => { renderWorkflowStatusControls = () => {
const { const { isUpdatingStatus, onChangeStatus, currentStatus, t, useOpenAuthoring } = this.props;
collection, return (
onPersist, <ToolbarDropdown
onPersistAndNew, dropdownTopOverlap="40px"
onPersistAndDuplicate, dropdownWidth="120px"
onDuplicate, renderButton={() => (
isPersisting, <StatusButton>
hasChanged, {isUpdatingStatus
isNewEntry, ? t('editor.editorToolbar.updating')
t, : t('editor.editorToolbar.setStatus')}
} = this.props; </StatusButton>
)}
>
<StatusDropdownItem
label={t('editor.editorToolbar.draft')}
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') ? 'check' : null}
/>
<StatusDropdownItem
label={t('editor.editorToolbar.inReview')}
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null}
/>
{useOpenAuthoring ? (
''
) : (
<StatusDropdownItem
label={t('editor.editorToolbar.ready')}
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null}
/>
)}
</ToolbarDropdown>
);
};
renderNewEntryWorkflowPublishControls = ({ canCreate, canPublish }) => {
const { isPublishing, onPublish, onPublishAndNew, onPublishAndDuplicate, t } = this.props;
return canPublish ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>
{isPublishing
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{canCreate ? (
<>
<DropdownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPublishAndNew}
/>
<DropdownItem
label={t('editor.editorToolbar.publishAndDuplicate')}
icon="add"
onClick={onPublishAndDuplicate}
/>
</>
) : null}
</ToolbarDropdown>
) : (
''
);
};
renderExistingEntryWorkflowPublishControls = ({ canCreate, canPublish }) => {
const { unPublish, onDuplicate, isPersisting, t } = this.props;
return canPublish || canCreate ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishedToolbarButton>
{isPersisting
? t('editor.editorToolbar.unpublishing')
: t('editor.editorToolbar.published')}
</PublishedToolbarButton>
)}
>
{canPublish && (
<DropdownItem
label={t('editor.editorToolbar.unpublish')}
icon="arrow"
iconDirection="right"
onClick={unPublish}
/>
)}
{canCreate && (
<DropdownItem
label={t('editor.editorToolbar.duplicate')}
icon="add"
onClick={onDuplicate}
/>
)}
</ToolbarDropdown>
) : (
''
);
};
renderExistingEntrySimplePublishControls = ({ canCreate }) => {
const { onDuplicate, t } = this.props;
return canCreate ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishedToolbarButton>{t('editor.editorToolbar.published')}</PublishedToolbarButton>
)}
>
{
<DropdownItem
label={t('editor.editorToolbar.duplicate')}
icon="add"
onClick={onDuplicate}
/>
}
</ToolbarDropdown>
) : (
<PublishedButton>{t('editor.editorToolbar.published')}</PublishedButton>
);
};
renderNewEntrySimplePublishControls = ({ canCreate }) => {
const { onPersist, onPersistAndNew, onPersistAndDuplicate, isPersisting, t } = this.props;
const canCreate = collection.get('create');
if (!isNewEntry && !hasChanged) {
return (
<>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
{canCreate ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishedToolbarButton>
{t('editor.editorToolbar.published')}
</PublishedToolbarButton>
)}
>
{
<DropdownItem
label={t('editor.editorToolbar.duplicate')}
icon="add"
onClick={onDuplicate}
/>
}
</ToolbarDropdown>
) : (
<PublishedButton>{t('editor.editorToolbar.published')}</PublishedButton>
)}
</>
);
}
return ( return (
<div> <div>
<ToolbarDropdown <ToolbarDropdown
@ -381,6 +479,21 @@ class EditorToolbar extends React.Component {
); );
}; };
renderSimplePublishControls = () => {
const { collection, hasChanged, isNewEntry, t } = this.props;
const canCreate = collection.get('create');
if (!isNewEntry && !hasChanged) {
return (
<>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
{this.renderExistingEntrySimplePublishControls({ canCreate })}
</>
);
}
return this.renderNewEntrySimplePublishControls({ canCreate });
};
renderWorkflowSaveControls = () => { renderWorkflowSaveControls = () => {
const { const {
onPersist, onPersist,
@ -421,95 +534,17 @@ class EditorToolbar extends React.Component {
}; };
renderWorkflowPublishControls = () => { renderWorkflowPublishControls = () => {
const { const { collection, currentStatus, isNewEntry, useOpenAuthoring, t } = this.props;
collection,
isUpdatingStatus,
isPublishing,
onChangeStatus,
onPublish,
unPublish,
onDuplicate,
onPublishAndNew,
onPublishAndDuplicate,
currentStatus,
isNewEntry,
useOpenAuthoring,
isPersisting,
t,
} = this.props;
const canCreate = collection.get('create'); const canCreate = collection.get('create');
const canPublish = collection.get('publish') && !useOpenAuthoring;
if (currentStatus) { if (currentStatus) {
return ( return (
<> <>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'))} {this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'))}
<ToolbarDropdown {this.renderWorkflowStatusControls()}
dropdownTopOverlap="40px" {this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish })}
dropdownWidth="120px"
renderButton={() => (
<StatusButton>
{isUpdatingStatus
? t('editor.editorToolbar.updating')
: t('editor.editorToolbar.setStatus')}
</StatusButton>
)}
>
<StatusDropdownItem
label={t('editor.editorToolbar.draft')}
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') ? 'check' : null}
/>
<StatusDropdownItem
label={t('editor.editorToolbar.inReview')}
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null}
/>
{useOpenAuthoring ? (
''
) : (
<StatusDropdownItem
label={t('editor.editorToolbar.ready')}
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null}
/>
)}
</ToolbarDropdown>
{useOpenAuthoring ? (
''
) : (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>
{isPublishing
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{canCreate ? (
<>
<DropdownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPublishAndNew}
/>
<DropdownItem
label={t('editor.editorToolbar.publishAndDuplicate')}
icon="add"
onClick={onPublishAndDuplicate}
/>
</>
) : null}
</ToolbarDropdown>
)}
</> </>
); );
} }
@ -521,31 +556,7 @@ class EditorToolbar extends React.Component {
return ( return (
<> <>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))} {this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
<ToolbarDropdown {this.renderExistingEntryWorkflowPublishControls({ canCreate, canPublish })}
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishedToolbarButton>
{isPersisting
? t('editor.editorToolbar.unpublishing')
: t('editor.editorToolbar.published')}
</PublishedToolbarButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.unpublish')}
icon="arrow"
iconDirection="right"
onClick={unPublish}
/>
{canCreate && (
<DropdownItem
label={t('editor.editorToolbar.duplicate')}
icon="add"
onClick={onDuplicate}
/>
)}
</ToolbarDropdown>
</> </>
); );
} }

View File

@ -53,7 +53,7 @@ const WorkflowTopDescription = styled.p`
class Workflow extends Component { class Workflow extends Component {
static propTypes = { static propTypes = {
collections: ImmutablePropTypes.orderedMap, collections: ImmutablePropTypes.orderedMap.isRequired,
isEditorialWorkflow: PropTypes.bool.isRequired, isEditorialWorkflow: PropTypes.bool.isRequired,
isOpenAuthoring: PropTypes.bool, isOpenAuthoring: PropTypes.bool,
isFetching: PropTypes.bool, isFetching: PropTypes.bool,

View File

@ -126,6 +126,7 @@ const WorkflowCard = ({
editLink, editLink,
timestamp, timestamp,
onDelete, onDelete,
allowPublish,
canPublish, canPublish,
onPublish, onPublish,
t, t,
@ -143,11 +144,13 @@ const WorkflowCard = ({
? t('workflow.workflowCard.deleteChanges') ? t('workflow.workflowCard.deleteChanges')
: t('workflow.workflowCard.deleteNewEntry')} : t('workflow.workflowCard.deleteNewEntry')}
</DeleteButton> </DeleteButton>
<PublishButton disabled={!canPublish} onClick={onPublish}> {allowPublish && (
{isModification <PublishButton disabled={!canPublish} onClick={onPublish}>
? t('workflow.workflowCard.publishChanges') {isModification
: t('workflow.workflowCard.publishNewEntry')} ? t('workflow.workflowCard.publishChanges')
</PublishButton> : t('workflow.workflowCard.publishNewEntry')}
</PublishButton>
)}
</CardButtonContainer> </CardButtonContainer>
</WorkflowCardContainer> </WorkflowCardContainer>
); );
@ -161,6 +164,7 @@ WorkflowCard.propTypes = {
editLink: PropTypes.string.isRequired, editLink: PropTypes.string.isRequired,
timestamp: PropTypes.string.isRequired, timestamp: PropTypes.string.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
allowPublish: PropTypes.bool.isRequired,
canPublish: PropTypes.bool.isRequired, canPublish: PropTypes.bool.isRequired,
onPublish: PropTypes.func.isRequired, onPublish: PropTypes.func.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,

View File

@ -134,7 +134,7 @@ class WorkflowList extends React.Component {
handleDelete: PropTypes.func.isRequired, handleDelete: PropTypes.func.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
isOpenAuthoring: PropTypes.bool, isOpenAuthoring: PropTypes.bool,
collections: ImmutablePropTypes.orderedMap, collections: ImmutablePropTypes.orderedMap.isRequired,
}; };
handleChangeStatus = (newStatus, dragProps) => { handleChangeStatus = (newStatus, dragProps) => {
@ -210,11 +210,15 @@ class WorkflowList extends React.Component {
const editLink = `collections/${entry.getIn(['metaData', 'collection'])}/entries/${slug}`; const editLink = `collections/${entry.getIn(['metaData', 'collection'])}/entries/${slug}`;
const ownStatus = entry.getIn(['metaData', 'status']); const ownStatus = entry.getIn(['metaData', 'status']);
const collectionName = entry.getIn(['metaData', 'collection']); const collectionName = entry.getIn(['metaData', 'collection']);
const collectionLabel = collections const collection = collections.find(
?.find(collection => collection.get('name') === collectionName) collection => collection.get('name') === collectionName,
?.get('label'); );
const collectionLabel = collection?.get('label');
const isModification = entry.get('isModification'); const isModification = entry.get('isModification');
const allowPublish = collection?.get('publish');
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false); const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
return ( return (
<DragSource <DragSource
namespace={DNDNamespace} namespace={DNDNamespace}
@ -235,6 +239,7 @@ class WorkflowList extends React.Component {
editLink={editLink} editLink={editLink}
timestamp={timestamp} timestamp={timestamp}
onDelete={this.requestDelete.bind(this, collectionName, slug, ownStatus)} onDelete={this.requestDelete.bind(this, collectionName, slug, ownStatus)}
allowPublish={allowPublish}
canPublish={canPublish} canPublish={canPublish}
onPublish={this.requestPublish.bind(this, collectionName, slug, ownStatus)} onPublish={this.requestPublish.bind(this, collectionName, slug, ownStatus)}
/> />

View File

@ -152,5 +152,17 @@ describe('config', () => {
); );
}).not.toThrowError(); }).not.toThrowError();
}); });
it('should throw if collection publish is not a boolean', () => {
expect(() => {
validateConfig(merge(validConfig, { collections: [{ publish: 'false' }] }));
}).toThrowError("'collections[0].publish' should be boolean");
});
it('should not throw if collection publish is a boolean', () => {
expect(() => {
validateConfig(merge(validConfig, { collections: [{ publish: false }] }));
}).not.toThrowError();
});
}); });
}); });

View File

@ -117,6 +117,7 @@ const getConfigSchema = () => ({
preview_path: { type: 'string' }, preview_path: { type: 'string' },
preview_path_date_field: { type: 'string' }, preview_path_date_field: { type: 'string' },
create: { type: 'boolean' }, create: { type: 'boolean' },
publish: { type: 'boolean' },
editor: { editor: {
type: 'object', type: 'object',
properties: { properties: {

View File

@ -194,6 +194,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
* `files` or `folder` (requires one of these): specifies the collection type and location; details in [Collection Types](../collection-types) * `files` or `folder` (requires one of these): specifies the collection type and location; details in [Collection Types](../collection-types)
* `filter`: optional filter for `folder` collections; details in [Collection Types](../collection-types) * `filter`: optional filter for `folder` collections; details in [Collection Types](../collection-types)
* `create`: for `folder` collections only; `true` allows users to create new items in the collection; defaults to `false` * `create`: for `folder` collections only; `true` allows users to create new items in the collection; defaults to `false`
* `publish`: for `publish_mode: editorial_workflow` only; `false` hides UI publishing controls for a collection; defaults to `true`
* `delete`: `false` prevents users from deleting items in a collection; defaults to `true` * `delete`: `false` prevents users from deleting items in a collection; defaults to `true`
* `extension`: see detailed description below * `extension`: see detailed description below
* `format`: see detailed description below * `format`: see detailed description below