diff --git a/lib/template-loader.php b/lib/template-loader.php
index f69957dc824a8..84ab186fe2ed1 100644
--- a/lib/template-loader.php
+++ b/lib/template-loader.php
@@ -262,3 +262,25 @@ function gutenberg_viewport_meta_tag() {
function gutenberg_strip_php_suffix( $template_file ) {
return preg_replace( '/\.php$/', '', $template_file );
}
+
+/**
+ * Extends default editor settings to enable template and template part editing.
+ *
+ * @param array $settings Default editor settings.
+ *
+ * @return array Filtered editor settings.
+ */
+function gutenberg_template_loader_filter_block_editor_settings( $settings ) {
+ if ( ! post_type_exists( 'wp_template' ) || ! post_type_exists( 'wp_template_part' ) ) {
+ return $settings;
+ }
+
+ // Create template part auto-drafts for the edited post.
+ foreach ( parse_blocks( get_post()->post_content ) as $block ) {
+ create_auto_draft_for_template_part_block( $block );
+ }
+
+ // TODO: Set editing mode and current template ID for editing modes support.
+ return $settings;
+}
+add_filter( 'block_editor_settings', 'gutenberg_template_loader_filter_block_editor_settings' );
diff --git a/lib/template-parts.php b/lib/template-parts.php
index e14b584ead3ad..62b892e812909 100644
--- a/lib/template-parts.php
+++ b/lib/template-parts.php
@@ -44,12 +44,14 @@ function gutenberg_register_template_part_post_type() {
'show_in_menu' => 'themes.php',
'show_in_admin_bar' => false,
'show_in_rest' => true,
+ 'rest_base' => 'template-parts',
'map_meta_cap' => true,
'supports' => array(
'title',
'slug',
'editor',
'revisions',
+ 'custom-fields',
),
);
diff --git a/package-lock.json b/package-lock.json
index e457d881cbbc9..ce1a819918986 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8503,6 +8503,7 @@
"requires": {
"@babel/runtime": "^7.4.4",
"@wordpress/api-fetch": "file:packages/api-fetch",
+ "@wordpress/blocks": "file:packages/blocks",
"@wordpress/data": "file:packages/data",
"@wordpress/deprecated": "file:packages/deprecated",
"@wordpress/element": "file:packages/element",
@@ -9096,6 +9097,7 @@
"version": "file:packages/url",
"requires": {
"@babel/runtime": "^7.4.4",
+ "lodash": "^4.17.15",
"qs": "^6.5.2"
}
},
diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js
index b9594fe7428ba..0ebe714800756 100644
--- a/packages/block-editor/src/components/inner-blocks/index.js
+++ b/packages/block-editor/src/components/inner-blocks/index.js
@@ -36,7 +36,12 @@ class InnerBlocks extends Component {
}
componentDidMount() {
- const { templateLock, block } = this.props;
+ const {
+ block,
+ templateLock,
+ __experimentalBlocks,
+ replaceInnerBlocks,
+ } = this.props;
const { innerBlocks } = block;
// Only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists directly on the block.
if ( innerBlocks.length === 0 || templateLock === 'all' ) {
@@ -48,10 +53,22 @@ class InnerBlocks extends Component {
templateInProcess: false,
} );
}
+
+ // Set controlled blocks value from parent, if any.
+ if ( __experimentalBlocks ) {
+ replaceInnerBlocks( __experimentalBlocks );
+ }
}
componentDidUpdate( prevProps ) {
- const { template, block, templateLock } = this.props;
+ const {
+ block,
+ templateLock,
+ template,
+ isLastBlockChangePersistent,
+ onInput,
+ onChange,
+ } = this.props;
const { innerBlocks } = block;
this.updateNestedSettings();
@@ -62,6 +79,14 @@ class InnerBlocks extends Component {
this.synchronizeBlocksWithTemplate();
}
}
+
+ // Sync with controlled blocks value from parent, if possible.
+ if ( prevProps.block.innerBlocks !== innerBlocks ) {
+ const resetFunc = isLastBlockChangePersistent ? onChange : onInput;
+ if ( resetFunc ) {
+ resetFunc( innerBlocks );
+ }
+ }
}
/**
@@ -141,6 +166,7 @@ InnerBlocks = compose( [
getBlockRootClientId,
getTemplateLock,
isNavigationMode,
+ isLastBlockChangePersistent,
} = select( 'core/block-editor' );
const { clientId, isSmallScreen } = ownProps;
const block = getBlock( clientId );
@@ -152,6 +178,7 @@ InnerBlocks = compose( [
hasOverlay: block.name !== 'core/template' && ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ),
parentLock: getTemplateLock( rootClientId ),
enableClickThrough: isNavigationMode() || isSmallScreen,
+ isLastBlockChangePersistent: isLastBlockChangePersistent(),
};
} ),
withDispatch( ( dispatch, ownProps ) => {
@@ -163,7 +190,13 @@ InnerBlocks = compose( [
return {
replaceInnerBlocks( blocks ) {
- replaceInnerBlocks( clientId, blocks, block.innerBlocks.length === 0 && templateInsertUpdatesSelection );
+ replaceInnerBlocks(
+ clientId,
+ blocks,
+ block.innerBlocks.length === 0 &&
+ templateInsertUpdatesSelection &&
+ blocks.length !== 0
+ );
},
updateNestedSettings( settings ) {
dispatch( updateBlockListSettings( clientId, settings ) );
diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss
index 80486c92d11e3..c32a1028be795 100644
--- a/packages/block-library/src/editor.scss
+++ b/packages/block-library/src/editor.scss
@@ -34,6 +34,7 @@
@import "./subhead/editor.scss";
@import "./table/editor.scss";
@import "./tag-cloud/editor.scss";
+@import "./template-part/editor.scss";
@import "./text-columns/editor.scss";
@import "./verse/editor.scss";
@import "./video/editor.scss";
diff --git a/packages/block-library/src/template-part/block.json b/packages/block-library/src/template-part/block.json
index 7d3870aff2d10..86807ca749e08 100644
--- a/packages/block-library/src/template-part/block.json
+++ b/packages/block-library/src/template-part/block.json
@@ -11,5 +11,8 @@
"theme": {
"type": "string"
}
+ },
+ "supports": {
+ "html": false
}
}
diff --git a/packages/block-library/src/template-part/edit.js b/packages/block-library/src/template-part/edit.js
deleted file mode 100644
index 66566932ed390..0000000000000
--- a/packages/block-library/src/template-part/edit.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function TemplatePartEdit() {
- return 'Template Part Placeholder';
-}
diff --git a/packages/block-library/src/template-part/edit/index.js b/packages/block-library/src/template-part/edit/index.js
new file mode 100644
index 0000000000000..d40180e17c783
--- /dev/null
+++ b/packages/block-library/src/template-part/edit/index.js
@@ -0,0 +1,50 @@
+/**
+ * WordPress dependencies
+ */
+import { useRef, useEffect } from '@wordpress/element';
+import { EntityProvider } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import useTemplatePartPost from './use-template-part-post';
+import TemplatePartInnerBlocks from './inner-blocks';
+import TemplatePartPlaceholder from './placeholder';
+
+export default function TemplatePartEdit( {
+ attributes: { postId: _postId, slug, theme },
+ setAttributes,
+} ) {
+ const initialPostId = useRef( _postId );
+ const initialSlug = useRef( slug );
+ const initialTheme = useRef( theme );
+
+ // Resolve the post ID if not set, and load its post.
+ const postId = useTemplatePartPost( _postId, slug, theme );
+
+ // Set the post ID, once found, so that edits persist.
+ useEffect( () => {
+ if (
+ ( initialPostId.current === undefined || initialPostId.current === null ) &&
+ postId !== undefined &&
+ postId !== null
+ ) {
+ setAttributes( { postId } );
+ }
+ }, [ postId ] );
+
+ if ( postId ) {
+ // Part of a template file, post ID already resolved.
+ return (
+
+
+
+ );
+ }
+ if ( ! initialSlug.current && ! initialTheme.current ) {
+ // Fresh new block.
+ return ;
+ }
+ // Part of a template file, post ID not resolved yet.
+ return null;
+}
diff --git a/packages/block-library/src/template-part/edit/inner-blocks.js b/packages/block-library/src/template-part/edit/inner-blocks.js
new file mode 100644
index 0000000000000..f6eb61341ca69
--- /dev/null
+++ b/packages/block-library/src/template-part/edit/inner-blocks.js
@@ -0,0 +1,22 @@
+/**
+ * WordPress dependencies
+ */
+import { useEntityBlockEditor } from '@wordpress/core-data';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+export default function TemplatePartInnerBlocks() {
+ const [ blocks, onInput, onChange ] = useEntityBlockEditor(
+ 'postType',
+ 'wp_template_part',
+ {
+ initialEdits: { status: 'publish' },
+ }
+ );
+ return (
+
+ );
+}
diff --git a/packages/block-library/src/template-part/edit/placeholder.js b/packages/block-library/src/template-part/edit/placeholder.js
new file mode 100644
index 0000000000000..2258b1a90e691
--- /dev/null
+++ b/packages/block-library/src/template-part/edit/placeholder.js
@@ -0,0 +1,122 @@
+/**
+ * WordPress dependencies
+ */
+import { useEntityBlockEditor, EntityProvider } from '@wordpress/core-data';
+import { __ } from '@wordpress/i18n';
+import { BlockPreview } from '@wordpress/block-editor';
+import { useState, useCallback } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { cleanForSlug } from '@wordpress/url';
+import { Placeholder, TextControl, Button } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import useTemplatePartPost from './use-template-part-post';
+
+function TemplatePartPreview() {
+ const [ blocks ] = useEntityBlockEditor( 'postType', 'wp_template_part' );
+ return (
+
+
+ { __( 'Preview' ) }
+
+
+
+ );
+}
+
+export default function TemplatePartPlaceholder( { setAttributes } ) {
+ const [ slug, _setSlug ] = useState();
+ const [ theme, setTheme ] = useState();
+ const [ help, setHelp ] = useState();
+
+ // Try to find an existing template part.
+ const postId = useTemplatePartPost( null, slug, theme );
+
+ // If found, get its preview.
+ const preview = useSelect(
+ ( select ) => {
+ if ( ! postId ) {
+ return;
+ }
+ const templatePart = select( 'core' ).getEntityRecord(
+ 'postType',
+ 'wp_template_part',
+ postId
+ );
+ if ( templatePart ) {
+ return (
+
+
+
+ );
+ }
+ },
+ [ postId ]
+ );
+
+ const setSlug = useCallback( ( nextSlug ) => {
+ _setSlug( nextSlug );
+ setHelp( cleanForSlug( nextSlug ) );
+ }, [] );
+
+ const { saveEntityRecord } = useDispatch( 'core' );
+ const onChooseOrCreate = useCallback( async () => {
+ const nextAttributes = { slug, theme };
+ if ( postId !== undefined && postId !== null ) {
+ // Existing template part found.
+ nextAttributes.postId = postId;
+ } else {
+ // Create a new template part.
+ try {
+ const cleanSlug = cleanForSlug( slug );
+ const templatePart = await saveEntityRecord(
+ 'postType',
+ 'wp_template_part',
+ {
+ title: cleanSlug,
+ status: 'publish',
+ slug: cleanSlug,
+ meta: { theme },
+ }
+ );
+ nextAttributes.postId = templatePart.id;
+ } catch ( err ) {
+ setHelp( __( 'Error adding template.' ) );
+ }
+ }
+ setAttributes( nextAttributes );
+ }, [ postId, slug, theme ] );
+ return (
+
+
+
+
+
+ { preview }
+
+
+ );
+}
diff --git a/packages/block-library/src/template-part/edit/use-template-part-post.js b/packages/block-library/src/template-part/edit/use-template-part-post.js
new file mode 100644
index 0000000000000..54391be3e0ce9
--- /dev/null
+++ b/packages/block-library/src/template-part/edit/use-template-part-post.js
@@ -0,0 +1,44 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+export default function useTemplatePartPost( postId, slug, theme ) {
+ return useSelect(
+ ( select ) => {
+ if ( postId ) {
+ // This is already a custom template part,
+ // use its CPT post.
+ return (
+ select( 'core' ).getEntityRecord(
+ 'postType',
+ 'wp_template_part',
+ postId
+ ) && postId
+ );
+ }
+
+ // This is not a custom template part,
+ // load the auto-draft created from the
+ // relevant file.
+ if ( slug && theme ) {
+ const posts = select( 'core' ).getEntityRecords(
+ 'postType',
+ 'wp_template_part',
+ {
+ status: 'auto-draft',
+ slug,
+ meta: { theme },
+ }
+ );
+ const foundPost =
+ posts &&
+ posts.find(
+ ( post ) => post.slug === slug && post.meta && post.meta.theme === theme
+ );
+ return foundPost && foundPost.id;
+ }
+ },
+ [ postId, slug, theme ]
+ );
+}
diff --git a/packages/block-library/src/template-part/editor.scss b/packages/block-library/src/template-part/editor.scss
new file mode 100644
index 0000000000000..2d32895db8131
--- /dev/null
+++ b/packages/block-library/src/template-part/editor.scss
@@ -0,0 +1,28 @@
+.wp-block-template-part__placeholder-input-container {
+ display: flex;
+ flex-wrap: wrap;
+ width: 100%;
+}
+
+.wp-block-template-part__placeholder-input {
+ margin: 5px;
+}
+
+.wp-block-template-part__placeholder-preview {
+ margin-bottom: 15px;
+ width: 100%;
+
+ .block-editor-block-preview__container {
+ padding: 1px;
+ }
+
+ .block-editor-block-preview__content {
+ position: initial;
+ }
+}
+
+.wp-block-template-part__placeholder-preview-title {
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
diff --git a/packages/core-data/package.json b/packages/core-data/package.json
index 71f5fbd092001..de23c034048d1 100644
--- a/packages/core-data/package.json
+++ b/packages/core-data/package.json
@@ -24,6 +24,7 @@
"dependencies": {
"@babel/runtime": "^7.4.4",
"@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/blocks": "file:../blocks",
"@wordpress/data": "file:../data",
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/element": "file:../element",
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index 66fbd292ec340..3a08117082084 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -249,6 +249,26 @@ export function* saveEntityRecord(
const entityIdKey = entity.key || DEFAULT_ENTITY_KEY;
const recordId = record[ entityIdKey ];
+ // Evaluate optimized edits.
+ // (Function edits that should be evaluated on save to avoid expensive computations on every edit.)
+ for ( const [ key, value ] of Object.entries( record ) ) {
+ if ( typeof value === 'function' ) {
+ const evaluatedValue = value(
+ yield select( 'getEditedEntityRecord', kind, name, recordId )
+ );
+ yield editEntityRecord(
+ kind,
+ name,
+ recordId,
+ {
+ [ key ]: evaluatedValue,
+ },
+ { undoIgnore: true }
+ );
+ record[ key ] = evaluatedValue;
+ }
+ }
+
yield { type: 'SAVE_ENTITY_RECORD_START', kind, name, recordId, isAutosave };
let updatedRecord;
let error;
diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js
index 50439f1b57ad6..d080d77b06ce2 100644
--- a/packages/core-data/src/entity-provider.js
+++ b/packages/core-data/src/entity-provider.js
@@ -1,8 +1,14 @@
/**
* WordPress dependencies
*/
-import { createContext, useContext, useCallback } from '@wordpress/element';
+import {
+ createContext,
+ useContext,
+ useCallback,
+ useMemo,
+} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
+import { parse, serialize } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -122,13 +128,14 @@ export function useEntityProp( kind, type, prop ) {
export function __experimentalUseEntitySaving( kind, type, props ) {
const id = useEntityId( kind, type );
- const [ isDirty, isSaving, edits ] = useSelect(
+ const [ isDirty, isSaving, _select ] = useSelect(
( select ) => {
const { getEntityRecordNonTransientEdits, isSavingEntityRecord } = select(
'core'
);
- const _edits = getEntityRecordNonTransientEdits( kind, type, id );
- const editKeys = Object.keys( _edits );
+ const editKeys = Object.keys(
+ getEntityRecordNonTransientEdits( kind, type, id )
+ );
return [
props ?
editKeys.some( ( key ) =>
@@ -136,7 +143,7 @@ export function __experimentalUseEntitySaving( kind, type, props ) {
) :
editKeys.length > 0,
isSavingEntityRecord( kind, type, id ),
- _edits,
+ select,
];
},
[ kind, type, id, props ]
@@ -144,11 +151,18 @@ export function __experimentalUseEntitySaving( kind, type, props ) {
const { saveEntityRecord } = useDispatch( 'core' );
const save = useCallback( () => {
- let filteredEdits = edits;
+ // We use the `select` from `useSelect` here instead of importing it from
+ // the data module so that we get the one bound to the provided registry,
+ // and not the default one.
+ let filteredEdits = _select( 'core' ).getEntityRecordNonTransientEdits(
+ kind,
+ type,
+ id
+ );
if ( typeof props === 'string' ) {
filteredEdits = { [ props ]: filteredEdits[ props ] };
} else if ( props ) {
- filteredEdits = filteredEdits.reduce( ( acc, key ) => {
+ filteredEdits = Object.keys( filteredEdits ).reduce( ( acc, key ) => {
if ( props.includes( key ) ) {
acc[ key ] = filteredEdits[ key ];
}
@@ -156,7 +170,67 @@ export function __experimentalUseEntitySaving( kind, type, props ) {
}, {} );
}
saveEntityRecord( kind, type, { id, ...filteredEdits } );
- }, [ kind, type, id, props, edits ] );
+ }, [ kind, type, id, props, _select ] );
return [ isDirty, isSaving, save ];
}
+
+/**
+ * Hook that returns block content getters and setters for
+ * the nearest provided entity of the specified type.
+ *
+ * The return value has the shape `[ blocks, onInput, onChange ]`.
+ * `onInput` is for block changes that don't create undo levels
+ * or dirty the post, non-persistent changes, and `onChange` is for
+ * peristent changes. They map directly to the props of a
+ * `BlockEditorProvider` and are intended to be used with it,
+ * or similar components or hooks.
+ *
+ * @param {string} kind The entity kind.
+ * @param {string} type The entity type.
+ * @param {Object} options
+ * @param {Object} [options.initialEdits] Initial edits object for the entity record.
+ * @param {string} [options.blocksProp='blocks'] The name of the entity prop that holds the blocks array.
+ * @param {string} [options.contentProp='content'] The name of the entity prop that holds the serialized blocks.
+ *
+ * @return {[WPBlock[], Function, Function]} The block array and setters.
+ */
+export function useEntityBlockEditor(
+ kind,
+ type,
+ { initialEdits, blocksProp = 'blocks', contentProp = 'content' } = {}
+) {
+ const [ content, setContent ] = useEntityProp( kind, type, contentProp );
+
+ const { editEntityRecord } = useDispatch( 'core' );
+ const id = useEntityId( kind, type );
+ const initialBlocks = useMemo( () => {
+ if ( initialEdits ) {
+ editEntityRecord( kind, type, id, initialEdits, { undoIgnore: true } );
+ }
+
+ // Guard against other instances that might have
+ // set content to a function already.
+ if ( typeof content !== 'function' ) {
+ const parsedContent = parse( content );
+ return parsedContent.length ? parsedContent : [];
+ }
+ }, [ id ] ); // Reset when the provided entity record changes.
+ const [ blocks = initialBlocks, onInput ] = useEntityProp(
+ kind,
+ type,
+ blocksProp
+ );
+
+ const onChange = useCallback(
+ ( nextBlocks ) => {
+ onInput( nextBlocks );
+ // Use a function edit to avoid serializing often.
+ setContent( ( { blocks: blocksToSerialize } ) =>
+ serialize( blocksToSerialize )
+ );
+ },
+ [ onInput, setContent ]
+ );
+ return [ blocks, onInput, onChange ];
+}
diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js
index bf12ac2e5096f..783e2bbdd4175 100644
--- a/packages/core-data/src/index.js
+++ b/packages/core-data/src/index.js
@@ -49,9 +49,5 @@ registerStore( REDUCER_KEY, {
resolvers: { ...resolvers, ...entityResolvers },
} );
-export {
- default as EntityProvider,
- useEntityId,
- useEntityProp,
- __experimentalUseEntitySaving,
-} from './entity-provider';
+export { default as EntityProvider } from './entity-provider';
+export * from './entity-provider';
diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js
index 3c8d770d8d106..8c404ebb58862 100644
--- a/packages/editor/src/components/entities-saved-states/index.js
+++ b/packages/editor/src/components/entities-saved-states/index.js
@@ -1,7 +1,6 @@
/**
* External dependencies
*/
-import { startCase } from 'lodash';
import EquivalentKeyMap from 'equivalent-key-map';
/**
@@ -20,8 +19,9 @@ const EntitiesSavedStatesCheckbox = ( {
setCheckedById,
} ) => (
setCheckedById( id, nextChecked ) }
diff --git a/packages/url/README.md b/packages/url/README.md
index 5b381e074f48c..9436880c51f43 100644
--- a/packages/url/README.md
+++ b/packages/url/README.md
@@ -37,6 +37,27 @@ _Returns_
- `string`: URL with arguments applied.
+# **cleanForSlug**
+
+Performs some basic cleanup of a string for use as a post slug.
+
+This replicates some of what `sanitize_title()` does in WordPress core, but
+is only designed to approximate what the slug will be.
+
+Converts whitespace, periods, forward slashes and underscores to hyphens.
+Converts Latin-1 Supplement and Latin Extended-A letters to basic Latin
+letters. Removes combining diacritical marks. Converts remaining string
+to lowercase. It does not touch octets, HTML entities, or other encoded
+characters.
+
+_Parameters_
+
+- _string_ `string`: Title or slug to be processed.
+
+_Returns_
+
+- `string`: Processed string.
+
# **filterURLForDisplay**
Returns a URL for display.
diff --git a/packages/url/package.json b/packages/url/package.json
index f1a7768f2a67b..73c82ab0daeb5 100644
--- a/packages/url/package.json
+++ b/packages/url/package.json
@@ -23,6 +23,7 @@
"sideEffects": false,
"dependencies": {
"@babel/runtime": "^7.4.4",
+ "lodash": "^4.17.15",
"qs": "^6.5.2"
},
"publishConfig": {
diff --git a/packages/url/src/clean-for-slug.js b/packages/url/src/clean-for-slug.js
new file mode 100644
index 0000000000000..58c814eced030
--- /dev/null
+++ b/packages/url/src/clean-for-slug.js
@@ -0,0 +1,27 @@
+/**
+ * External dependencies
+ */
+import { deburr, toLower, trim } from 'lodash';
+
+/**
+ * Performs some basic cleanup of a string for use as a post slug.
+ *
+ * This replicates some of what `sanitize_title()` does in WordPress core, but
+ * is only designed to approximate what the slug will be.
+ *
+ * Converts whitespace, periods, forward slashes and underscores to hyphens.
+ * Converts Latin-1 Supplement and Latin Extended-A letters to basic Latin
+ * letters. Removes combining diacritical marks. Converts remaining string
+ * to lowercase. It does not touch octets, HTML entities, or other encoded
+ * characters.
+ *
+ * @param {string} string Title or slug to be processed.
+ *
+ * @return {string} Processed string.
+ */
+export function cleanForSlug( string ) {
+ if ( ! string ) {
+ return '';
+ }
+ return toLower( deburr( trim( string.replace( /[\s\./_]+/g, '-' ), '-' ) ) );
+}
diff --git a/packages/url/src/index.js b/packages/url/src/index.js
index e39d95f280486..3a47cba0bc3b5 100644
--- a/packages/url/src/index.js
+++ b/packages/url/src/index.js
@@ -18,3 +18,4 @@ export { prependHTTP } from './prepend-http';
export { safeDecodeURI } from './safe-decode-uri';
export { safeDecodeURIComponent } from './safe-decode-uri-component';
export { filterURLForDisplay } from './filter-url-for-display';
+export { cleanForSlug } from './clean-for-slug';
diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js
index 31b7e05ecf3e5..aa1ef282f5d06 100644
--- a/packages/url/src/test/index.test.js
+++ b/packages/url/src/test/index.test.js
@@ -26,6 +26,7 @@ import {
prependHTTP,
safeDecodeURI,
filterURLForDisplay,
+ cleanForSlug,
} from '../';
describe( 'isURL', () => {
@@ -550,3 +551,16 @@ describe( 'filterURLForDisplay', () => {
} );
} );
+describe( 'cleanForSlug', () => {
+ it( 'should return string prepared for use as url slug', () => {
+ expect( cleanForSlug( ' /Déjà_vu. ' ) ).toBe( 'deja-vu' );
+ } );
+
+ it( 'should return an empty string for missing argument', () => {
+ expect( cleanForSlug() ).toBe( '' );
+ } );
+
+ it( 'should return an empty string for falsy argument', () => {
+ expect( cleanForSlug( null ) ).toBe( '' );
+ } );
+} );