diff --git a/docs/manifest.json b/docs/manifest.json
index b65c0aead24a5f..d0ac5cd20463ec 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1583,6 +1583,12 @@
"markdown_source": "../packages/media-utils/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/menus",
+ "slug": "packages-menus",
+ "markdown_source": "../packages/menus/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/notices",
"slug": "packages-notices",
diff --git a/package-lock.json b/package-lock.json
index f4c169e392bef9..3379ee2e8a722f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13266,6 +13266,7 @@
"@wordpress/icons": "file:packages/icons",
"@wordpress/is-shallow-equal": "file:packages/is-shallow-equal",
"@wordpress/keycodes": "file:packages/keycodes",
+ "@wordpress/menus": "file:packages/menus",
"@wordpress/notices": "file:packages/notices",
"@wordpress/primitives": "file:packages/primitives",
"@wordpress/reusable-blocks": "file:packages/reusable-blocks",
@@ -13653,6 +13654,7 @@
"@wordpress/interface": "file:packages/interface",
"@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts",
"@wordpress/media-utils": "file:packages/media-utils",
+ "@wordpress/menus": "file:packages/menus",
"@wordpress/notices": "file:packages/notices",
"@wordpress/url": "file:packages/url",
"classnames": "^2.2.5",
@@ -14101,6 +14103,14 @@
"lodash": "^4.17.21"
}
},
+ "@wordpress/menus": {
+ "version": "file:packages/menus",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@wordpress/blocks": "file:packages/blocks",
+ "lodash": "^4.17.21"
+ }
+ },
"@wordpress/notices": {
"version": "file:packages/notices",
"requires": {
diff --git a/package.json b/package.json
index efa0c6d2eda24b..e6d435f8d78f3f 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"@wordpress/keycodes": "file:packages/keycodes",
"@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks",
"@wordpress/media-utils": "file:packages/media-utils",
+ "@wordpress/menus": "file:packages/menus",
"@wordpress/notices": "file:packages/notices",
"@wordpress/nux": "file:packages/nux",
"@wordpress/plugins": "file:packages/plugins",
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index 7797172977a793..59bcd17a1ecd7d 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -52,6 +52,7 @@
"@wordpress/icons": "file:../icons",
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/keycodes": "file:../keycodes",
+ "@wordpress/menus": "file:../menus",
"@wordpress/notices": "file:../notices",
"@wordpress/primitives": "file:../primitives",
"@wordpress/reusable-blocks": "file:../reusable-blocks",
diff --git a/packages/block-library/src/navigation/placeholder.js b/packages/block-library/src/navigation/placeholder.js
index ccf46229e2a14b..0847fa6f94f114 100644
--- a/packages/block-library/src/navigation/placeholder.js
+++ b/packages/block-library/src/navigation/placeholder.js
@@ -20,13 +20,13 @@ import {
import { __ } from '@wordpress/i18n';
import { navigation, chevronDown, Icon } from '@wordpress/icons';
import { store as coreStore } from '@wordpress/core-data';
+import { menuItemsToBlocks } from '@wordpress/menus';
/**
* Internal dependencies
*/
import PlaceholderPreview from './placeholder-preview';
-import menuItemsToBlocks from './menu-items-to-blocks';
function NavigationPlaceholder( { onCreate }, ref ) {
const [ selectedMenu, setSelectedMenu ] = useState();
diff --git a/packages/edit-navigation/package.json b/packages/edit-navigation/package.json
index 0ba5612bf411aa..9787c2aba768ff 100644
--- a/packages/edit-navigation/package.json
+++ b/packages/edit-navigation/package.json
@@ -46,6 +46,7 @@
"@wordpress/interface": "file:../interface",
"@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts",
"@wordpress/media-utils": "file:../media-utils",
+ "@wordpress/menus": "file:../menus",
"@wordpress/notices": "file:../notices",
"@wordpress/url": "file:../url",
"classnames": "^2.2.5",
diff --git a/packages/edit-navigation/src/store/menu-items-to-blocks.js b/packages/edit-navigation/src/store/menu-items-to-blocks.js
deleted file mode 100644
index 21bfd975299254..00000000000000
--- a/packages/edit-navigation/src/store/menu-items-to-blocks.js
+++ /dev/null
@@ -1,204 +0,0 @@
-/**
- * External dependencies
- */
-import { sortBy } from 'lodash';
-
-/**
- * WordPress dependencies
- */
-import { createBlock, parse } from '@wordpress/blocks';
-
-/**
- * Convert a flat menu item structure to a nested blocks structure.
- *
- * @param {Object[]} menuItems An array of menu items.
- *
- * @return {WPBlock[]} An array of blocks.
- */
-export default function menuItemsToBlocks( menuItems ) {
- if ( ! menuItems ) {
- return null;
- }
-
- const menuTree = createDataTree( menuItems );
- return mapMenuItemsToBlocks( menuTree );
-}
-
-/**
- * A recursive function that maps menu item nodes to blocks.
- *
- * @param {WPNavMenuItem[]} menuItems An array of WPNavMenuItem items.
- * @return {Object} Object containing innerBlocks and mapping.
- */
-function mapMenuItemsToBlocks( menuItems ) {
- let mapping = {};
-
- // The menuItem should be in menu_order sort order.
- const sortedItems = sortBy( menuItems, 'menu_order' );
-
- const innerBlocks = sortedItems.map( ( menuItem ) => {
- if ( menuItem.type === 'block' ) {
- const [ block ] = parse( menuItem.content.raw );
-
- if ( ! block ) {
- return createBlock( 'core/freeform', {
- content: menuItem.content,
- } );
- }
-
- return block;
- }
-
- const attributes = menuItemToBlockAttributes( menuItem );
-
- // If there are children recurse to build those nested blocks.
- const {
- innerBlocks: nestedBlocks = [], // alias to avoid shadowing
- mapping: nestedMapping = {}, // alias to avoid shadowing
- } = menuItem.children?.length
- ? mapMenuItemsToBlocks( menuItem.children )
- : {};
-
- // Update parent mapping with nested mapping.
- mapping = {
- ...mapping,
- ...nestedMapping,
- };
-
- // Create block with nested "innerBlocks".
- const block = createBlock(
- 'core/navigation-link',
- attributes,
- nestedBlocks
- );
-
- // Create mapping for menuItem -> block
- mapping[ menuItem.id ] = block.clientId;
-
- return block;
- } );
-
- return {
- innerBlocks,
- mapping,
- };
-}
-
-/**
- * A WP nav_menu_item object.
- * For more documentation on the individual fields present on a menu item please see:
- * https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L789
- *
- * Changes made here should also be mirrored in packages/edit-navigation/src/store/utils.js.
- *
- * @typedef WPNavMenuItem
- *
- * @property {Object} title stores the raw and rendered versions of the title/label for this menu item.
- * @property {Array} xfn the XFN relationships expressed in the link of this menu item.
- * @property {Array} classes the HTML class attributes for this menu item.
- * @property {string} attr_title the HTML title attribute for this menu item.
- * @property {string} object The type of object originally represented, such as 'category', 'post', or 'attachment'.
- * @property {string} object_id The DB ID of the original object this menu item represents, e.g. ID for posts and term_id for categories.
- * @property {string} description The description of this menu item.
- * @property {string} url The URL to which this menu item points.
- * @property {string} type The family of objects originally represented, such as 'post_type' or 'taxonomy'.
- * @property {string} target The target attribute of the link element for this menu item.
- */
-
-/**
- * Convert block attributes to menu item.
- *
- * @param {WPNavMenuItem} menuItem the menu item to be converted to block attributes.
- * @return {Object} the block attributes converted from the WPNavMenuItem item.
- */
-function menuItemToBlockAttributes( {
- title: menuItemTitleField,
- xfn,
- classes,
- // eslint-disable-next-line camelcase
- attr_title,
- object,
- // eslint-disable-next-line camelcase
- object_id,
- description,
- url,
- type: menuItemTypeField,
- target,
-} ) {
- // For historical reasons, the `core/navigation-link` variation type is `tag`
- // whereas WP Core expects `post_tag` as the `object` type.
- // To avoid writing a block migration we perform a conversion here.
- // See also inverse equivalent in `blockAttributesToMenuItem`.
- if ( object && object === 'post_tag' ) {
- object = 'tag';
- }
-
- return {
- label: menuItemTitleField?.rendered || '',
- ...( object?.length && {
- type: object,
- } ),
- kind: menuItemTypeField?.replace( '_', '-' ) || 'custom',
- url: url || '',
- ...( xfn?.length &&
- xfn.join( ' ' ).trim() && {
- rel: xfn.join( ' ' ).trim(),
- } ),
- ...( classes?.length &&
- classes.join( ' ' ).trim() && {
- className: classes.join( ' ' ).trim(),
- } ),
- ...( attr_title?.length && {
- title: attr_title,
- } ),
- // eslint-disable-next-line camelcase
- ...( object_id &&
- 'custom' !== object && {
- id: object_id,
- } ),
- ...( description?.length && {
- description,
- } ),
- ...( target === '_blank' && {
- opensInNewTab: true,
- } ),
- };
-}
-
-/**
- * Creates a nested, hierarchical tree representation from unstructured data that
- * has an inherent relationship defined between individual items.
- *
- * For example, by default, each element in the dataset should have an `id` and
- * `parent` property where the `parent` property indicates a relationship between
- * the current item and another item with a matching `id` properties.
- *
- * This is useful for building linked lists of data from flat data structures.
- *
- * @param {Array} dataset linked data to be rearranged into a hierarchical tree based on relational fields.
- * @param {string} id the property which uniquely identifies each entry within the array.
- * @param {*} relation the property which identifies how the current item is related to other items in the data (if at all).
- * @return {Array} a nested array of parent/child relationships
- */
-function createDataTree( dataset, id = 'id', relation = 'parent' ) {
- const hashTable = Object.create( null );
- const dataTree = [];
-
- for ( const data of dataset ) {
- hashTable[ data[ id ] ] = {
- ...data,
- children: [],
- };
- }
- for ( const data of dataset ) {
- if ( data[ relation ] ) {
- hashTable[ data[ relation ] ].children.push(
- hashTable[ data[ id ] ]
- );
- } else {
- dataTree.push( hashTable[ data[ id ] ] );
- }
- }
-
- return dataTree;
-}
diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js
index 25648239434d58..6d300278a237fe 100644
--- a/packages/edit-navigation/src/store/resolvers.js
+++ b/packages/edit-navigation/src/store/resolvers.js
@@ -2,6 +2,7 @@
* WordPress dependencies
*/
import { createBlock } from '@wordpress/blocks';
+import { menuItemsToBlocks } from '@wordpress/menus';
/**
* Internal dependencies
@@ -10,7 +11,6 @@ import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants';
import { resolveMenuItems, dispatch } from './controls';
import { buildNavigationPostId } from './utils';
-import menuItemsToBlocks from './menu-items-to-blocks';
/**
* Creates a "stub" navigation post reflecting the contents of menu with id=menuId. The
* post is meant as a convenient to only exists in runtime and should never be saved. It
diff --git a/packages/edit-navigation/src/store/test/menu-items-to-blocks.js b/packages/edit-navigation/src/store/test/menu-items-to-blocks.js
deleted file mode 100644
index 599b30e679ff55..00000000000000
--- a/packages/edit-navigation/src/store/test/menu-items-to-blocks.js
+++ /dev/null
@@ -1,368 +0,0 @@
-/**
- * Internal dependencies
- */
-import menuItemsToBlocks from '../menu-items-to-blocks';
-
-// Mock createBlock to avoid creating the blocks in test environment.
-jest.mock( '@wordpress/blocks', () => {
- const blocks = jest.requireActual( '@wordpress/blocks' );
-
- return {
- ...blocks,
- createBlock( name, attributes, innerBlocks ) {
- return {
- name,
- attributes,
- innerBlocks,
- };
- },
- };
-} );
-
-describe( 'converting menu items to blocks', () => {
- it( 'converts an flat structure of menu item objects to blocks', () => {
- const { innerBlocks: actual } = menuItemsToBlocks( [
- {
- id: 1,
- title: {
- raw: 'Item 1',
- rendered: 'Item 1',
- },
- url: 'http://localhost:8889/item-1/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 1,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 2,
- title: {
- raw: 'Item 2',
- rendered: 'Item 2',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 2,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- ] );
-
- expect( actual ).toEqual( [
- expect.objectContaining( {
- name: 'core/navigation-link',
- innerBlocks: [],
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- innerBlocks: [],
- } ),
- ] );
- } );
-
- it( 'converts an nested structure of menu item objects to nested blocks', () => {
- const { innerBlocks: actual } = menuItemsToBlocks( [
- {
- id: 1,
- title: {
- raw: 'Top Level',
- rendered: 'Top Level',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 1,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 2,
- title: {
- raw: 'Child 1',
- rendered: 'Child 1',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 1,
- menu_order: 1,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 3,
- title: {
- raw: 'Child 2',
- rendered: 'Child 2',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 1,
- menu_order: 2,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 4,
- title: {
- raw: 'Sub Child',
- rendered: 'Sub Child',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 3,
- menu_order: 1,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 5,
- title: {
- raw: 'Sub Sub Child',
- rendered: 'Sub Sub Child',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 4,
- menu_order: 1,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 6,
- title: {
- raw: 'Top Level 2',
- rendered: 'Top Level 2',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 2,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- ] );
-
- expect( actual ).toEqual( [
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Top Level',
- } ),
- innerBlocks: [
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Child 1',
- } ),
- innerBlocks: [],
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Child 2',
- } ),
- innerBlocks: [
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Sub Child',
- } ),
- innerBlocks: [
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Sub Sub Child',
- } ),
- innerBlocks: [],
- } ),
- ],
- } ),
- ],
- } ),
- ],
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Top Level 2',
- } ),
- innerBlocks: [],
- } ),
- ] );
- } );
-
- it( 'respects menu order when converting to blocks', () => {
- const { innerBlocks: actual } = menuItemsToBlocks( [
- {
- id: 1,
- title: {
- raw: 'Ordered 5th',
- rendered: 'Ordered 5th',
- },
- url: 'http://localhost:8889/item-1/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 100,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 2,
- title: {
- raw: 'Ordered 2nd',
- rendered: 'Ordered 2nd',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 10,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 3,
- title: {
- raw: 'Ordered 4th',
- rendered: 'Ordered 4th',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 30,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 4,
- title: {
- raw: 'Ordered 3rd',
- rendered: 'Ordered 3rd',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 20,
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- {
- id: 5,
- title: {
- raw: 'Ordered 1st',
- rendered: 'Ordered 1st',
- },
- url: 'http://localhost:8889/',
- attr_title: '',
- description: '',
- type: 'custom',
- type_label: 'Custom Link',
- object: 'custom',
- parent: 0,
- menu_order: 0, // capturing 0 edge case.
- target: '',
- classes: [ '' ],
- xfn: [ '' ],
- },
- ] );
-
- expect( actual ).toEqual( [
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Ordered 1st',
- } ),
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Ordered 2nd',
- } ),
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Ordered 3rd',
- } ),
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Ordered 4th',
- } ),
- } ),
- expect.objectContaining( {
- name: 'core/navigation-link',
- attributes: expect.objectContaining( {
- label: 'Ordered 5th',
- } ),
- } ),
- ] );
- } );
-
- it( 'returns an empty array when menu items argument is an empty array', () => {
- const { innerBlocks: actual } = menuItemsToBlocks( [] );
- expect( actual ).toEqual( [] );
- } );
-} );
diff --git a/packages/menus/.npmrc b/packages/menus/.npmrc
new file mode 100644
index 00000000000000..9cf9495031ecc4
--- /dev/null
+++ b/packages/menus/.npmrc
@@ -0,0 +1 @@
+package-lock=false
\ No newline at end of file
diff --git a/packages/menus/CHANGELOG.md b/packages/menus/CHANGELOG.md
new file mode 100644
index 00000000000000..e04ce921cdfdc4
--- /dev/null
+++ b/packages/menus/CHANGELOG.md
@@ -0,0 +1,5 @@
+
+
+## Unreleased
+
+Initial release.
diff --git a/packages/menus/README.md b/packages/menus/README.md
new file mode 100644
index 00000000000000..0e15b4cdee728d
--- /dev/null
+++ b/packages/menus/README.md
@@ -0,0 +1,36 @@
+# Menus
+
+This package contains common functionality used by the Navigation block (see `@wordpress/block-library`) and the Navigation editor (see `@wordpress/edit-navigation`).
+
+> This package is meant to be used only with WordPress core. Feel free to use it in your own project but please keep in mind that it might never get fully documented.
+
+## Installation
+
+Install the module
+
+```bash
+npm install @wordpress/menus
+```
+
+_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as IE browsers then using [core-js](https://github.com/zloirock/core-js) will add polyfills for these methods._
+
+## API documentation
+
+
+
+# **menuItemsToBlocks**
+
+Converts a flat menu item structure to a nested blocks structure and provides a mapping object between Menu Item IDs and Block clientIds.
+
+_Parameters_
+
+- _menuItems_ `Object[]`: An array of menu items.
+
+_Returns_
+
+- `{ innerBlocks: WPBlock[], mapping: {} }`: a collection of blocks and an object containing mapping between menuItem.id and block.clientId
+
+
+
+
+