diff --git a/docs/reference-guides/data/data-core-edit-site.md b/docs/reference-guides/data/data-core-edit-site.md
index 0cac2268b2ab29..6dea8e9b77d1b2 100644
--- a/docs/reference-guides/data/data-core-edit-site.md
+++ b/docs/reference-guides/data/data-core-edit-site.md
@@ -291,7 +291,7 @@ _Parameters_
_Returns_
-- `number`: The resolved template ID for the page route.
+- `Object`: Action object.
### setHasPageContentFocus
diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss
index 4f8bbab5a16095..12443a30a96656 100644
--- a/packages/base-styles/_z-index.scss
+++ b/packages/base-styles/_z-index.scss
@@ -127,6 +127,7 @@ $z-layers: (
".block-editor-template-part__selection-modal": 1000001,
".block-editor-block-rename-modal": 1000001,
".edit-site-list__rename-modal": 1000001,
+ ".edit-site-swap-template-modal": 1000001,
// Note: The ConfirmDialog component's z-index is being set to 1000001 in packages/components/src/confirm-dialog/styles.ts
// because it uses emotion and not sass. We need it to render on top its parent popover.
diff --git a/packages/core-data/src/hooks/use-entity-record.ts b/packages/core-data/src/hooks/use-entity-record.ts
index 01eee562644cf1..7f3630b1dd4ceb 100644
--- a/packages/core-data/src/hooks/use-entity-record.ts
+++ b/packages/core-data/src/hooks/use-entity-record.ts
@@ -155,8 +155,8 @@ export default function useEntityRecord< RecordType >(
const mutations = useMemo(
() => ( {
- edit: ( record ) =>
- editEntityRecord( kind, name, recordId, record ),
+ edit: ( record, editOptions: any = {} ) =>
+ editEntityRecord( kind, name, recordId, record, editOptions ),
save: ( saveOptions: any = {} ) =>
saveEditedEntityRecord( kind, name, recordId, {
throwOnError: true,
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js
index b49e8ac459e3fe..2295ee12f45049 100644
--- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js
@@ -2,21 +2,31 @@
* WordPress dependencies
*/
import { useSelect, useDispatch } from '@wordpress/data';
-import { useMemo } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
-import { BlockContextProvider, BlockPreview } from '@wordpress/block-editor';
-import { Button, __experimentalVStack as VStack } from '@wordpress/components';
+import {
+ DropdownMenu,
+ MenuGroup,
+ MenuItem,
+ __experimentalHStack as HStack,
+ __experimentalText as Text,
+} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { store as coreStore } from '@wordpress/core-data';
-import { parse } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { store as editSiteStore } from '../../../store';
+import SwapTemplateButton from './swap-template-button';
+import ResetDefaultTemplate from './reset-default-template';
+
+const POPOVER_PROPS = {
+ className: 'edit-site-page-panels-edit-template__dropdown',
+ placement: 'bottom-start',
+};
export default function EditTemplate() {
- const { context, hasResolved, template } = useSelect( ( select ) => {
+ const { hasResolved, template } = useSelect( ( select ) => {
const { getEditedPostContext, getEditedPostType, getEditedPostId } =
select( editSiteStore );
const { getEditedEntityRecord, hasFinishedResolution } =
@@ -39,39 +49,43 @@ export default function EditTemplate() {
const { setHasPageContentFocus } = useDispatch( editSiteStore );
- const blockContext = useMemo(
- () => ( { ...context, postType: null, postId: null } ),
- [ context ]
- );
-
- const blocks = useMemo(
- () =>
- template.blocks ??
- ( template.content && typeof template.content !== 'function'
- ? parse( template.content )
- : [] ),
- [ template.blocks, template.content ]
- );
-
if ( ! hasResolved ) {
return null;
}
return (
-
- { decodeEntities( template.title ) }
-
-
-
-
-
-
-
+ { ( { onClose } ) => (
+ <>
+
+
+
+
+
+ >
+ ) }
+
+
);
}
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js
new file mode 100644
index 00000000000000..3000d21ab13662
--- /dev/null
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js
@@ -0,0 +1,83 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { useMemo } from '@wordpress/element';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { store as editSiteStore } from '../../../store';
+
+export function useEditedPostContext() {
+ return useSelect(
+ ( select ) => select( editSiteStore ).getEditedPostContext(),
+ []
+ );
+}
+
+export function useIsPostsPage() {
+ const { postId } = useEditedPostContext();
+ return useSelect(
+ ( select ) =>
+ +postId ===
+ select( coreStore ).getEntityRecord( 'root', 'site' )
+ ?.page_for_posts,
+ [ postId ]
+ );
+}
+
+function useTemplates() {
+ return useSelect(
+ ( select ) =>
+ select( coreStore ).getEntityRecords( 'postType', 'wp_template', {
+ per_page: -1,
+ post_type: 'page',
+ } ),
+ []
+ );
+}
+
+export function useAvailableTemplates() {
+ const currentTemplateSlug = useCurrentTemplateSlug();
+ const isPostsPage = useIsPostsPage();
+ const templates = useTemplates();
+ return useMemo(
+ () =>
+ // The posts page template cannot be changed.
+ ! isPostsPage &&
+ templates?.filter(
+ ( template ) =>
+ template.is_custom &&
+ template.slug !== currentTemplateSlug &&
+ !! template.content.raw // Skip empty templates.
+ ),
+ [ templates, currentTemplateSlug, isPostsPage ]
+ );
+}
+
+export function useCurrentTemplateSlug() {
+ const { postType, postId } = useEditedPostContext();
+ const templates = useTemplates();
+ const entityTemplate = useSelect(
+ ( select ) => {
+ const post = select( coreStore ).getEditedEntityRecord(
+ 'postType',
+ postType,
+ postId
+ );
+ return post?.template;
+ },
+ [ postType, postId ]
+ );
+
+ if ( ! entityTemplate ) {
+ return;
+ }
+ // If a page has a `template` set and is not included in the list
+ // of the theme's templates, do not return it, in order to resolve
+ // to the current theme's default template.
+ return templates?.find( ( template ) => template.slug === entityTemplate )
+ ?.slug;
+}
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js
index 69971d1ad413ae..df59dffe66be69 100644
--- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js
@@ -20,7 +20,6 @@ import { store as editSiteStore } from '../../../store';
import SidebarCard from '../sidebar-card';
import PageContent from './page-content';
import PageSummary from './page-summary';
-import EditTemplate from './edit-template';
export default function PagePanels() {
const { id, type, hasResolved, status, date, password, title, modified } =
@@ -81,9 +80,6 @@ export default function PagePanels() {
-
-
-
>
);
}
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js
index 3dce743b298d45..c4dafeab6cb372 100644
--- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js
@@ -7,6 +7,7 @@ import { __experimentalVStack as VStack } from '@wordpress/components';
*/
import PageStatus from './page-status';
import PublishDate from './publish-date';
+import EditTemplate from './edit-template';
export default function PageSummary( {
status,
@@ -30,6 +31,7 @@ export default function PageSummary( {
postId={ postId }
postType={ postType }
/>
+
);
}
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js
new file mode 100644
index 00000000000000..bc61b82a8d0057
--- /dev/null
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js
@@ -0,0 +1,44 @@
+/**
+ * WordPress dependencies
+ */
+import { useDispatch } from '@wordpress/data';
+import { MenuGroup, MenuItem } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { useEntityRecord } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import {
+ useCurrentTemplateSlug,
+ useEditedPostContext,
+ useIsPostsPage,
+} from './hooks';
+import { store as editSiteStore } from '../../../store';
+
+export default function ResetDefaultTemplate( { onClick } ) {
+ const currentTemplateSlug = useCurrentTemplateSlug();
+ const isPostsPage = useIsPostsPage();
+ const { postType, postId } = useEditedPostContext();
+ const entity = useEntityRecord( 'postType', postType, postId );
+ const { setPage } = useDispatch( editSiteStore );
+ // The default template in a post is indicated by an empty string.
+ if ( ! currentTemplateSlug || isPostsPage ) {
+ return null;
+ }
+ return (
+
+
+
+ );
+}
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss
index 8c10b32085612b..aedcf5e46ca9ea 100644
--- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss
@@ -1,12 +1,37 @@
-.edit-site-page-panels__edit-template-preview {
- border: 1px solid $gray-200;
- height: 200px;
- max-height: 200px;
- overflow: hidden;
+.edit-site-swap-template-modal {
+ z-index: z-index(".edit-site-swap-template-modal");
}
-.edit-site-page-panels__edit-template-button {
- justify-content: center;
+.edit-site-page-panels__swap-template__confirm-modal__actions {
+ margin-top: $grid-unit-30;
+}
+
+.edit-site-page-panels__swap-template__modal-content .block-editor-block-patterns-list {
+ column-count: 2;
+ column-gap: $grid-unit-30;
+
+ // Small top padding required to avoid cutting off the visible outline when hovering items
+ padding-top: $border-width-focus-fallback;
+
+ @include break-medium() {
+ column-count: 3;
+ }
+
+ @include break-wide() {
+ column-count: 4;
+ }
+
+ .block-editor-block-patterns-list__list-item {
+ break-inside: avoid-column;
+ }
+
+ .block-editor-block-patterns-list__item {
+ // Avoid to override the BlockPatternList component
+ // default hover and focus styles.
+ &:not(:focus):not(:hover) .block-editor-block-preview__container {
+ box-shadow: 0 0 0 1px $gray-300;
+ }
+ }
}
.edit-site-change-status__content {
@@ -36,15 +61,21 @@
.edit-site-summary-field {
.components-dropdown {
- flex-grow: 1;
+ width: 70%;
}
.edit-site-summary-field__trigger {
- width: 100%;
+ max-width: 100%;
+
+ // Truncate
+ display: block;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.edit-site-summary-field__label {
width: 30%;
}
}
-
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js
new file mode 100644
index 00000000000000..fee4f22a3ae2bc
--- /dev/null
+++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js
@@ -0,0 +1,82 @@
+/**
+ * WordPress dependencies
+ */
+import { useDispatch } from '@wordpress/data';
+import { useMemo, useState, useCallback } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { __experimentalBlockPatternsList as BlockPatternsList } from '@wordpress/block-editor';
+import { MenuItem, Modal } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { useEntityRecord } from '@wordpress/core-data';
+import { parse } from '@wordpress/blocks';
+import { useAsyncList } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import { store as editSiteStore } from '../../../store';
+import { useAvailableTemplates, useEditedPostContext } from './hooks';
+
+export default function SwapTemplateButton( { onClick } ) {
+ const [ showModal, setShowModal ] = useState( false );
+ const availableTemplates = useAvailableTemplates();
+ const onClose = useCallback( () => {
+ setShowModal( false );
+ }, [] );
+ const { postType, postId } = useEditedPostContext();
+ const entitiy = useEntityRecord( 'postType', postType, postId );
+ const { setPage } = useDispatch( editSiteStore );
+ if ( ! availableTemplates?.length ) {
+ return null;
+ }
+ const onTemplateSelect = async ( template ) => {
+ entitiy.edit( { template: template.name }, { undoIgnore: true } );
+ await setPage( {
+ context: { postType, postId },
+ } );
+ onClose(); // Close the template suggestions modal first.
+ onClick();
+ };
+ return (
+ <>
+
+ { showModal && (
+
+
+
+
+
+ ) }
+ >
+ );
+}
+
+function TemplatesList( { onSelect } ) {
+ const availableTemplates = useAvailableTemplates();
+ const templatesAsPatterns = useMemo(
+ () =>
+ availableTemplates.map( ( template ) => ( {
+ name: template.slug,
+ blocks: parse( template.content.raw ),
+ title: decodeEntities( template.title.rendered ),
+ id: template.id,
+ } ) ),
+ [ availableTemplates ]
+ );
+ const shownTemplates = useAsyncList( templatesAsPatterns );
+ return (
+
+ );
+}
diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js
index 0ad521a9c9a54e..f690075c311489 100644
--- a/packages/edit-site/src/store/actions.js
+++ b/packages/edit-site/src/store/actions.js
@@ -4,7 +4,7 @@
import apiFetch from '@wordpress/api-fetch';
import { parse, __unstableSerializeAndClean } from '@wordpress/blocks';
import deprecated from '@wordpress/deprecated';
-import { addQueryArgs, getPathAndQueryString } from '@wordpress/url';
+import { addQueryArgs } from '@wordpress/url';
import { __, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
@@ -233,7 +233,7 @@ export function setHomeTemplateId() {
*
* @param {Object} context The context object.
*
- * @return {number} The resolved template ID for the page route.
+ * @return {Object} Action object.
*/
export function setEditedPostContext( context ) {
return {
@@ -257,22 +257,48 @@ export function setEditedPostContext( context ) {
export const setPage =
( page ) =>
async ( { dispatch, registry } ) => {
- if ( ! page.path && page.context?.postId ) {
- const entity = await registry
+ let template;
+ const getDefaultTemplate = async ( slug ) =>
+ apiFetch( {
+ path: addQueryArgs( '/wp/v2/templates/lookup', {
+ slug: `page-${ slug }`,
+ } ),
+ } );
+
+ if ( page.path ) {
+ template = await registry
+ .resolveSelect( coreStore )
+ .__experimentalGetTemplateForLink( page.path );
+ } else {
+ const editedEntity = await registry
.resolveSelect( coreStore )
- .getEntityRecord(
+ .getEditedEntityRecord(
'postType',
- page.context.postType || 'post',
- page.context.postId
+ page.context?.postType || 'post',
+ page.context?.postId
);
- // If the entity is undefined for some reason, path will resolve to "/"
- page.path = getPathAndQueryString( entity?.link );
+ const currentTemplateSlug = editedEntity?.template;
+ if ( currentTemplateSlug ) {
+ const currentTemplate = (
+ await registry
+ .resolveSelect( coreStore )
+ .getEntityRecords( 'postType', 'wp_template', {
+ per_page: -1,
+ } )
+ )?.find( ( { slug } ) => slug === currentTemplateSlug );
+ if ( currentTemplate ) {
+ template = currentTemplate;
+ } else {
+ // If a page has a `template` set and is not included in the list
+ // of the current theme's templates, query for current theme's default template.
+ template = await getDefaultTemplate( editedEntity?.link );
+ }
+ } else {
+ // Page's `template` is empty, that indicates we need to use the default template for the page.
+ template = await getDefaultTemplate( editedEntity?.link );
+ }
}
- const template = await registry
- .resolveSelect( coreStore )
- .__experimentalGetTemplateForLink( page.path );
-
if ( ! template ) {
return;
}
diff --git a/test/e2e/specs/site-editor/pages.spec.js b/test/e2e/specs/site-editor/pages.spec.js
index 1c0cb8c4b69686..e6a0b7f266cbf4 100644
--- a/test/e2e/specs/site-editor/pages.spec.js
+++ b/test/e2e/specs/site-editor/pages.spec.js
@@ -3,33 +3,48 @@
*/
const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+async function draftNewPage( page ) {
+ await page.getByRole( 'button', { name: 'Pages' } ).click();
+ await page.getByRole( 'button', { name: 'Draft a new page' } ).click();
+ await page
+ .locator( 'role=dialog[name="Draft a new page"i]' )
+ .locator( 'role=textbox[name="Page title"i]' )
+ .fill( 'Test Page' );
+ await page.keyboard.press( 'Enter' );
+ await expect(
+ page.locator(
+ `role=button[name="Dismiss this notice"i] >> text='"Test Page" successfully created.'`
+ )
+ ).toBeVisible();
+}
+
test.describe( 'Pages', () => {
test.beforeAll( async ( { requestUtils } ) => {
await requestUtils.activateTheme( 'emptytheme' );
+ await Promise.all( [
+ requestUtils.deleteAllTemplates( 'wp_template' ),
+ requestUtils.deleteAllPages(),
+ ] );
} );
test.afterAll( async ( { requestUtils } ) => {
await requestUtils.activateTheme( 'twentytwentyone' );
+ await Promise.all( [
+ requestUtils.deleteAllTemplates( 'wp_template' ),
+ requestUtils.deleteAllPages(),
+ ] );
} );
- test.beforeEach( async ( { admin } ) => {
+ test.beforeEach( async ( { requestUtils, admin } ) => {
+ await Promise.all( [
+ requestUtils.deleteAllTemplates( 'wp_template' ),
+ requestUtils.deleteAllPages(),
+ ] );
await admin.visitSiteEditor();
} );
test( 'create a new page', async ( { page, editor } ) => {
- // Draft a new page.
- await page.getByRole( 'button', { name: 'Pages' } ).click();
- await page.getByRole( 'button', { name: 'Draft a new page' } ).click();
- await page
- .locator( 'role=dialog[name="Draft a new page"i]' )
- .locator( 'role=textbox[name="Page title"i]' )
- .fill( 'Test Page' );
- await page.keyboard.press( 'Enter' );
- await expect(
- page.locator(
- `role=button[name="Dismiss this notice"i] >> text='"Test Page" successfully created.'`
- )
- ).toBeVisible();
+ await draftNewPage( page );
// Insert into Page Content using default block.
await editor.canvas
@@ -79,15 +94,11 @@ test.describe( 'Pages', () => {
// Switch to template editing focus.
await editor.openDocumentSettingsSidebar();
- await expect(
- page.locator(
- '.edit-site-page-panels__edit-template-preview iframe'
- )
- ).toBeVisible();
await page
.getByRole( 'region', { name: 'Editor settings' } )
- .getByRole( 'button', { name: 'Edit template' } )
+ .getByRole( 'button', { name: 'Template options' } )
.click();
+ await page.getByRole( 'button', { name: 'Edit template' } ).click();
await expect(
editor.canvas.getByRole( 'document', {
name: 'Block: Content',
@@ -125,4 +136,81 @@ test.describe( 'Pages', () => {
)
).toBeVisible();
} );
+ test( 'swap template and reset to default', async ( {
+ admin,
+ page,
+ editor,
+ } ) => {
+ // Create a custom template first.
+ const templateName = 'demo';
+ await page.getByRole( 'button', { name: 'Templates' } ).click();
+ await page.getByRole( 'button', { name: 'Add New Template' } ).click();
+ await page
+ .getByRole( 'button', {
+ name: 'A custom template can be manually applied to any post or page.',
+ } )
+ .click();
+ // Fill the template title and submit.
+ const newTemplateDialog = page.locator(
+ 'role=dialog[name="Create custom template"i]'
+ );
+ const templateNameInput = newTemplateDialog.locator(
+ 'role=textbox[name="Name"i]'
+ );
+ await templateNameInput.fill( templateName );
+ await page.keyboard.press( 'Enter' );
+ await page
+ .locator( '.block-editor-block-patterns-list__list-item' )
+ .click();
+ await editor.saveSiteEditorEntities();
+ await admin.visitSiteEditor();
+
+ // Create new page that has the default template so as to swap it.
+ await draftNewPage( page );
+ await editor.openDocumentSettingsSidebar();
+ const templateOptionsButton = page
+ .getByRole( 'region', { name: 'Editor settings' } )
+ .getByRole( 'button', { name: 'Template options' } );
+ await expect( templateOptionsButton ).toHaveText( 'Single Entries' );
+ await templateOptionsButton.click();
+ await page
+ .getByRole( 'menu', { name: 'Template options' } )
+ .getByText( 'Swap template' )
+ .click();
+ const templateItem = page.locator(
+ '.block-editor-block-patterns-list__item-title'
+ );
+ // Empty theme's custom template with `postTypes: ['post']`, should not be suggested.
+ await expect( templateItem ).toHaveCount( 1 );
+ await templateItem.click();
+ await expect( templateOptionsButton ).toHaveText( 'demo' );
+ await editor.saveSiteEditorEntities();
+
+ // Now reset, and apply the default template back.
+ await templateOptionsButton.click();
+ const resetButton = page
+ .getByRole( 'menu', { name: 'Template options' } )
+ .getByText( 'Reset' );
+ await expect( resetButton ).toBeVisible();
+ await resetButton.click();
+ await expect( templateOptionsButton ).toHaveText( 'Single Entries' );
+ } );
+ test( 'swap template options should respect the declared `postTypes`', async ( {
+ page,
+ editor,
+ } ) => {
+ await draftNewPage( page );
+ await editor.openDocumentSettingsSidebar();
+ const templateOptionsButton = page
+ .getByRole( 'region', { name: 'Editor settings' } )
+ .getByRole( 'button', { name: 'Template options' } );
+ await templateOptionsButton.click();
+ // Empty theme has only one custom template with `postTypes: ['post']`,
+ // so it should not be suggested.
+ await expect(
+ page
+ .getByRole( 'menu', { name: 'Template options' } )
+ .getByText( 'Swap template' )
+ ).toHaveCount( 0 );
+ } );
} );
diff --git a/test/emptytheme/templates/custom-template.html b/test/emptytheme/templates/custom-template.html
new file mode 100644
index 00000000000000..e4e8c11a39ef6f
--- /dev/null
+++ b/test/emptytheme/templates/custom-template.html
@@ -0,0 +1,3 @@
+
+
Custom template for Posts
+
diff --git a/test/emptytheme/theme.json b/test/emptytheme/theme.json
index b28e6c9f274b2f..d95ed844e6b1cb 100644
--- a/test/emptytheme/theme.json
+++ b/test/emptytheme/theme.json
@@ -8,5 +8,12 @@
"wideSize": "1100px"
}
},
+ "customTemplates": [
+ {
+ "name": "custom-template",
+ "title": "Custom",
+ "postTypes": [ "post" ]
+ }
+ ],
"patterns": [ "short-text-surrounded-by-round-images", "partner-logos" ]
}