diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 0851b0384db1e..73a7821777664 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -54,6 +54,7 @@ import * as shortcode from './shortcode';
import * as spacer from './spacer';
import * as subhead from './subhead';
import * as table from './table';
+import * as tableOfContents from './table-of-contents';
import * as textColumns from './text-columns';
import * as verse from './verse';
import * as video from './video';
@@ -154,6 +155,7 @@ export const registerCoreBlocks = () => {
spacer,
subhead,
table,
+ tableOfContents,
tagCloud,
textColumns,
verse,
diff --git a/packages/block-library/src/table-of-contents/ListItem.js b/packages/block-library/src/table-of-contents/ListItem.js
new file mode 100644
index 0000000000000..e25b6d2ee806a
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/ListItem.js
@@ -0,0 +1,36 @@
+export default function ListItem( { children, noWrapList = false } ) {
+ if ( children ) {
+ const childNodes = children.map( function( childNode, index ) {
+ const { content, anchor, level } = childNode.block;
+
+ const entry = anchor ? (
+
+ { content }
+
+ ) : (
+
+ { content }
+
+ );
+
+ return (
+
+ { entry }
+ { childNode.children ? (
+ { childNode.children }
+ ) : null }
+
+ );
+ } );
+
+ // Don't wrap the list elements in if converting to a core/list.
+ return noWrapList ? childNodes : ;
+ }
+}
diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json
new file mode 100644
index 0000000000000..2b503ce44dd7f
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/block.json
@@ -0,0 +1,17 @@
+{
+ "name": "core/table-of-contents",
+ "category": "common",
+ "attributes": {
+ "headings": {
+ "type": "array",
+ "source": "query",
+ "selector": ".blocks-table-of-contents-entry",
+ "default": [],
+ "query": {
+ "content": { "source": "text" },
+ "anchor": { "source": "attribute", "attribute": "href" },
+ "level": { "source": "attribute", "attribute": "data-level" }
+ }
+ }
+ }
+}
diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js
new file mode 100644
index 0000000000000..b302578d8ea8d
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/edit.js
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+
+const { isEqual } = require( 'lodash' );
+
+/**
+ * Internal dependencies
+ */
+import { getHeadingsList, linearToNestedHeadingList } from './utils';
+import ListItem from './ListItem';
+
+/**
+ * WordPress dependencies
+ */
+import { Component } from '@wordpress/element';
+import { subscribe } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+
+class TableOfContentsEdit extends Component {
+ componentDidMount() {
+ const { attributes, setAttributes } = this.props;
+ let { headings } = attributes;
+
+ // Update the table of contents when changes are made to other blocks.
+ this.unsubscribe = subscribe( () => {
+ this.setState( { headings: getHeadingsList() } );
+ } );
+
+ if ( ! headings ) {
+ headings = getHeadingsList();
+ }
+
+ setAttributes( { headings } );
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ componentDidUpdate( prevProps, prevState ) {
+ const { setAttributes } = this.props;
+ const { headings } = this.state;
+
+ if ( prevState && ! isEqual( headings, prevState.headings ) ) {
+ setAttributes( { headings } );
+ }
+ }
+
+ render() {
+ const { attributes } = this.props;
+ const { headings = [] } = attributes;
+
+ if ( headings.length === 0 ) {
+ return (
+
+ { __(
+ 'Start adding heading blocks to see a Table of Contents here'
+ ) }
+
+ );
+ }
+
+ return (
+
+ { linearToNestedHeadingList( headings ) }
+
+ );
+ }
+}
+
+export default TableOfContentsEdit;
diff --git a/packages/block-library/src/table-of-contents/index.js b/packages/block-library/src/table-of-contents/index.js
new file mode 100644
index 0000000000000..3a2ca88ede24e
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/index.js
@@ -0,0 +1,28 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import metadata from './block.json';
+import save from './save';
+import transforms from './transforms';
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ title: __( 'Table of Contents' ),
+ description: __(
+ 'Add a list of internal links allowing your readers to quickly navigate around.'
+ ),
+ icon: 'list-view',
+ category: 'layout',
+ transforms,
+ edit,
+ save,
+};
diff --git a/packages/block-library/src/table-of-contents/save.js b/packages/block-library/src/table-of-contents/save.js
new file mode 100644
index 0000000000000..934ea75ab02be
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/save.js
@@ -0,0 +1,20 @@
+/**
+ * Internal dependencies
+ */
+import { linearToNestedHeadingList } from './utils';
+import ListItem from './ListItem';
+
+export default function save( props ) {
+ const { attributes } = props;
+ const { headings } = attributes;
+
+ if ( headings.length === 0 ) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/block-library/src/table-of-contents/transforms.js b/packages/block-library/src/table-of-contents/transforms.js
new file mode 100644
index 0000000000000..80c72ac1a103e
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/transforms.js
@@ -0,0 +1,31 @@
+/**
+ * WordPress dependencies
+ */
+import { createBlock } from '@wordpress/blocks';
+import { renderToString } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { linearToNestedHeadingList } from './utils';
+import ListItem from './ListItem';
+
+const transforms = {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/list' ],
+ transform: ( { headings } ) => {
+ return createBlock( 'core/list', {
+ values: renderToString(
+
+ { linearToNestedHeadingList( headings ) }
+
+ ),
+ } );
+ },
+ },
+ ],
+};
+
+export default transforms;
diff --git a/packages/block-library/src/table-of-contents/utils.js b/packages/block-library/src/table-of-contents/utils.js
new file mode 100644
index 0000000000000..010eb17475da3
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/utils.js
@@ -0,0 +1,111 @@
+/**
+ * WordPress dependencies
+ */
+import { select } from '@wordpress/data';
+import { create } from '@wordpress/rich-text';
+
+/**
+ * Takes a flat list of heading parameters and nests them based on each header's
+ * immediate parent's level.
+ *
+ * @param {Array} headingsList The flat list of headings to nest.
+ * @param {number} index The current list index.
+ * @return {Array} The nested list of headings.
+ */
+export function linearToNestedHeadingList( headingsList, index = 0 ) {
+ const nestedHeadingsList = [];
+
+ headingsList.forEach( function( heading, key ) {
+ if ( heading.content === undefined ) {
+ return;
+ }
+
+ // Make sure we are only working with the same level as the first iteration in our set.
+ if ( heading.level === headingsList[ 0 ].level ) {
+ // Check that the next iteration will return a value.
+ // If it does and the next level is greater than the current level,
+ // the next iteration becomes a child of the current interation.
+ if (
+ headingsList[ key + 1 ] !== undefined &&
+ headingsList[ key + 1 ].level > heading.level
+ ) {
+ // We need to calculate the last index before the next iteration that has the same level (siblings).
+ // We then use this last index to slice the array for use in recursion.
+ // This prevents duplicate nodes.
+ let endOfSlice = headingsList.length;
+ for ( let i = key + 1; i < headingsList.length; i++ ) {
+ if ( headingsList[ i ].level === heading.level ) {
+ endOfSlice = i;
+ break;
+ }
+ }
+
+ // We found a child node: Push a new node onto the return array with children.
+ nestedHeadingsList.push( {
+ block: heading,
+ index: index + key,
+ children: linearToNestedHeadingList(
+ headingsList.slice( key + 1, endOfSlice ),
+ index + key + 1
+ ),
+ } );
+ } else {
+ // No child node: Push a new node onto the return array.
+ nestedHeadingsList.push( {
+ block: heading,
+ index: index + key,
+ children: null,
+ } );
+ }
+ }
+ } );
+
+ return nestedHeadingsList;
+}
+
+/**
+ * Gets a list of heading texts, anchors and levels in the current document.
+ *
+ * @return {Array} The list of headings.
+ */
+export function getHeadingsList() {
+ return convertBlocksToTableOfContents( getHeadingBlocks() );
+}
+
+/**
+ * Gets a list of heading blocks in the current document.
+ *
+ * @return {Array} The list of heading blocks.
+ */
+export function getHeadingBlocks() {
+ const editor = select( 'core/block-editor' );
+ return editor
+ .getBlocks()
+ .filter( ( block ) => block.name === 'core/heading' );
+}
+
+/**
+ * Extracts text, anchor and level from a list of heading blocks.
+ *
+ * @param {Array} headingBlocks The list of heading blocks.
+ * @return {Array} The list of heading parameters.
+ */
+export function convertBlocksToTableOfContents( headingBlocks ) {
+ return headingBlocks.map( function( heading ) {
+ // This is a string so that it can be stored/sourced as an attribute in the table of contents
+ // block using a data attribute.
+ const level = heading.attributes.level.toString();
+
+ const headingContent = heading.attributes.content;
+ const anchorContent = heading.attributes.anchor;
+
+ // Strip html from heading to use as the table of contents entry.
+ const content = headingContent
+ ? create( { html: headingContent } ).text
+ : '';
+
+ const anchor = anchorContent ? '#' + anchorContent : '';
+
+ return { content, anchor, level };
+ } );
+}
diff --git a/packages/e2e-tests/fixtures/block-transforms.js b/packages/e2e-tests/fixtures/block-transforms.js
index 3574162eb7a35..4b24247d8bead 100644
--- a/packages/e2e-tests/fixtures/block-transforms.js
+++ b/packages/e2e-tests/fixtures/block-transforms.js
@@ -508,6 +508,10 @@ export const EXPECTED_TRANSFORMS = {
originalBlock: 'Table',
availableTransforms: [ 'Group' ],
},
+ 'core__table-of-contents': {
+ originalBlock: 'Table of Contents',
+ availableTransforms: [ 'Group', 'List' ],
+ },
'core__tag-cloud': {
originalBlock: 'Tag Cloud',
availableTransforms: [ 'Group' ],
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html
new file mode 100644
index 0000000000000..0648739665c54
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json
new file mode 100644
index 0000000000000..3769fd2eed875
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json
@@ -0,0 +1,32 @@
+[
+ {
+ "clientId": "_clientId_0",
+ "name": "core/table-of-contents",
+ "isValid": true,
+ "attributes": {
+ "headings": [
+ {
+ "content": "First Heading",
+ "anchor": "#0-First-Heading",
+ "level": "2"
+ },
+ {
+ "content": "Sub Heading",
+ "anchor": "#1-Sub-Heading",
+ "level": "3"
+ },
+ {
+ "content": "Another Sub Heading",
+ "anchor": "#2-Another-Sub-Heading",
+ "level": "3"
+ },
+ {
+ "content": "A Sub Heading Without Link",
+ "level": "3"
+ }
+ ]
+ },
+ "innerBlocks": [],
+ "originalContent": ""
+ }
+]
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json
new file mode 100644
index 0000000000000..858fc9f39961a
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json
@@ -0,0 +1,11 @@
+[
+ {
+ "blockName": "core/table-of-contents",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "\n\n",
+ "innerContent": [
+ "\n\n"
+ ]
+ }
+]
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html
new file mode 100644
index 0000000000000..9e7a0258eb32e
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html
@@ -0,0 +1,3 @@
+
+
+