diff --git a/mu-plugins/blocks/chapter-list/class-chapter-walker.php b/mu-plugins/blocks/chapter-list/class-chapter-walker.php
new file mode 100644
index 00000000..0728d959
--- /dev/null
+++ b/mu-plugins/blocks/chapter-list/class-chapter-walker.php
@@ -0,0 +1,95 @@
+ 0 specifies the number of display levels.
+ *
+ * NOTE: This is identical to `Walker::walk()` except that it ignores orphaned
+ * pages, which are essentially pages whose ancestor is not published.
+ *
+ * @param array $elements An array of elements.
+ * @param int $max_depth The maximum hierarchical depth.
+ * @param mixed ...$args Optional additional arguments.
+ * @return string The hierarchical item output.
+ */
+ public function walk( $elements, $max_depth, ...$args ) {
+ $output = '';
+
+ // invalid parameter or nothing to walk.
+ if ( $max_depth < -1 || empty( $elements ) ) {
+ return $output;
+ }
+
+ $parent_field = $this->db_fields['parent'];
+
+ // flat display.
+ if ( -1 === $max_depth ) {
+ $empty_array = array();
+ foreach ( $elements as $e ) {
+ $this->display_element( $e, $empty_array, 1, 0, $args, $output );
+ }
+ return $output;
+ }
+
+ /*
+ * Need to display in hierarchical order.
+ * Separate elements into two buckets: top level and children elements.
+ * Children_elements is two dimensional array, eg.
+ * Children_elements[10][] contains all sub-elements whose parent is 10.
+ */
+ $top_level_elements = array();
+ $children_elements = array();
+ foreach ( $elements as $e ) {
+ if ( empty( $e->$parent_field ) ) {
+ $top_level_elements[] = $e;
+ } else {
+ $children_elements[ $e->$parent_field ][] = $e;
+ }
+ }
+
+ /*
+ * When none of the elements is top level.
+ * Assume the first one must be root of the sub elements.
+ */
+ if ( empty( $top_level_elements ) ) {
+ $first = array_slice( $elements, 0, 1 );
+ $root = $first[0];
+
+ $top_level_elements = array();
+ $children_elements = array();
+ foreach ( $elements as $e ) {
+ if ( $root->$parent_field === $e->$parent_field ) {
+ $top_level_elements[] = $e;
+ } else {
+ $children_elements[ $e->$parent_field ][] = $e;
+ }
+ }
+ }
+
+ foreach ( $top_level_elements as $e ) {
+ $this->display_element( $e, $children_elements, $max_depth, 0, $args, $output );
+ }
+
+ /*
+ * Here is where it differs from the original `walk()`. The original would
+ * automatically display orphans.
+ */
+
+ return $output;
+ }
+}
diff --git a/mu-plugins/blocks/chapter-list/index.php b/mu-plugins/blocks/chapter-list/index.php
new file mode 100644
index 00000000..4aeffbb2
--- /dev/null
+++ b/mu-plugins/blocks/chapter-list/index.php
@@ -0,0 +1,80 @@
+ __NAMESPACE__ . '\render',
+ )
+ );
+}
+
+/**
+ * Render the block content.
+ *
+ * @param array $attributes Block attributes.
+ * @param string $content Block default content.
+ * @param WP_Block $block Block instance.
+ *
+ * @return string Returns the block markup.
+ */
+function render( $attributes, $content, $block ) {
+ if ( ! isset( $block->context['postId'] ) ) {
+ return '';
+ }
+
+ $post_id = $block->context['postId'];
+ $post_type = get_post_type( $post_id );
+
+ $args = array(
+ 'title_li' => '',
+ 'echo' => 0,
+ 'sort_column' => 'menu_order, title',
+ 'post_type' => $post_type,
+
+ // Use custom walker that excludes display of orphaned pages (an ancestor
+ // of such a page is likely not published and thus this is not accessible).
+ 'walker' => new Chapter_Walker(),
+ );
+
+ $post_type_obj = get_post_type_object( $post_type );
+
+ if ( $post_type_obj && current_user_can( $post_type_obj->cap->read_private_posts ) ) {
+ $args['post_status'] = array( 'publish', 'private' );
+ }
+
+ $content = wp_list_pages( $args );
+
+ $header = '
';
+
+ $wrapper_attributes = get_block_wrapper_attributes();
+ return sprintf(
+ '',
+ $wrapper_attributes,
+ $header,
+ $content
+ );
+}
diff --git a/mu-plugins/blocks/chapter-list/src/block.json b/mu-plugins/blocks/chapter-list/src/block.json
new file mode 100644
index 00000000..6542ea5f
--- /dev/null
+++ b/mu-plugins/blocks/chapter-list/src/block.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "wporg/chapter-list",
+ "version": "0.1.0",
+ "title": "Chapter Navigation",
+ "category": "widgets",
+ "icon": "smiley",
+ "description": "",
+ "usesContext": [ "postId" ],
+ "attributes": {
+ "postType": {
+ "type": "string"
+ }
+ },
+ "supports": {
+ "html": false,
+ "spacing": {
+ "margin": [
+ "top",
+ "bottom"
+ ],
+ "padding": true,
+ "blockGap": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true
+ }
+ },
+ "textdomain": "wporg",
+ "editorScript": "file:./index.js",
+ "style": "file:./style-index.css",
+ "viewScript": "file:./view.js"
+}
diff --git a/mu-plugins/blocks/chapter-list/src/index.js b/mu-plugins/blocks/chapter-list/src/index.js
new file mode 100644
index 00000000..2254aa23
--- /dev/null
+++ b/mu-plugins/blocks/chapter-list/src/index.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { useBlockProps } from '@wordpress/block-editor';
+import ServerSideRender from '@wordpress/server-side-render';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import './style.scss';
+
+const Edit = ( { attributes, name } ) => {
+ const blockProps = useBlockProps();
+ return (
+
+
+
+ );
+};
+
+registerBlockType( metadata.name, {
+ edit: Edit,
+} );
diff --git a/mu-plugins/blocks/chapter-list/src/style.scss b/mu-plugins/blocks/chapter-list/src/style.scss
new file mode 100644
index 00000000..1d3a3dce
--- /dev/null
+++ b/mu-plugins/blocks/chapter-list/src/style.scss
@@ -0,0 +1,176 @@
+.wp-block-wporg-chapter-list {
+ --local--icon-size: calc(var(--wp--custom--body--small--typography--line-height) * 1em);
+
+ font-size: var(--wp--preset--font-size--small);
+ line-height: var(--wp--custom--body--small--typography--line-height);
+
+ @media (max-width: 767px) {
+ border: 1px solid var(--wp--preset--color--light-grey-1);
+ border-radius: 2px;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-size: var(--wp--preset--font-size--normal) !important;
+ }
+
+ a:where(:not(.wp-element-button)) {
+ color: var(--wp--preset--color--charcoal-4);
+ }
+
+ .wporg-chapter-list__header {
+ position: relative;
+
+ @media (max-width: 767px) {
+ padding: 15px var(--wp--preset--spacing--20);
+ }
+
+ .wp-block-heading {
+ margin-bottom: 0;
+ margin-top: 0;
+ }
+ }
+
+ .wporg-chapter-list__list {
+
+ @media (max-width: 767px) {
+ display: none;
+ margin-top: 0;
+ padding: 0 var(--wp--preset--spacing--20) 15px;
+ }
+ }
+
+ ul {
+ margin-top: 0;
+ margin-bottom: 0;
+ list-style-type: none;
+ padding-inline-start: 0;
+ }
+
+ li {
+ margin-block: calc(var(--wp--preset--spacing--20) / 4);
+ color: var(--wp--preset--color--charcoal-4);
+ padding-inline-start: var(--local--icon-size);
+ position: relative;
+
+ &::before {
+ content: "";
+ display: inline-block;
+ position: absolute;
+ inset-inline-start: 0;
+ width: var(--local--icon-size);
+ height: var(--local--icon-size);
+ /* stylelint-disable-next-line function-url-quotes */
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4' fill='none'%3E%3Ccircle cx='2' cy='2' r='1.5' fill='%23656A71'/%3E%3C/svg%3E%0A");
+ mask-repeat: no-repeat;
+ mask-position: center;
+ background-color: var(--wp--preset--color--charcoal-4);
+ }
+ }
+
+ .children {
+
+ /* Shift the children to the left by half the icon size, allowing for the dot width of 4px. */
+ margin-inline-start: calc((var(--local--icon-size) - 4px) * -0.5);
+ }
+
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+
+ &.has-js-control {
+ .page_item_has_children {
+ padding-inline-start: 0;
+
+ &::before {
+ display: none;
+ }
+ }
+
+ .children {
+ display: none;
+ padding-inline-start: var(--local--icon-size);
+
+ &.is-open {
+ display: revert;
+ }
+ }
+ }
+
+ .wporg-chapter-list__button-group {
+ display: flex;
+ align-items: flex-start;
+ }
+
+ .wporg-chapter-list__toggle,
+ .wporg-chapter-list__button-group > button {
+ font-size: inherit;
+ background-color: transparent;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ height: var(--local--icon-size);
+
+ &::before {
+ content: "";
+ display: inline-block;
+ height: var(--local--icon-size);
+ width: var(--local--icon-size);
+ /* stylelint-disable-next-line function-url-quotes */
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15.9899 10.8888L12.0018 14.3071L8.01367 10.8888L8.98986 9.74988L12.0018 12.3315L15.0137 9.74988L15.9899 10.8888Z' fill='%231E1E1E'/%3E%3C/svg%3E%0A");
+ mask-repeat: no-repeat;
+ mask-position: center;
+ transform: rotate(-90deg);
+ background-color: var(--wp--preset--color--charcoal-4);
+ }
+
+ &[aria-expanded="true"]::before {
+ transform: revert;
+ }
+
+ &:focus-visible {
+ outline: 1px dashed var(--wp--preset--color--blueberry-1);
+ }
+ }
+
+ .wporg-chapter-list__toggle {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0 var(--wp--preset--spacing--20) 0 0;
+
+ @media (min-width: 768px) {
+ display: none;
+ }
+
+ &[aria-expanded="true"]::before {
+ background-color: var(--wp--preset--color--charcoal-1);
+ }
+ }
+
+ /* Descendent is `span` if there are children, or `a` if not. */
+ .current_page_item,
+ .current_page_item > span a,
+ .current_page_item > a {
+ color: var(--wp--preset--color--charcoal-1);
+ }
+
+ .current_page_item > span a,
+ .current_page_item > a {
+ font-weight: 700;
+ }
+
+ .current_page_item > span button::before {
+ background-color: var(--wp--preset--color--charcoal-1);
+ }
+}
diff --git a/mu-plugins/blocks/chapter-list/src/view.js b/mu-plugins/blocks/chapter-list/src/view.js
new file mode 100644
index 00000000..727b0389
--- /dev/null
+++ b/mu-plugins/blocks/chapter-list/src/view.js
@@ -0,0 +1,83 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+const init = () => {
+ const container = document.querySelector( '.wp-block-wporg-chapter-list' );
+ const toggleButton = container?.querySelector( '.wporg-chapter-list__toggle' );
+ const list = container?.querySelector( '.wporg-chapter-list__list' );
+
+ if ( toggleButton && list ) {
+ toggleButton.addEventListener( 'click', function () {
+ if ( toggleButton.getAttribute( 'aria-expanded' ) === 'true' ) {
+ toggleButton.setAttribute( 'aria-expanded', false );
+ list.removeAttribute( 'style' );
+ } else {
+ toggleButton.setAttribute( 'aria-expanded', true );
+ list.setAttribute( 'style', 'display:block;' );
+ }
+ } );
+ }
+
+ if ( container ) {
+ container.classList.toggle( 'has-js-control' );
+
+ const parents = container.querySelectorAll( '.page_item_has_children' );
+ parents.forEach( ( item ) => {
+ // Get link, remove (will re-ad later).
+ const link = item.querySelector( ':scope > a' );
+ link.remove();
+
+ // Get submenu
+ const submenu = item.querySelector( ':scope > ul' );
+
+ // Create the toggle button.
+ const button = document.createElement( 'button' );
+ button.setAttribute( 'aria-expanded', false );
+ // translators: %s link title.
+ button.setAttribute( 'aria-label', sprintf( __( 'Open %s submenu', 'wporg' ), link.innerText ) );
+ button.onclick = () => {
+ submenu.classList.toggle( 'is-open' );
+ // This attribute returns a string.
+ const isOpen = button.getAttribute( 'aria-expanded' );
+ button.setAttribute( 'aria-expanded', isOpen === 'false' );
+ if ( isOpen === 'false' ) {
+ button.setAttribute(
+ 'aria-label',
+ // translators: %s link title.
+ sprintf( __( 'Close %s submenu', 'wporg' ), link.innerText )
+ );
+ } else {
+ button.setAttribute(
+ 'aria-label',
+ // translators: %s link title.
+ sprintf( __( 'Open %s submenu', 'wporg' ), link.innerText )
+ );
+ }
+ };
+
+ const buttonGroup = document.createElement( 'span' );
+ buttonGroup.className = 'wporg-chapter-list__button-group';
+ buttonGroup.append( button, link );
+
+ item.insertBefore( buttonGroup, submenu );
+
+ // Automatically open the trail to the current page.
+ if (
+ item.classList.contains( 'current_page_item' ) ||
+ item.classList.contains( 'current_page_ancestor' )
+ ) {
+ submenu.classList.toggle( 'is-open' );
+ button.setAttribute( 'aria-expanded', true );
+ button.setAttribute(
+ 'aria-label',
+ // translators: %s link title.
+ sprintf( __( 'Close %s submenu', 'wporg' ), link.innerText )
+ );
+ }
+ } );
+ }
+};
+
+window.addEventListener( 'load', init );
diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php
index 1317ab89..90fab566 100644
--- a/mu-plugins/loader.php
+++ b/mu-plugins/loader.php
@@ -27,6 +27,7 @@
}
require_once __DIR__ . '/helpers/helpers.php';
+require_once __DIR__ . '/blocks/chapter-list/index.php';
require_once __DIR__ . '/blocks/favorite-button/index.php';
require_once __DIR__ . '/blocks/global-header-footer/blocks.php';
require_once __DIR__ . '/blocks/google-map/index.php';