diff --git a/blocks/toc/editor.scss b/blocks/toc/editor.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/blocks/toc/index.php b/blocks/toc/index.php
new file mode 100644
index 00000000..6b688bbd
--- /dev/null
+++ b/blocks/toc/index.php
@@ -0,0 +1,7 @@
+ 'wpcom-blocks',
+ 'style' => 'wpcom-blocks',
+ 'editor_style' => 'wpcom-blocks-editor',
+] );
diff --git a/blocks/toc/src/block.json b/blocks/toc/src/block.json
new file mode 100644
index 00000000..ad8d0894
--- /dev/null
+++ b/blocks/toc/src/block.json
@@ -0,0 +1,13 @@
+{
+ "name": "jetpack/toc",
+ "category": "layout",
+ "attributes": {
+ "ListType": {
+ "type": "string",
+ "default": "ol"
+ },
+ "nodes": {
+ "type": "array"
+ }
+ }
+}
\ No newline at end of file
diff --git a/blocks/toc/src/edit.js b/blocks/toc/src/edit.js
new file mode 100644
index 00000000..f6d540dd
--- /dev/null
+++ b/blocks/toc/src/edit.js
@@ -0,0 +1,137 @@
+/**
+ * WordPress dependencies
+ */
+import { BlockControls } from "@wordpress/block-editor";
+import { ToolbarGroup } from "@wordpress/components";
+import { useDispatch, useSelect } from "@wordpress/data";
+import { useEffect } from "@wordpress/element";
+import { __ } from "@wordpress/i18n";
+// import { formatListBullets, formatListNumbered } from "@wordpress/icons";
+import { SVG, Path } from "@wordpress/primitives";
+
+/**
+ * Internal dependencies
+ */
+import Tree from "./tree";
+import { formatListBullets, formatListNumbered } from "./icons";
+
+/**
+ * External dependencies
+ */
+import { kebabCase, last } from "lodash";
+
+const node = (block) => ({
+ children: [],
+ level: block.attributes.level,
+ anchor: block.attributes.anchor,
+ content: block.attributes.content,
+});
+
+const parent = (stack) => last(stack);
+const sibling = (stack) => last(parent(stack).children);
+
+const nestTree = (blocks) => {
+ const rootNode = node({ attributes: { level: 0 } });
+ rootNode.children = [node(blocks[0])];
+ const nodeStack = [rootNode];
+ for (let i = 1; i < blocks.length; i++) {
+ const currentNode = node(blocks[i]);
+ let parentNode = parent(nodeStack);
+ let siblingNode = sibling(nodeStack);
+
+ if (currentNode.level === siblingNode.level) {
+ parentNode.children.push(currentNode);
+ } else if (siblingNode.level < currentNode.level) {
+ nodeStack.push(siblingNode);
+ siblingNode.children.push(currentNode);
+ } else if (currentNode.level < siblingNode.level) {
+ while (currentNode.level < siblingNode.level) {
+ siblingNode = nodeStack.pop();
+ parentNode = parent(nodeStack);
+ }
+ parentNode.children.push(currentNode);
+ }
+ }
+
+ return rootNode.children;
+};
+
+const makeAnchor = (title, usedAnchors) => {
+ const kebabTitle = kebabCase(title);
+ let titleCandidate = kebabTitle,
+ i = 0;
+ while (usedAnchors.has(titleCandidate)) {
+ titleCandidate = kebabTitle + "-" + i++;
+ }
+ return titleCandidate;
+};
+
+export default ({ attributes, setAttributes }) => {
+ const { updateBlockAttributes } = useDispatch("core/editor");
+
+ const { headings, anchors } = useSelect((select) => {
+ const headings = select("core/block-editor")
+ .getBlocks()
+ .filter((block) => block.name === "core/heading");
+
+ const anchors = select("core/block-editor")
+ .getBlocks()
+ // ignore uncommitted anchors so they continue to update
+ .filter(
+ (block) => block.attributes.anchor && !block.attributes.tempAnchor
+ )
+ .map((block) => block.attributes.anchor);
+
+ return { headings, anchors };
+ });
+
+ const usedAnchors = new Set(anchors);
+
+ // We are storing a temp anchor name on anchors we've created so they get updated
+ // as the content changes
+ headings
+ .filter((block) => block.attributes.tempAnchor || !block.attributes.anchor)
+ .forEach((block) => {
+ const newAnchor = makeAnchor(block.attributes.content, usedAnchors);
+ updateBlockAttributes(block.clientId, {
+ anchor: newAnchor,
+ tempAnchor: newAnchor,
+ });
+ usedAnchors.add(newAnchor);
+ });
+
+ const nodes = nestTree(headings);
+ useEffect(() => {
+ setAttributes({ nodes });
+ }, [...headings]);
+
+ const ListType = attributes.ordered ? "ol" : "ul";
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/blocks/toc/src/icons.js b/blocks/toc/src/icons.js
new file mode 100644
index 00000000..ef3ba4c1
--- /dev/null
+++ b/blocks/toc/src/icons.js
@@ -0,0 +1,22 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from "@wordpress/primitives";
+
+export const formatListBullets = (
+
+);
+
+export const formatListNumbered = (
+
+);
+
+export const icon = (
+
+);
diff --git a/blocks/toc/src/index.js b/blocks/toc/src/index.js
new file mode 100644
index 00000000..6f373f13
--- /dev/null
+++ b/blocks/toc/src/index.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from "@wordpress/blocks";
+import { __ } from "@wordpress/i18n";
+
+/**
+ * Internal dependencies
+ */
+import metadata from "./block.json";
+import edit from "./edit";
+import save from "./save";
+import { icon } from "./icons";
+
+const { name, category, attributes } = metadata;
+
+export const registerBlock = () =>
+ registerBlockType(name, {
+ title: __("Table of Contents"),
+ icon,
+ category,
+ attributes,
+ edit,
+ save,
+ });
diff --git a/blocks/toc/src/save.js b/blocks/toc/src/save.js
new file mode 100644
index 00000000..e0be80cf
--- /dev/null
+++ b/blocks/toc/src/save.js
@@ -0,0 +1,10 @@
+/**
+ * Internal dependencies
+ */
+import Tree from "./tree";
+
+export default ({ attributes }) => {
+ const { nodes, ordered } = attributes;
+ const ListType = ordered ? "ol" : "ul";
+ return ;
+};
diff --git a/blocks/toc/src/tree.js b/blocks/toc/src/tree.js
new file mode 100644
index 00000000..50fea9b2
--- /dev/null
+++ b/blocks/toc/src/tree.js
@@ -0,0 +1,22 @@
+const Tree = ({ nodes, ListType }) => {
+ if (!nodes || !nodes.length) {
+ return null;
+ }
+
+ return (
+
+ {nodes.map(node => {
+ const { children, anchor, content } = node;
+
+ return (
+
+ {content}
+
+
+ );
+ })}
+
+ );
+};
+
+export default Tree;
diff --git a/blocks/toc/style.scss b/blocks/toc/style.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/package.json b/package.json
index 9417e7f6..4293ff11 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.7.4",
- "@wordpress/icons": "^1.1.0",
+ "@wordpress/icons": "^1.2.0",
"@wordpress/scripts": "^6.0.0",
"autoprefixer": "^9.7.4",
"eslint": "^6.7.2",
diff --git a/src/index.js b/src/index.js
index 072bffa5..1cfca60e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -29,6 +29,7 @@ import * as layoutGridBlock from '../blocks/layout-grid/src';
import * as motionBackgroundBlock from '../blocks/motion-background/src';
import * as richImageTools from '../blocks/rich-image/src';
import * as starscapeBlock from '../blocks/starscape/src';
+import * as tocBlock from '../blocks/toc/src';
// Instantiate the blocks, adding them to our block category
bauhausCentenaryBlock.registerBlock();
@@ -38,3 +39,5 @@ layoutGridBlock.registerBlock();
motionBackgroundBlock.registerBlock();
richImageTools.registerBlock();
starscapeBlock.registerBlock();
+tocBlock.registerBlock();
+
diff --git a/yarn.lock b/yarn.lock
index a4c49070..7b30cd5f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -686,6 +686,13 @@
dependencies:
regenerator-runtime "^0.13.2"
+"@babel/runtime@^7.8.3":
+ version "7.9.2"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.2.tgz#d90df0583a3a252f09aaa619665367bae518db06"
+ integrity sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==
+ dependencies:
+ regenerator-runtime "^0.13.4"
+
"@babel/template@^7.4.0", "@babel/template@^7.7.4":
version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.4.tgz#428a7d9eecffe27deac0a98e23bf8e3675d2a77b"
@@ -1297,6 +1304,17 @@
webpack "^4.8.3"
webpack-sources "^1.3.0"
+"@wordpress/element@^2.12.0":
+ version "2.12.0"
+ resolved "https://registry.yarnpkg.com/@wordpress/element/-/element-2.12.0.tgz#356ed5dcc812f4f26cea8ea254163724f0e6d378"
+ integrity sha512-XVTEHkqvkaTv6W2vNENnXHPJdc9BGn1vqsbPnfDKC7UzSyr7Vn2yFiJxLgv8wZmkia720afAKOHcaseXoXlYsw==
+ dependencies:
+ "@babel/runtime" "^7.8.3"
+ "@wordpress/escape-html" "^1.7.0"
+ lodash "^4.17.15"
+ react "^16.9.0"
+ react-dom "^16.9.0"
+
"@wordpress/element@^2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@wordpress/element/-/element-2.9.0.tgz#5f374d6ea7ec18d4064b57e3e790f10354e29732"
@@ -1315,6 +1333,13 @@
dependencies:
"@babel/runtime" "^7.4.4"
+"@wordpress/escape-html@^1.7.0":
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/@wordpress/escape-html/-/escape-html-1.7.0.tgz#8f3e1798a23c16deac7bd0e416f11b90f3f31e89"
+ integrity sha512-xDOBo0P3Jnbdbb/UypsQaplsD2k4UXgd/EpKhMAKhDa2m20GxWWmEKW9IB3/5bS4Rh2YZjVM9WL4JyWPUo4hEA==
+ dependencies:
+ "@babel/runtime" "^7.8.3"
+
"@wordpress/eslint-plugin@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@wordpress/eslint-plugin/-/eslint-plugin-3.2.0.tgz#e3958c0b8e9a5421ed0d88a5c92040d230b3a0da"
@@ -1329,6 +1354,15 @@
globals "^12.0.0"
requireindex "^1.2.0"
+"@wordpress/icons@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@wordpress/icons/-/icons-1.2.0.tgz#734c8d684c8a02d688e8d96e5ce57de574d5c388"
+ integrity sha512-9QuXr1FA4byrlQ5SQWoVE/BMzoDqhX1AYuzZeZh9aa9dlHLo8cMQ0hGhbgtrv8GsztRniRZ/ms7UqlkFxXgD8g==
+ dependencies:
+ "@babel/runtime" "^7.8.3"
+ "@wordpress/element" "^2.12.0"
+ "@wordpress/primitives" "^1.2.0"
+
"@wordpress/jest-console@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@wordpress/jest-console/-/jest-console-3.4.0.tgz#3022e947d3c2c3aaf6e419bd4bbe4c648a41c390"
@@ -1354,6 +1388,15 @@
resolved "https://registry.yarnpkg.com/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-2.1.0.tgz#961f42a3f047abe5463a4cb0474a4423bf8b510e"
integrity sha512-NSwcK7GtlmW5O5ZMG7elRKBa9sPws17Sadjlztig6ShOuhlLFeHYk99tUenpmJ/PYOZex4fSJ5e9mqjPyKunjw==
+"@wordpress/primitives@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@wordpress/primitives/-/primitives-1.2.0.tgz#b7aec50761f9b8cc89f0a2176709e6500567d283"
+ integrity sha512-tUs2h9Pq0WW5THlD3L+gBN6reio3bpF01igDmTHYFopzQjhnZTEu3oIRjQWH6gqphBLILcknjhW19EL9ISh1TA==
+ dependencies:
+ "@babel/runtime" "^7.8.3"
+ "@wordpress/element" "^2.12.0"
+ classnames "^2.2.5"
+
"@wordpress/scripts@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@wordpress/scripts/-/scripts-6.0.0.tgz#4f900d92f7b20d2219a0a27635559d47307aa447"
@@ -2461,7 +2504,7 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
-classnames@^2.2.6:
+classnames@^2.2.5, classnames@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@@ -7867,6 +7910,11 @@ regenerator-runtime@^0.13.2:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"
integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==
+regenerator-runtime@^0.13.4:
+ version "0.13.5"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"
+ integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==
+
regenerator-transform@^0.14.0:
version "0.14.1"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"